diff --git a/src/components/Calendar/Components/CalendarComponent.tsx b/src/components/Calendar/Components/CalendarComponent.tsx index 56f7bffe..9e496631 100644 --- a/src/components/Calendar/Components/CalendarComponent.tsx +++ b/src/components/Calendar/Components/CalendarComponent.tsx @@ -38,8 +38,8 @@ const CalendarComponent = (props: { isStandalone?: boolean }) => { const weightsQuery = useBodyWeightQuery(); const sessionQuery = useSessionsQuery({ filtersetQuerySessions: { - "date__gte": dateToYYYYMMDD(startOfMonth), - "date__lte": dateToYYYYMMDD(endOfMonth), + "datetime_start__gte": startOfMonth.toISOString(), + "datetime_start__lt": new Date(currentYear, currentMonth + 1, 1).toISOString(), }, filtersetQueryLogs: { "date__gte": dateToYYYYMMDD(startOfMonth), @@ -99,7 +99,7 @@ const CalendarComponent = (props: { isStandalone?: boolean }) => { date: new Date(date), weightEntry: weightsQuery.data?.find(w => isSameDay(w.date, date)), measurements: measurements.filter(m => isSameDay(m.date, date)) || [], - workoutSession: sessionQuery.data?.find(m => isSameDay(m.date, date)) || undefined, + workoutSession: sessionQuery.data?.find(m => isSameDay(m.datetimeStart, date)) || undefined, nutritionLogs: nutritionDiaryQuery.data?.filter(m => isSameDay(m.datetime, date)) || [], }); date.setDate(date.getDate() + 1); diff --git a/src/components/Routines/api/session.test.ts b/src/components/Routines/api/session.test.ts index 8dea6030..1cbac677 100644 --- a/src/components/Routines/api/session.test.ts +++ b/src/components/Routines/api/session.test.ts @@ -84,11 +84,10 @@ describe("Session service tests", () => { "id": SESSION_UUID, "routine": 39764, "day": null, - "date": "2025-08-07", "notes": null, "impression": "3", - "time_start": "20:10:58", - "time_end": "23:28:21" + "datetime_start": "2025-08-07T20:10:58+02:00", + "datetime_end": "2025-08-07T23:28:21+02:00" }, ] } @@ -163,21 +162,21 @@ describe("Session service tests", () => { results: [ { id: SESSION_UUID, routine: 39764, day: 5, - date: "2025-08-07", notes: "ok", impression: "3", - time_start: "20:10:58", time_end: "23:28:21", + datetime_start: "2025-08-07T20:10:58+02:00", + datetime_end: "2025-08-07T23:28:21+02:00", }, ], }; (axios.get as Mock).mockResolvedValue({ data: apiResponse }); - const result = await searchSession({ routine: 39764, date: "2025-08-07" }); + const result = await searchSession({ routine: 39764, datetime_start__date: "2025-08-07" }); const url = (axios.get as Mock).mock.calls[0][0] as string; expect(url).toContain("/api/v2/workoutsession/"); expect(url).toContain("routine=39764"); - expect(url).toContain("date=2025-08-07"); + expect(url).toContain("datetime_start__date=2025-08-07"); expect(result).toBeInstanceOf(WorkoutSession); expect(result?.id).toBe(SESSION_UUID); }); @@ -205,9 +204,9 @@ describe("Session service tests", () => { test('addSession POSTs the serialized session and returns the parsed session', async () => { (axios.post as Mock).mockResolvedValue({ data: { - id: SESSION_UUID_2, routine: 39764, day: 5, date: "2025-08-07", + id: SESSION_UUID_2, routine: 39764, day: 5, notes: null, impression: "3", - time_start: null, time_end: null, + datetime_start: "2025-08-07T00:00:00+02:00", datetime_end: null, }, }); @@ -215,11 +214,10 @@ describe("Session service tests", () => { id: null, routineId: 39764, dayId: 5, - date: new Date(2025, 7, 7), notes: null, impression: "3", - timeStart: null, - timeEnd: null, + datetimeStart: new Date(2025, 7, 7, 20, 10), + datetimeEnd: null, })); expect(axios.post).toHaveBeenCalledTimes(1); @@ -228,11 +226,10 @@ describe("Session service tests", () => { expect(body).toEqual({ routine: 39764, day: 5, - date: "2025-08-07", notes: null, impression: "3", - time_start: null, - time_end: null, + datetime_start: new Date(2025, 7, 7, 20, 10).toISOString(), + datetime_end: null, }); expect(result).toBeInstanceOf(WorkoutSession); expect(result.id).toBe(SESSION_UUID_2); @@ -241,9 +238,9 @@ describe("Session service tests", () => { test('editSession PATCHes /workoutsession// with the serialized session', async () => { (axios.patch as Mock).mockResolvedValue({ data: { - id: SESSION_UUID, routine: 39764, day: 5, date: "2025-08-07", + id: SESSION_UUID, routine: 39764, day: 5, notes: "edited", impression: "3", - time_start: null, time_end: null, + datetime_start: "2025-08-07T00:00:00+02:00", datetime_end: null, }, }); @@ -251,11 +248,10 @@ describe("Session service tests", () => { id: SESSION_UUID, routineId: 39764, dayId: 5, - date: new Date(2025, 7, 7), notes: "edited", impression: "3", - timeStart: null, - timeEnd: null, + datetimeStart: new Date(2025, 7, 7, 20, 10), + datetimeEnd: null, })); expect(axios.patch).toHaveBeenCalledTimes(1); @@ -265,11 +261,10 @@ describe("Session service tests", () => { id: SESSION_UUID, routine: 39764, day: 5, - date: "2025-08-07", notes: "edited", impression: "3", - time_start: null, - time_end: null, + datetime_start: new Date(2025, 7, 7, 20, 10).toISOString(), + datetime_end: null, }); expect(result.notes).toBe("edited"); }); diff --git a/src/components/Routines/models/WorkoutSession.ts b/src/components/Routines/models/WorkoutSession.ts index 66d2847c..21b25e39 100644 --- a/src/components/Routines/models/WorkoutSession.ts +++ b/src/components/Routines/models/WorkoutSession.ts @@ -2,7 +2,6 @@ import { Day } from "@/components/Routines/models/Day"; import { WorkoutLog } from "@/components/Routines/models/WorkoutLog"; import i18n from 'i18next'; import { Adapter } from "@/core/lib/Adapter"; -import { dateTimeToHHMM, dateToYYYYMMDD, HHMMToDateTime, yyyymmddToDate } from "@/core/lib/date"; export const NOTES_MAX_LENGTH = 1000 as const; @@ -14,11 +13,10 @@ interface WorkoutSessionParams { id: string | null; dayId: number; routineId: number; - date: Date; + datetimeStart: Date; + datetimeEnd: Date | null; notes: string | null; impression: string; - timeStart: Date | null; - timeEnd: Date | null; dayObj?: Day; logs?: WorkoutLog[]; } @@ -28,11 +26,10 @@ export class WorkoutSession { id: string | null; dayId: number; routineId: number; - date: Date; + datetimeStart: Date; + datetimeEnd: Date | null; notes: string | null; impression: string; - timeStart: Date | null; - timeEnd: Date | null; dayObj?: Day; logs: WorkoutLog[] = []; @@ -40,11 +37,10 @@ export class WorkoutSession { this.id = params.id; this.dayId = params.dayId; this.routineId = params.routineId; - this.date = params.date; + this.datetimeStart = params.datetimeStart; + this.datetimeEnd = params.datetimeEnd; this.notes = params.notes; this.impression = params.impression; - this.timeStart = params.timeStart; - this.timeEnd = params.timeEnd; if (params.dayObj) { this.dayObj = params.dayObj; } @@ -70,10 +66,10 @@ export class WorkoutSession { } get textRepresentation(): string { - const time = this.timeStart && this.timeEnd ? `${this.timeStart.toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit' - })} - ${this.timeEnd.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} /` : ""; + const format = (date: Date) => date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const time = this.datetimeEnd + ? `${format(this.datetimeStart)} - ${format(this.datetimeEnd)} /` + : `${format(this.datetimeStart)} /`; const notes = this.notes ?? ""; @@ -89,11 +85,10 @@ export class WorkoutSessionAdapter implements Adapter { id: item.id, dayId: item.day!, routineId: item.routine!, - date: yyyymmddToDate(item.date!), + datetimeStart: new Date(item.datetime_start), + datetimeEnd: item.datetime_end ? new Date(item.datetime_end) : null, notes: item.notes !== undefined ? item.notes : null, impression: item.impression!, - timeStart: item.time_start !== undefined ? HHMMToDateTime(item.time_start) : null, - timeEnd: item.time_end !== undefined ? HHMMToDateTime(item.time_end) : null, dayObj: item.dayObj, logs: item.logs }); @@ -102,13 +97,12 @@ export class WorkoutSessionAdapter implements Adapter { toJson = (item: WorkoutSession) => ({ ...(item.id != null ? { id: item.id } : {}), day: item.dayId, - date: dateToYYYYMMDD(item.date), routine: item.routineId, notes: item.notes, impression: item.impression, // eslint-disable-next-line camelcase - time_start: dateTimeToHHMM(item.timeStart), + datetime_start: item.datetimeStart.toISOString(), // eslint-disable-next-line camelcase - time_end: dateTimeToHHMM(item.timeEnd), + datetime_end: item.datetimeEnd ? item.datetimeEnd.toISOString() : null, }); -} \ No newline at end of file +} diff --git a/src/components/Routines/widgets/forms/SessionForm.test.tsx b/src/components/Routines/widgets/forms/SessionForm.test.tsx index cf04f48a..0f6d5228 100644 --- a/src/components/Routines/widgets/forms/SessionForm.test.tsx +++ b/src/components/Routines/widgets/forms/SessionForm.test.tsx @@ -62,7 +62,7 @@ describe('SessionForm', () => { // Assert expect(mockUseFindSessionQuery).toHaveBeenCalledWith( routineId, - { routine: routineId, date: '2024-05-01', day: dayId } + { routine: routineId, datetime_start__date: '2024-05-01', day: dayId } ); // Act - the parent selects another date @@ -80,7 +80,7 @@ describe('SessionForm', () => { // Assert expect(mockUseFindSessionQuery).toHaveBeenLastCalledWith( routineId, - { routine: routineId, date: '2024-05-08', day: dayId } + { routine: routineId, datetime_start__date: '2024-05-08', day: dayId } ); }); @@ -129,11 +129,11 @@ describe('SessionForm', () => { id: 'bbbbbbbb-bbbb-bbbb-bbbb-000000000001', dayId: dayId, routineId: routineId, - date: date.toJSDate(), + notes: 'Test notes', impression: '3', - timeStart: timeStart.toJSDate(), - timeEnd: timeEnd.toJSDate() + datetimeStart: timeStart.toJSDate(), + datetimeEnd: timeEnd.toJSDate() }); mockUseFindSessionQuery.mockReturnValue({ @@ -219,6 +219,61 @@ describe('SessionForm', () => { expect(editMutateAsync).not.toHaveBeenCalled(); }); + test('shows what the server rejected', async () => { + + // Arrange + mockUseFindSessionQuery.mockReturnValue({ + data: null, + isLoading: false, + isSuccess: true + }); + mockUseAddSessionQuery.mockReturnValue({ + data: null, + isPending: false, + mutateAsync: addMutateAsync, + isError: true, + error: { + message: 'Request failed with status code 400', + response: { data: { datetime_end: ['A session cannot be longer than 5 hours.'] } } + }, + }); + + // Act + renderForm(DateTime.fromISO('2024-05-01')); + + // Assert + expect(screen.getByText(/A session cannot be longer than 5 hours/)).toBeInTheDocument(); + }); + + test('submits a session that runs past midnight with the end on the next day', async () => { + + // Arrange + const user = userEvent.setup(); + mockUseFindSessionQuery.mockReturnValue({ + data: new WorkoutSession({ + id: null, + dayId: dayId, + routineId: routineId, + notes: '', + impression: '2', + datetimeStart: DateTime.fromISO('2024-05-01T23:00').toJSDate(), + datetimeEnd: DateTime.fromISO('2024-05-01T01:30').toJSDate(), + }), + isLoading: false, + isSuccess: true + }); + + // Act + renderForm(DateTime.fromISO('2024-05-01')); + await user.click(screen.getByRole('button', { name: /submit/i })); + + // Assert + await waitFor(() => expect(editMutateAsync).toHaveBeenCalled()); + const draft = editMutateAsync.mock.calls[0][0] as WorkoutSession; + expect(draft.datetimeStart).toEqual(DateTime.fromISO('2024-05-01T23:00').toJSDate()); + expect(draft.datetimeEnd).toEqual(DateTime.fromISO('2024-05-02T01:30').toJSDate()); + }); + test('submits an existing session through the edit mutation', async () => { // Arrange @@ -227,11 +282,11 @@ describe('SessionForm', () => { id: 'bbbbbbbb-bbbb-bbbb-bbbb-000000000001', dayId: dayId, routineId: routineId, - date: DateTime.fromISO('2024-05-01').toJSDate(), + notes: 'Test notes', impression: '3', - timeStart: null, - timeEnd: null + datetimeStart: DateTime.fromISO('2024-05-01').toJSDate(), + datetimeEnd: null }); mockUseFindSessionQuery.mockReturnValue({ data: mockSession, diff --git a/src/components/Routines/widgets/forms/SessionForm.tsx b/src/components/Routines/widgets/forms/SessionForm.tsx index e43f9323..2bfbb076 100644 --- a/src/components/Routines/widgets/forms/SessionForm.tsx +++ b/src/components/Routines/widgets/forms/SessionForm.tsx @@ -8,6 +8,7 @@ import { import { useAddSessionQuery, useEditSessionQuery, useFindSessionQuery } from "@/components/Routines/queries"; import { WgerTextField } from "@/core/forms/WgerTextField"; import { dateToYYYYMMDD } from "@/core/lib/date"; +import { FormQueryErrors } from "@/core/ui/Widgets/FormError"; import { SentimentNeutral, SentimentSatisfiedAlt, SentimentVeryDissatisfied } from "@mui/icons-material"; import { Button, ButtonGroup, Typography } from "@mui/material"; import Grid from '@mui/material/Grid'; @@ -48,7 +49,8 @@ export const SessionForm = ({ initialSession, dayId, routineId, selectedDate, se routineId, { routine: routineId, - date: dateToYYYYMMDD(selectedDate.toJSDate()), + // eslint-disable-next-line camelcase + datetime_start__date: dateToYYYYMMDD(selectedDate.toJSDate()), day: dayId } ); @@ -65,13 +67,10 @@ export const SessionForm = ({ initialSession, dayId, routineId, selectedDate, se start: yup .date() .nullable(), + // An end before the start is not an error, it means the session ran over midnight end: yup .date() - .nullable() - .min( - yup.ref('start'), - t('forms.endBeforeStart') - ), + .nullable(), fitInWeek: yup.boolean() }); @@ -84,18 +83,18 @@ export const SessionForm = ({ initialSession, dayId, routineId, selectedDate, se formikRef.current.setValues({ notes: findSessionQuery.data.notes || '', impression: findSessionQuery.data.impression || IMPRESSION_NEUTRAL, - date: findSessionQuery.data.date, - start: findSessionQuery.data.timeStart ? DateTime.fromJSDate(findSessionQuery.data.timeStart) : null, - end: findSessionQuery.data.timeEnd ? DateTime.fromJSDate(findSessionQuery.data.timeEnd) : null, + date: findSessionQuery.data.datetimeStart, + start: DateTime.fromJSDate(findSessionQuery.data.datetimeStart), + end: findSessionQuery.data.datetimeEnd ? DateTime.fromJSDate(findSessionQuery.data.datetimeEnd) : null, }); setSession(findSessionQuery.data); } else if (findSessionQuery.isSuccess && !findSessionQuery.data) { formikRef.current.setValues({ notes: '', impression: IMPRESSION_NEUTRAL, - date: initialSession?.date || DateTime.now().toJSDate(), //JS Date, not DateTime - start: initialSession?.timeStart ? DateTime.fromJSDate(initialSession.timeStart) : null, - end: initialSession?.timeEnd ? DateTime.fromJSDate(initialSession.timeEnd) : null, + date: initialSession?.datetimeStart || DateTime.now().toJSDate(), //JS Date, not DateTime + start: initialSession ? DateTime.fromJSDate(initialSession.datetimeStart) : null, + end: initialSession?.datetimeEnd ? DateTime.fromJSDate(initialSession.datetimeEnd) : null, }); setSession(undefined); } @@ -108,23 +107,35 @@ export const SessionForm = ({ initialSession, dayId, routineId, selectedDate, se enableReinitialize initialValues={{ notes: session !== undefined ? session.notes : '', - date: session !== undefined ? session.date : new Date(), - start: session !== undefined && session.timeStart !== null ? DateTime.fromJSDate(session.timeStart!) : null, - end: session !== undefined && session.timeEnd !== null ? DateTime.fromJSDate(session.timeEnd!) : null, + date: session !== undefined ? session.datetimeStart : new Date(), + start: session !== undefined ? DateTime.fromJSDate(session.datetimeStart) : null, + end: session?.datetimeEnd != null ? DateTime.fromJSDate(session.datetimeEnd) : null, impression: session !== undefined ? session.impression : IMPRESSION_NEUTRAL, }} innerRef={formikRef} validationSchema={validationSchema} onSubmit={async (values) => { + const day = selectedDate.startOf('day'); + const start = values.start + ? day.set({ hour: values.start.hour, minute: values.start.minute }) + : day; + let end = values.end + ? day.set({ hour: values.end.hour, minute: values.end.minute }) + : null; + + // An end before the start means the session ran past midnight + if (end !== null && end < start) { + end = end.plus({ days: 1 }); + } + const draft = new WorkoutSession({ id: session?.id ?? null, dayId: dayId, routineId: routineId, - date: selectedDate.toJSDate(), notes: values.notes, impression: values.impression, - timeStart: values.start ? values.start.toJSDate() : null, - timeEnd: values.end ? values.end.toJSDate() : null, + datetimeStart: start.toJSDate(), + datetimeEnd: end !== null ? end.toJSDate() : null, }); if (session !== undefined) { @@ -248,6 +259,10 @@ export const SessionForm = ({ initialSession, dayId, routineId, selectedDate, se + + + +