Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion app/src/components/Bibliotek.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
20 changes: 8 additions & 12 deletions app/src/components/History.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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]
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 => {
Expand All @@ -285,13 +275,19 @@ 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 {
setLoadingSession(false);
}
};

// 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"));
Expand Down
6 changes: 3 additions & 3 deletions app/src/components/Home.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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={{
Expand All @@ -176,7 +176,7 @@ export default function Home({ onShowHistoryWithDate }) {
{tooltip && tooltip.names.length > 0 && (
<div style={{
position: "absolute",
left: Math.min(tooltip.x + 10, (weekStripRef.current?.offsetWidth || 300) - 160),
left: Math.min(tooltip.x + 10, tooltip.maxLeft ?? 140),
top: Math.max(tooltip.y - 10, 4),
background: "var(--surface-card)",
border: "1px solid var(--border-subtle-wl)",
Expand Down
10 changes: 5 additions & 5 deletions app/src/components/MuscleMap.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
return "var(--cds-support-error)";
}

export const initialState = {

Check warning on line 37 in app/src/components/MuscleMap.jsx

View workflow job for this annotation

GitHub Actions / Test, Build and Deploy

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
step: "upload",
images: [],
exercises: [],
Expand All @@ -54,7 +54,7 @@
sessionDate: localDateStr(),
};

export function reducer(state, action) {

Check warning on line 57 in app/src/components/MuscleMap.jsx

View workflow job for this annotation

GitHub Actions / Test, Build and Deploy

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
switch (action.type) {
case "RESET":
return { ...initialState, sessionDate: localDateStr() };
Expand Down Expand Up @@ -140,10 +140,6 @@
fetchLibraryExercises().then(setLibraryExercises).catch(() => {});
}, []);

useEffect(() => {
if (step === "confirm") setUseTodayDate(true);
}, [step]);

useEffect(() => {
if (step !== "confirm") return;
fetchGymSessionsByDate(sessionDate)
Expand All @@ -161,8 +157,11 @@
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();
Expand Down Expand Up @@ -210,6 +209,7 @@
}
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." });
Expand Down
3 changes: 2 additions & 1 deletion app/src/components/Planlegger.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
33 changes: 18 additions & 15 deletions app/src/components/Report.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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); };
Expand Down Expand Up @@ -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");
Expand Down
Loading