diff --git a/app/src/App.jsx b/app/src/App.jsx index 467a923..4515c03 100644 --- a/app/src/App.jsx +++ b/app/src/App.jsx @@ -9,6 +9,7 @@ import Report from "./components/Report"; import Bibliotek from "./components/Bibliotek"; import TemplatePicker from "./components/TemplatePicker"; import TemplateSessionEditor from "./components/TemplateSessionEditor"; +import Planlegger from "./components/Planlegger"; function App() { const [session, setSession] = useState(undefined); @@ -40,6 +41,7 @@ function App() { onShowHistoryWithDate: (dateStr) => { setHistoryInitialDate(dateStr); setView("history"); }, onShowTemplatePicker: () => setView("template-picker"), onShowReportWithPrefill: (prefill) => { setReportPrefill(prefill); setView("report"); }, + onShowPlanlegger: () => setView("planlegger"), }; let content; @@ -85,6 +87,8 @@ function App() { setView("logger"); }} />; + else if (view === "planlegger") + content = ; else content = + + + setTheme(theme === "g10" ? "g100" : "g10")}> {theme === "g10" ? : } diff --git a/app/src/components/Planlegger.jsx b/app/src/components/Planlegger.jsx new file mode 100644 index 0000000..e1d9f80 --- /dev/null +++ b/app/src/components/Planlegger.jsx @@ -0,0 +1,621 @@ +import { useState, useEffect, useMemo, useCallback } from "react"; +import { Button, InlineLoading, InlineNotification } from "@carbon/react"; +import { ChevronLeft, ChevronRight, Add, Close, TrashCan } from "@carbon/icons-react"; +import { fetchWeekPlan, saveWeekPlan, deleteWeekPlan, fetchTemplates } from "../lib/db"; +import { buildMuscleMapFromExercises } from "../lib/utils"; +import { toWeekIso } from "../lib/utils"; +import { calcMuscles, MUSCLES, HeatmapBodySVG, useIsMobile } from "../lib/bodymap.jsx"; +import { logDevError } from "../lib/utils"; +import PageShell, { SectionLabel, PageHeading, StickyCta, AccentChip } from "./PageShell"; + +const DAY_LABELS = ["MAN", "TIR", "ONS", "TOR", "FRE", "LØR", "SØN"]; + +function formatWeekLabel(monday) { + const sunday = new Date(monday); + sunday.setUTCDate(monday.getUTCDate() + 6); + + const monthNames = ["JAN", "FEB", "MAR", "APR", "MAI", "JUN", "JUL", "AUG", "SEP", "OKT", "NOV", "DES"]; + const startDay = monday.getUTCDate(); + const endDay = sunday.getUTCDate(); + const endMonth = monthNames[sunday.getUTCMonth()]; + + // ISO week number + const d = new Date(Date.UTC(monday.getUTCFullYear(), monday.getUTCMonth(), monday.getUTCDate())); + const dayOfWeek = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayOfWeek); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const week = Math.ceil(((d - yearStart) / 86400000 + 1) / 7); + + return `UKE ${week} · ${startDay}–${endDay} ${endMonth}`; +} + +function TemplatePicker({ templates, onSelect, onClose }) { + return ( +
+
e.stopPropagation()} + onKeyDown={e => e.key === "Escape" && onClose()} + style={{ + background: "var(--cds-layer-01)", + width: "100%", + maxWidth: 640, + maxHeight: "60vh", + overflowY: "auto", + borderTop: "2px solid var(--accent)", + paddingBottom: 16, + }} + > +
+ + Velg mal + + +
+ + {templates.length === 0 ? ( +

+ Ingen maler opprettet ennå. +

+ ) : ( +
+ {templates.map(tpl => ( + + ))} +
+ )} +
+
+ ); +} + +function DayRow({ dow, date, template, onAdd, onRemove }) { + const dateLabel = date + ? date.toLocaleDateString("no-NO", { day: "numeric", month: "short", timeZone: "UTC" }) + : ""; + + const muscles = useMemo(() => { + if (!template) return []; + const exercises = (template.session_template_exercises || []).map(e => ({ + name: e.name, + primary: e.primary_muscles || [], + secondary: e.secondary_muscles || [], + enabled: true, + })); + const { primary, secondary } = calcMuscles(exercises); + const ids = [...new Set([...primary, ...secondary])]; + return ids.slice(0, 4).map(id => MUSCLES[id]?.label || id); + }, [template]); + + return ( +
+
+
+ {DAY_LABELS[dow - 1]} +
+
+ {dateLabel} +
+
+ + {template ? ( +
+
+
+ {template.name} +
+ {muscles.length > 0 && ( +
+ {muscles.map(m => ( + {m} + ))} +
+ )} +
+ +
+ ) : ( + + )} +
+ ); +} + +export default function Planlegger() { + const [weekOffset, setWeekOffset] = useState(0); + // assignments: { [dow: 1-7]: template | null } + const [assignments, setAssignments] = useState({}); + const [savedAssignments, setSavedAssignments] = useState({}); + const [templates, setTemplates] = useState([]); + const [pickerDow, setPickerDow] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [saveError, setSaveError] = useState(null); + const [hoveredMuscle, setHoveredMuscle] = useState(null); + const [mobileBodyView, setMobileBodyView] = useState("front"); + const isMobile = useIsMobile(); + + const today = useMemo(() => new Date(), []); + const monday = useMemo(() => { + const base = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate())); + const day = base.getUTCDay() || 7; + base.setUTCDate(base.getUTCDate() - (day - 1) + weekOffset * 7); + return base; + }, [today, weekOffset]); + + const weekIso = useMemo(() => toWeekIso(monday), [monday]); + const weekLabel = useMemo(() => formatWeekLabel(monday), [monday]); + + // Dates for each day of the week + const weekDates = useMemo(() => { + return Array.from({ length: 7 }, (_, i) => { + const d = new Date(monday); + d.setUTCDate(monday.getUTCDate() + i); + return d; + }); + }, [monday]); + + // Projected body map from all assigned templates + const projectedData = useMemo(() => { + const allExercises = Object.values(assignments) + .filter(Boolean) + .flatMap(tpl => + (tpl.session_template_exercises || []).map(e => ({ + name: e.name, + primary: e.primary_muscles || [], + secondary: e.secondary_muscles || [], + enabled: true, + })) + ); + const { primary, secondary } = calcMuscles(allExercises); + const muscleMap = buildMuscleMapFromExercises(allExercises); + + // Build counts for HeatmapBodySVG + const counts = {}; + primary.forEach(id => { counts[id] = { primary: 1, secondary: 0 }; }); + secondary.forEach(id => { + if (!counts[id]) counts[id] = { primary: 0, secondary: 1 }; + }); + + return { primary, secondary, muscleMap, counts }; + }, [assignments]); + + const sessionCount = useMemo( + () => Object.values(assignments).filter(Boolean).length, + [assignments] + ); + + const muscleGroupCount = useMemo( + () => new Set([...projectedData.primary, ...projectedData.secondary]).size, + [projectedData] + ); + + const untrainedMuscleIds = useMemo(() => { + const trained = new Set([...projectedData.primary, ...projectedData.secondary]); + return Object.keys(MUSCLES).filter(id => !trained.has(id)); + }, [projectedData]); + + const showForslag = untrainedMuscleIds.length >= 2 && sessionCount > 0; + + const forslagTemplates = useMemo(() => { + if (!showForslag) return []; + const untrainedSet = new Set(untrainedMuscleIds); + return templates + .map(tpl => { + const exercises = (tpl.session_template_exercises || []).map(e => ({ + name: e.name, + primary: e.primary_muscles || [], + secondary: e.secondary_muscles || [], + enabled: true, + })); + const { primary, secondary } = calcMuscles(exercises); + const covered = [...primary, ...secondary].filter(id => untrainedSet.has(id)).length; + return { tpl, covered }; + }) + .filter(x => x.covered > 0) + .sort((a, b) => b.covered - a.covered) + .slice(0, 3) + .map(x => x.tpl); + }, [showForslag, untrainedMuscleIds, templates]); + + const loadPlan = useCallback(async (iso) => { + setLoading(true); + setSaveError(null); + try { + const [{ days }, tpls] = await Promise.all([fetchWeekPlan(iso), fetchTemplates()]); + setTemplates(tpls); + const map = {}; + (days || []).forEach(d => { + map[d.day_of_week] = d.session_templates || null; + }); + setAssignments(map); + setSavedAssignments(map); + } catch (e) { + logDevError("Planlegger/loadPlan", e); + setSaveError(e.message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadPlan(weekIso); + }, [weekIso, loadPlan]); + + // Close picker on Escape + useEffect(() => { + if (pickerDow === null) return; + const handler = (e) => { if (e.key === "Escape") setPickerDow(null); }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [pickerDow]); + + const handleAssign = (dow, tpl) => { + setAssignments(prev => ({ ...prev, [dow]: tpl })); + setPickerDow(null); + }; + + const handleRemove = (dow) => { + setAssignments(prev => ({ ...prev, [dow]: null })); + }; + + const handleSave = async () => { + setSaving(true); + setSaveError(null); + try { + const asgn = Object.entries(assignments) + .filter(([, tpl]) => tpl) + .map(([dow, tpl]) => ({ day_of_week: parseInt(dow, 10), template_id: tpl.id })); + await saveWeekPlan(weekIso, asgn); + setSavedAssignments({ ...assignments }); + } catch (e) { + logDevError("Planlegger/saveWeekPlan", e); + setSaveError(e.message); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + setDeleting(true); + setSaveError(null); + try { + await deleteWeekPlan(weekIso); + setAssignments({}); + setSavedAssignments({}); + setConfirmDelete(false); + } catch (e) { + logDevError("Planlegger/deleteWeekPlan", e); + setSaveError(e.message); + } finally { + setDeleting(false); + } + }; + + const hasSavedPlan = Object.values(savedAssignments).some(Boolean); + + return ( + +
+ + {/* Week navigation */} +
+ + + {weekLabel} + + +
+ + Planlegg uken + + {loading ? ( + + ) : ( + <> + {/* Projected coverage */} + Projisert dekning + +

+ {sessionCount} {sessionCount === 1 ? "økt" : "økter"} · {muscleGroupCount} muskelgrupper +

+ + {/* Body map */} + {isMobile ? ( +
+
+ {["front", "back"].map(v => ( + + ))} +
+
+ +
+
+ ) : ( +
+ {["front", "back"].map(view => ( +
+ +
+ ))} +
+ )} + + {hoveredMuscle && projectedData.muscleMap[hoveredMuscle]?.length > 0 && ( +
+ {MUSCLES[hoveredMuscle]?.label}:{" "} + {projectedData.muscleMap[hoveredMuscle].join(", ")} +
+ )} + + {/* Forslag card */} + {showForslag && ( +
+

+ {untrainedMuscleIds.length} muskelgrupper er ikke dekket denne uken +

+
0 ? 10 : 0 }}> + {untrainedMuscleIds.map(id => ( + {MUSCLES[id]?.label || id} + ))} +
+ {forslagTemplates.length > 0 && ( + <> +

+ Maler som dekker disse: +

+
+ {forslagTemplates.map(tpl => ( + + · {tpl.name} + + ))} +
+ + )} +
+ )} + + {/* Week plan */} + Ukesplan + + {saveError && ( + + )} + +
+ {Array.from({ length: 7 }, (_, i) => i + 1).map(dow => ( + + ))} +
+ + {/* Confirm delete strip */} + {confirmDelete && ( +
+ + Fjerne hele ukeplanen? + +
+ + +
+
+ )} + + )} +
+ + {/* Sticky action bar */} + {!loading && ( + +
+ {hasSavedPlan && !confirmDelete && ( + + )} + +
+
+ )} + + {/* Template picker bottom sheet */} + {pickerDow !== null && ( + handleAssign(pickerDow, tpl)} + onClose={() => setPickerDow(null)} + /> + )} +
+ ); +} diff --git a/app/src/lib/db.js b/app/src/lib/db.js index ebd0b93..1e59cec 100644 --- a/app/src/lib/db.js +++ b/app/src/lib/db.js @@ -281,6 +281,71 @@ export async function checkGymCalendarConflict(gymCalendarId, excludeSessionId = return data; // null = no conflict } +// ── WEEK PLANS ──────────────────────────────────────────────────────── + +export async function fetchWeekPlan(weekIso) { + const { data: plan, error: planError } = await supabase + .from("week_plans") + .select("id, week_iso") + .eq("week_iso", weekIso) + .maybeSingle(); + if (planError) throw planError; + if (!plan) return { plan: null, days: [] }; + + const { data: days, error: daysError } = await supabase + .from("week_plan_days") + .select(` + id, day_of_week, sort_order, + template_id, + session_templates( + id, name, + session_template_exercises( + id, name, primary_muscles, secondary_muscles, sets, reps, sort_order + ) + ) + `) + .eq("plan_id", plan.id) + .order("day_of_week", { ascending: true }); + if (daysError) throw daysError; + + return { plan, days: days || [] }; +} + +// assignments: [{ day_of_week: 1..7, template_id: uuid|null }] +export async function saveWeekPlan(weekIso, assignments) { + const { data: { user } } = await supabase.auth.getUser(); + + const { data: plan, error: upsertError } = await supabase + .from("week_plans") + .upsert({ user_id: user.id, week_iso: weekIso }, { onConflict: "user_id,week_iso" }) + .select("id") + .single(); + if (upsertError) throw upsertError; + + await supabase.from("week_plan_days").delete().eq("plan_id", plan.id); + + if (assignments.length > 0) { + const rows = assignments.map((a, i) => ({ + plan_id: plan.id, + day_of_week: a.day_of_week, + template_id: a.template_id || null, + sort_order: i, + })); + const { error: insertError } = await supabase.from("week_plan_days").insert(rows); + if (insertError) throw insertError; + } + + return plan; +} + +export async function deleteWeekPlan(weekIso) { + const { error } = await supabase + .from("week_plans") + .delete() + .eq("week_iso", weekIso); + if (error) throw error; +} + export async function updateSession(sessionId, exercises, gymCalendarId, { replace = false } = {}) { const enabledExercises = exercises.filter(e => e.enabled && e.name); diff --git a/app/src/lib/utils.js b/app/src/lib/utils.js index e4fc5db..9b86f18 100644 --- a/app/src/lib/utils.js +++ b/app/src/lib/utils.js @@ -112,6 +112,28 @@ export function buildMuscleMapFromSession(session) { return map; } +// Returns ISO week string e.g. "2026-W19" for a given Date. +export function toWeekIso(date) { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const day = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - day); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const week = Math.ceil(((d - yearStart) / 86400000 + 1) / 7); + return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`; +} + +// Returns the Monday Date for a given ISO week string e.g. "2026-W19". +export function weekIsoToMonday(weekIso) { + const [yearStr, weekStr] = weekIso.split("-W"); + const year = parseInt(yearStr, 10); + const week = parseInt(weekStr, 10); + const jan4 = new Date(Date.UTC(year, 0, 4)); + const day = jan4.getUTCDay() || 7; + const monday = new Date(jan4); + monday.setUTCDate(jan4.getUTCDate() - (day - 1) + (week - 1) * 7); + return monday; +} + // Builds muscle-ID → exercise-name map from a recommendations array. export function buildRecMuscleMap(recs) { const map = {};