From 4e421091188ea95ebdbc33806e114ecc3b8cf74d Mon Sep 17 00:00:00 2001 From: Alex Mendiola Date: Sun, 2 Aug 2026 21:33:20 -0400 Subject: [PATCH 1/2] Add notes field to WorkoutLog and session log UI - WorkoutLog model: add notes property (string | null) - SessionLogsForm: add Notes text field with last-session hint - LogWidgets: add editable Notes column to history DataGrid - translations: add routines.lastSession key Co-Authored-By: Claude Sonnet 4.6 --- public/locales/en/translation.json | 1 + src/components/Routines/models/WorkoutLog.ts | 13 ++++++-- .../Routines/widgets/LogWidgets.tsx | 10 ++++++ .../widgets/forms/SessionLogsForm.tsx | 31 +++++++++++++++++-- 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 83bd027c..b04f6a17 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -244,6 +244,7 @@ "weekNr": "Week {{number}}", "iterationNr": "Iteration {{number}}", "backToRoutine": "Back to routine", + "lastSession": "Last session", "minLengthRoutine": "The routine needs to be at least {{number}} weeks long", "maxLengthRoutine": "The routine can be at most {{number}} weeks long", "resultingRoutine": "Resulting routine", diff --git a/src/components/Routines/models/WorkoutLog.ts b/src/components/Routines/models/WorkoutLog.ts index 19a00beb..658f2a53 100644 --- a/src/components/Routines/models/WorkoutLog.ts +++ b/src/components/Routines/models/WorkoutLog.ts @@ -16,6 +16,7 @@ export interface LogEntryForm { repetitionsTarget: number | string | null; weight: number | string; weightTarget: number | string | null; + notes: string; } @@ -44,6 +45,7 @@ export class WorkoutLog { public restTime: number | null; public restTimeTarget: number | null; + public notes: string | null; public exerciseObj?: Exercise; @@ -72,7 +74,9 @@ export class WorkoutLog { rirTarget?: number | null; restTime?: number | null; - restTimeTarget?: number | null + restTimeTarget?: number | null; + + notes?: string | null; }) { this.id = data.id; this.date = typeof data.date === 'string' ? new Date(data.date) : data.date; @@ -99,6 +103,7 @@ export class WorkoutLog { this.restTime = data.restTime || null; this.restTimeTarget = data.restTimeTarget || null; + this.notes = data.notes || null; } get rirString(): string { @@ -131,7 +136,8 @@ export class WorkoutLogAdapter implements Adapter { rirTarget: item.rir_target === null ? null : Number.parseFloat(item.rir_target), restTime: item.rest, - restTimeTarget: item.rest_target + restTimeTarget: item.rest_target, + notes: item.notes || null }); toJson = (item: WorkoutLog) => ({ @@ -154,6 +160,7 @@ export class WorkoutLogAdapter implements Adapter { rir_target: item.rirTarget, rest: item.restTime, - rest_target: item.restTimeTarget + rest_target: item.restTimeTarget, + notes: item.notes || null }); } \ No newline at end of file diff --git a/src/components/Routines/widgets/LogWidgets.tsx b/src/components/Routines/widgets/LogWidgets.tsx index 7c33104d..7e53edf9 100644 --- a/src/components/Routines/widgets/LogWidgets.tsx +++ b/src/components/Routines/widgets/LogWidgets.tsx @@ -52,6 +52,7 @@ export const ExerciseLog = (props: { exercise: Exercise, routineId: number, logE repetitions: logEntry.repetitions, weight: logEntry.weight, rir: logEntry.rir, + notes: logEntry.notes, entry: logEntry })); @@ -98,6 +99,7 @@ export const ExerciseLog = (props: { exercise: Exercise, routineId: number, logE log.repetitions = newRow.repetitions; log.weight = newRow.weight; log.rir = newRow.rir; + log.notes = newRow.notes; editLogQuery.mutate(log); } @@ -153,6 +155,14 @@ export const ExerciseLog = (props: { exercise: Exercise, routineId: number, logE getOptionLabel: (value: any) => value.label, valueOptions: RIR_VALUES_SELECT_LIST, }, + { + field: 'notes', + type: 'string', + flex: 2, + disableColumnMenu: true, + editable: true, + headerName: t('notes'), + }, { field: 'actions', type: 'actions', diff --git a/src/components/Routines/widgets/forms/SessionLogsForm.tsx b/src/components/Routines/widgets/forms/SessionLogsForm.tsx index 4dc86bae..b498fa2a 100644 --- a/src/components/Routines/widgets/forms/SessionLogsForm.tsx +++ b/src/components/Routines/widgets/forms/SessionLogsForm.tsx @@ -3,7 +3,7 @@ import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget"; import { Exercise, getLanguageByShortName, NameAutocompleter, useLanguageQuery } from "@/components/Exercises"; import { RIR_VALUES_SELECT } from "@/components/Routines/models/BaseConfig"; import { LogEntryForm } from "@/components/Routines/models/WorkoutLog"; -import { useAddRoutineLogsQuery, useRoutineDetailQuery } from "@/components/Routines/queries"; +import { useAddRoutineLogsQuery, useRoutineDetailQuery, useRoutineLogQuery } from "@/components/Routines/queries"; import { REP_UNIT_REPETITIONS, SNACKBAR_AUTO_HIDE_DURATION } from "@/core/lib/consts"; import { SwapHoriz } from "@mui/icons-material"; import AddIcon from "@mui/icons-material/Add"; @@ -28,6 +28,7 @@ export const SessionLogsForm = ({ dayId, routineId, selectedDate }: SessionLogsF const [snackbarOpen, setSnackbarOpen] = useState(false); const routineQuery = useRoutineDetailQuery(routineId); const addLogsQuery = useAddRoutineLogsQuery(routineId); + const logsQuery = useRoutineLogQuery(routineId); const languageQuery = useLanguageQuery(); const handleSnackbarClose = () => setSnackbarOpen(false); const [exerciseIdToSwap, setExerciseIdToSwap] = useState(null); @@ -40,6 +41,17 @@ export const SessionLogsForm = ({ dayId, routineId, selectedDate }: SessionLogsF ); } + // Build a map of exerciseId → most recent note from previous logs + const lastNoteByExercise = new Map(); + if (logsQuery.isSuccess) { + const sorted = [...logsQuery.data].sort((a, b) => b.date.getTime() - a.date.getTime()); + for (const log of sorted) { + if (log.notes && !lastNoteByExercise.has(log.exerciseId)) { + lastNoteByExercise.set(log.exerciseId, log.notes); + } + } + } + if (routineQuery.isLoading) { return ; } @@ -86,6 +98,8 @@ export const SessionLogsForm = ({ dayId, routineId, selectedDate }: SessionLogsF weight: l.weight !== '' ? l.weight : null, // eslint-disable-next-line camelcase weight_target: l.weightTarget !== '' ? l.weightTarget : null, + + notes: l.notes !== '' ? l.notes : null, } )); @@ -149,7 +163,8 @@ export const SessionLogsForm = ({ dayId, routineId, selectedDate }: SessionLogsF repetitions: !hasNoIterationData && config.repetitions !== null ? config.repetitions : '', repetitionsTarget: !hasNoIterationData && config.repetitions !== null ? config.repetitions : '', weight: !hasNoIterationData && config.weight !== null ? config.weight : '', - weightTarget: !hasNoIterationData && config.weight !== null ? config.weight : '' + weightTarget: !hasNoIterationData && config.weight !== null ? config.weight : '', + notes: '' }); } } @@ -292,6 +307,18 @@ export const SessionLogsForm = ({ dayId, routineId, selectedDate }: SessionLogsF ))} + + + remove(index)}> From b8de95bc9d8d14943b6ff5765a5c3a1b62b276fd Mon Sep 17 00:00:00 2001 From: Alex Mendiola Date: Sun, 2 Aug 2026 21:46:06 -0400 Subject: [PATCH 2/2] Add tests for WorkoutLog notes field - WorkoutLog.test.ts: adapter fromJson/toJson and constructor tests for notes - SessionLogsForm.test.tsx: mock useRoutineLogQuery, add tests for notes field rendering, last-session hint, and notes submission Co-Authored-By: Claude Sonnet 4.6 --- .../Routines/models/WorkoutLog.test.ts | 62 +++++++++++++++++++ .../widgets/forms/SessionLogsForm.test.tsx | 58 ++++++++++++++++- 2 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 src/components/Routines/models/WorkoutLog.test.ts diff --git a/src/components/Routines/models/WorkoutLog.test.ts b/src/components/Routines/models/WorkoutLog.test.ts new file mode 100644 index 00000000..1f4b77d5 --- /dev/null +++ b/src/components/Routines/models/WorkoutLog.test.ts @@ -0,0 +1,62 @@ +import { WorkoutLog, WorkoutLogAdapter } from "@/components/Routines/models/WorkoutLog"; + +const adapter = new WorkoutLogAdapter(); + +const baseApiResponse = { + id: 'aaaaaaaa-aaaa-aaaa-aaaa-000000000001', + iteration: 1, + date: "2024-08-01", + exercise: 100, + slot_entry: 2, + routine: 1, + session: null, + repetitions_unit: 1, + repetitions: "10.00", + repetitions_target: null, + weight_unit: 1, + weight: "20.00", + weight_target: null, + rir: null, + rir_target: null, + rest: null, + rest_target: null, +}; + +describe('WorkoutLog adapter', () => { + test('fromJson sets notes when present', () => { + const log = adapter.fromJson({ ...baseApiResponse, notes: 'Felt strong' }); + expect(log.notes).toBe('Felt strong'); + }); + + test('fromJson sets notes to null when absent', () => { + const log = adapter.fromJson(baseApiResponse); + expect(log.notes).toBeNull(); + }); + + test('toJson includes notes', () => { + const log = adapter.fromJson({ ...baseApiResponse, notes: 'Heavy day' }); + const json = adapter.toJson(log); + expect(json.notes).toBe('Heavy day'); + }); + + test('toJson sets notes to null when not set', () => { + const log = adapter.fromJson(baseApiResponse); + const json = adapter.toJson(log); + expect(json.notes).toBeNull(); + }); + + test('constructor accepts notes', () => { + const log = new WorkoutLog({ + id: 'aaaaaaaa-aaaa-aaaa-aaaa-000000000001', + date: new Date('2024-08-01'), + iteration: 1, + exerciseId: 100, + slotEntryId: 2, + repetitions: 10, + weight: 20, + rir: null, + notes: 'Test note', + }); + expect(log.notes).toBe('Test note'); + }); +}); diff --git a/src/components/Routines/widgets/forms/SessionLogsForm.test.tsx b/src/components/Routines/widgets/forms/SessionLogsForm.test.tsx index 9e31e317..dec2923d 100644 --- a/src/components/Routines/widgets/forms/SessionLogsForm.test.tsx +++ b/src/components/Routines/widgets/forms/SessionLogsForm.test.tsx @@ -1,11 +1,13 @@ import { render, screen } from '@testing-library/react'; import userEvent from "@testing-library/user-event"; import { useLanguageQuery } from "@/components/Exercises"; -import { useAddRoutineLogsQuery, useRoutineDetailQuery } from "@/components/Routines/queries"; +import { useAddRoutineLogsQuery, useRoutineDetailQuery, useRoutineLogQuery } from "@/components/Routines/queries"; import { SessionLogsForm } from '@/components/Routines/widgets/forms/SessionLogsForm'; import { DateTime } from "luxon"; import { testLanguages } from "@/tests/exerciseTestdata"; import { testRoutine1 } from "@/tests/workoutRoutinesTestData"; +import { testWorkoutLogs } from "@/tests/workoutLogsRoutinesTestData"; +import { WorkoutLog } from "@/components/Routines/models/WorkoutLog"; import type { Mock } from 'vitest'; @@ -17,6 +19,7 @@ describe('SessionLogsForm', () => { const mockUseLanguageQuery = useLanguageQuery as Mock; const mockAddLogsQuery = useAddRoutineLogsQuery as Mock; const mockRoutineDetailQuery = useRoutineDetailQuery as Mock; + const mockRoutineLogQuery = useRoutineLogQuery as Mock; const mockMutateAsync = vi.fn(); beforeEach(() => { @@ -34,6 +37,10 @@ describe('SessionLogsForm', () => { isLoading: false, data: testLanguages, }); + mockRoutineLogQuery.mockReturnValue({ + isSuccess: false, + data: [], + }); }); @@ -124,4 +131,53 @@ describe('SessionLogsForm', () => { // Assert expect(screen.queryByText('Squats')).not.toBeInTheDocument(); }); + + test('renders notes field for each log entry', async () => { + render(); + + const notesFields = screen.getAllByRole('textbox', { name: /notes/i }); + expect(notesFields.length).toBeGreaterThan(0); + }); + + test('shows last-session note as helper text when previous log has notes', async () => { + const logWithNote = new WorkoutLog({ + ...testWorkoutLogs[0], + exerciseId: 345, + notes: 'Felt great last time', + }); + mockRoutineLogQuery.mockReturnValue({ + isSuccess: true, + data: [logWithNote], + }); + + render(); + + expect(screen.getAllByText(/Felt great last time/).length).toBeGreaterThan(0); + }); + + test('submits notes value with log entry', async () => { + const user = userEvent.setup(); + + render(); + + const notesField = screen.getAllByRole('textbox', { name: /notes/i })[0]; + await user.click(notesField); + await user.type(notesField, 'Good session'); + await user.click(screen.getByRole('button', { name: /submit/i })); + + expect(mockMutateAsync).toHaveBeenCalled(); + expect(mockMutateAsync.mock.calls[0][0][0]).toMatchObject({ notes: 'Good session' }); + }); });