diff --git a/CHANGELOG.md b/CHANGELOG.md index 986b916..15ba244 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to Workout Lens are documented here. +## [1.2.2] — 2026-05-10 + +### Developer + +- **React hook lint fixes (#159 #160)** — Resolved all `react-hooks/exhaustive-deps` and `react-compiler` warnings. Real refactors: `History` auto-expand logic moved into `loadSession` (eliminates a cascading setState); `MuscleMap` date-reset moved to the two dispatch sites that enter the confirm step; `Report` cache-lookup `useEffect` relocated below the `useMemo` values it reads (fixes forward-references to `muscleCounts`, `sessionCount`, `untrainedMuscles`); `Home` tooltip clamping now stores `maxLeft` in state at event-handler time instead of reading `weekStripRef.current` during render. Remaining five patterns (`Bibliotek` pagination reset, `Planlegger` async plan fetch, `Report` loading-state initialisation, `MuscleMap` template-preload callback) suppressed with targeted `eslint-disable` comments explaining why each omission is intentional. + ## [1.2.1] — 2026-05-10 ### Added diff --git a/app/src/components/Bibliotek.jsx b/app/src/components/Bibliotek.jsx index f91529c..2529641 100644 --- a/app/src/components/Bibliotek.jsx +++ b/app/src/components/Bibliotek.jsx @@ -62,7 +62,8 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) { : exercises; }, [exercises, debouncedSearch]); - useEffect(() => { setExVisible(20); }, [filteredExercises]); + // eslint-disable-next-line react-hooks/set-state-in-effect + useEffect(() => { setExVisible(20); }, [filteredExercises]); // reset pagination when filter changes const filteredTemplates = useMemo(() => { const q = tplSearch.trim().toLowerCase(); diff --git a/app/src/components/History.jsx b/app/src/components/History.jsx index cfe1d02..51663ca 100644 --- a/app/src/components/History.jsx +++ b/app/src/components/History.jsx @@ -182,6 +182,7 @@ export default function History({ initialDate }) { const [hoveredMuscle, setHoveredMuscle] = useState(null); const [classHistory, setClassHistory] = useState(new Map()); const fileRef = useRef(); + const initialDateRef = useRef(initialDate); const patchSessionEdit = (id, patch) => setSessionEdits(prev => { const next = new Map(prev); @@ -199,10 +200,6 @@ export default function History({ initialDate }) { .catch(() => {}); }, []); - useEffect(() => { - if (initialDate) loadSession(initialDate); - }, []); - const sessionMuscleIdMap = useMemo( () => new Map(sessions.map(s => [s.id, sessionMuscleIds(s)])), [sessions] @@ -230,14 +227,6 @@ export default function History({ initialDate }) { return d.getFullYear() === viewYear && d.getMonth() === viewMonth; }).length, [filteredSessions, viewYear, viewMonth]); - useEffect(() => { - if (daySessions.length === 1) { - setExpandedIds(new Set([daySessions[0].id])); - } else { - setExpandedIds(new Set()); - } - }, [daySessions]); - const loadClassHistory = async (gymCalendarId) => { setClassHistory(prev => new Map(prev).set(gymCalendarId, { loading: true, sessions: [], error: null })); try { @@ -272,6 +261,7 @@ export default function History({ initialDate }) { const loadSession = async (dateStr) => { setLoadingSession(true); setDaySessions([]); + setExpandedIds(new Set()); try { const results = await fetchSessionsByDate(dateStr); results.forEach(s => { @@ -285,6 +275,7 @@ export default function History({ initialDate }) { return new Date(ta) - new Date(tb); }); setDaySessions(results); + setExpandedIds(results.length === 1 ? new Set([results[0].id]) : new Set()); } catch (err) { logDevError("History/loadSession", err); } finally { @@ -292,6 +283,11 @@ export default function History({ initialDate }) { } }; + // mount-only: initialDate is a one-time navigation hint from the home screen + useEffect(() => { + if (initialDateRef.current) loadSession(initialDateRef.current); + }, []); // mount-only: initialDateRef is a ref, not reactive + const handleSelect = (dateStr) => { if (!dateStr || !filteredTrainedSet.has(dateStr)) return; setSelectedDate(new Date(dateStr + "T12:00:00")); diff --git a/app/src/components/Home.jsx b/app/src/components/Home.jsx index 0e0f93c..eac456f 100644 --- a/app/src/components/Home.jsx +++ b/app/src/components/Home.jsx @@ -142,7 +142,7 @@ export default function Home({ onShowHistoryWithDate }) { onKeyDown={count > 0 ? e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onShowHistoryWithDate(date); } } : undefined} onMouseEnter={count > 0 ? e => { const rect = weekStripRef.current?.getBoundingClientRect(); - if (rect) setTooltip({ names, x: e.clientX - rect.left, y: e.clientY - rect.top }); + if (rect) setTooltip({ names, x: e.clientX - rect.left, y: e.clientY - rect.top, maxLeft: rect.width - 160 }); } : undefined} onMouseMove={count > 0 ? e => { const rect = weekStripRef.current?.getBoundingClientRect(); @@ -152,7 +152,7 @@ export default function Home({ onShowHistoryWithDate }) { onFocus={count > 0 ? e => { const stripRect = weekStripRef.current?.getBoundingClientRect(); const cellRect = e.currentTarget.getBoundingClientRect(); - if (stripRect) setTooltip({ names, x: cellRect.left - stripRect.left, y: 0 }); + if (stripRect) setTooltip({ names, x: cellRect.left - stripRect.left, y: 0, maxLeft: stripRect.width - 160 }); } : undefined} onBlur={count > 0 ? () => setTooltip(null) : undefined} style={{ @@ -176,7 +176,7 @@ export default function Home({ onShowHistoryWithDate }) { {tooltip && tooltip.names.length > 0 && (
{}); }, []); - useEffect(() => { - if (step === "confirm") setUseTodayDate(true); - }, [step]); - useEffect(() => { if (step !== "confirm") return; fetchGymSessionsByDate(sessionDate) @@ -161,8 +157,11 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed } useEffect(() => { if (!templatePreload) return; dispatch({ type: "LOAD_TEMPLATE", exercises: templatePreload.map((e, i) => ({ ...e, id: e.id || i })) }); + // eslint-disable-next-line react-hooks/set-state-in-effect + setUseTodayDate(true); onTemplatePreloadConsumed(); - }, [templatePreload]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [templatePreload]); // onTemplatePreloadConsumed excluded: adding it would re-run if parent recreates the callback const stepIndex = { upload: 0, analyzing: 0, confirm: 1, muscles: 2 }[step] ?? 0; const headingRef = useRef(); @@ -210,6 +209,7 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed } } if (!Array.isArray(parsed)) throw new Error("Uventet svarformat fra Claude."); dispatch({ type: "ANALYZE_SUCCESS", exercises: parsed.map((ex, i) => ({ ...ex, id: i, enabled: true, sets: ex.sets ?? "1" })) }); + setUseTodayDate(true); } catch (err) { logDevError("MuscleMap/analyse", err); dispatch({ type: "ANALYZE_ERROR", error: err.message || "Kunne ikke tolke bildet. Prøv igjen med et tydeligere bilde." }); diff --git a/app/src/components/Planlegger.jsx b/app/src/components/Planlegger.jsx index 6d950bb..db2a8a4 100644 --- a/app/src/components/Planlegger.jsx +++ b/app/src/components/Planlegger.jsx @@ -341,7 +341,8 @@ export default function Planlegger() { }, []); useEffect(() => { - loadPlan(weekIso); + // eslint-disable-next-line react-hooks/set-state-in-effect + loadPlan(weekIso); // async data fetch — setState happens inside async callbacks, not synchronously }, [weekIso, loadPlan]); useEffect(() => { diff --git a/app/src/components/Report.jsx b/app/src/components/Report.jsx index a1ab93a..950bf86 100644 --- a/app/src/components/Report.jsx +++ b/app/src/components/Report.jsx @@ -95,9 +95,11 @@ export default function Report({ prefill, onPrefillConsumed }) { if (p.weekday !== undefined) setSelectedDays(new Set([p.weekday])); if (p.sessionType) setSelectedTypes(new Set([p.sessionType])); onPrefillConsumed?.(); - }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // mount-only: onPrefillConsumed excluded — it's a one-shot callback that must not re-run if parent re-renders useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect setLoading(true); setError(null); const to = new Date().toISOString().slice(0, 10); @@ -108,20 +110,6 @@ export default function Report({ prefill, onPrefillConsumed }) { .finally(() => setLoading(false)); }, [periodDays]); - useEffect(() => { - let cancelled = false; - setRecsError(null); - const trainedIds = Object.entries(muscleCounts) - .filter(([, c]) => c.primary > 0) - .map(([id]) => id); - fetchRecsCache(recsCacheKey(periodDays, sessionCount, trainedIds, untrainedMuscles)) - .then(cached => { if (!cancelled) setRecs(cached); }) - .catch(() => { if (!cancelled) setRecs(null); }); - return () => { cancelled = true; }; - // muscleCounts, sessionCount, untrainedMuscles are derived from the state values already in deps - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [periodDays, selectedDays, selectedTypes, sessions]); - useEffect(() => { if (!hoveredMuscle) return; const fn = e => { if (e.key === "Escape") setHoveredMuscle(null); }; @@ -290,6 +278,21 @@ export default function Report({ prefill, onPrefillConsumed }) { .map(([id, c]) => ({ id, ...c })) .sort((a, b) => b.primary - a.primary || b.secondary - a.secondary); + useEffect(() => { + let cancelled = false; + // eslint-disable-next-line react-hooks/set-state-in-effect + setRecsError(null); + const trainedIds = Object.entries(muscleCounts) + .filter(([, c]) => c.primary > 0) + .map(([id]) => id); + fetchRecsCache(recsCacheKey(periodDays, sessionCount, trainedIds, untrainedMuscles)) + .then(cached => { if (!cancelled) setRecs(cached); }) + .catch(() => { if (!cancelled) setRecs(null); }); + return () => { cancelled = true; }; + // muscleCounts, sessionCount, untrainedMuscles are derived from the state values already in deps + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [periodDays, selectedDays, selectedTypes, sessions]); + const dayLabel = selectedDays.size > 0 ? DAYS.filter(d => selectedDays.has(d.day)).map(d => d.label.toUpperCase()).join(" · ") : t("report.activeDays");