diff --git a/CHANGELOG.md b/CHANGELOG.md index 8250872..7731aea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ observed bug fixes, regression fixes, directly related small UI corrections, documentation corrections, robustness, and associated tests. It does not admit new features, modules, protocols, or speculative refactors. +- Corrected the Web Program calendar's completed-session placement using the + linked session's actual start date in the browser's history timezone. A + changed day displays both actual and imported dates, and concurrent cards + remain distinct. The Program detail Web API gains a nullable read-only + timestamp field; this changes no history, schema, Android behavior, or sync + artifact. + Durable ordered rescheduling remains outside the stabilized Programs V1 + exchange: its strict snapshot has no current date or ceded-slot state. - Fixed automatic Bluetooth synchronization recovery when RFCOMM disconnects after `trainlog-syncd` admits Android's arrival request but before the generation conversation can start. Requests whose durable terminal result diff --git a/docs/current_state.md b/docs/current_state.md index 91f5c1f..30b847c 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -457,8 +457,17 @@ The calendar uses the existing typed Core services `fetchAllPrograms(active)`, or execution state. With no active Program it shows an empty state and navigation to Sessions administration; with one it selects it automatically; with several it offers a non-persisted selector. Its continuous Monday–Sunday -weeks derive from Program bounds and real `planned_for` dates, do not invent -rest-day sessions, and retain undated sessions in a separate section. It shows +weeks derive from Program bounds, imported `planned_for` dates, and the actual +`sessions.started_at` instant for linked completed executions, presented on the +same browser-local civil day as Web session history. The Program detail Web API +adds the nullable read-only `execution_started_at` projection field; the +Programs synchronization artifact and its strict fields are unchanged. A completed +session moved from its imported date shows both dates without changing history +or Program identity. Several cards may share a day; rest days contain no +invented session. A completed execution with no valid history timestamp stays +undated rather than appearing to have occurred on its planned day. Unfinished +sessions still show their imported dates because no durable current-plan or +ceded-optional-slot state exists in the stabilized Programs exchange. It shows all five Core execution states; only `todo` offers Prepare. Each Prepare result is followed by an authoritative Core reread and may expose the returned preparation link. This calendar adds no Android delivery, start, or execution diff --git a/docs/roadmap.md b/docs/roadmap.md index 38f3ac3..d13a2d3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -90,6 +90,29 @@ protocol evolution, new Android/Web/TUI capabilities, and speculative optimization are out of scope. Work outside this boundary waits for a later feature cycle rather than widening 0.1.7. +### Deferred Program rescheduling + +Ordered Program rescheduling requires a separate, versioned shared-contract +decision after the actual-date display correction. One explicit user action +must select the starting session, restart date, affected successors, and usable +slots; preview the old and new dates; then durably record one decision. Only +unfinished sessions move, in Program position order. Explicitly ceded optional +slots may absorb required sessions without marking optional sessions completed +or removing historical identities. At most one principal Program session per +selected slot is a rule for this action, not a limit on other same-day workouts. +Rest days and Program bounds cannot be silently overridden. Preparations and +in-progress executions require checks before applying the decision. + +The result must not cascade again on display, import, replay, or Bluetooth +reconnection. PC and Android must receive the same business state; a Web-only +plan is insufficient. Compatibility requires proof in both supported exchange +directions and across revisions or reexports, including explicit behavior for +older clients unable to represent the decision. Payload acceptance alone does +not establish convergence. This future contract study does not reopen the +Bluetooth transport, Sleep, HR, or general sync invariants. Before any real +application, reread executions and preparations, confirm the restart date and +slot choices, and preview the resulting plan again. + Sleep Diary V1 stays frozen/readable while active capable peers require `sleep-diary-v2`. The Web’s relative HR rise/fall overlays remain descriptive measured-series presentation only, never inferred sleep states or causes. diff --git a/tui/src/web_programs.c b/tui/src/web_programs.c index aebe39f..d01b608 100644 --- a/tui/src/web_programs.c +++ b/tui/src/web_programs.c @@ -368,6 +368,7 @@ static TrainlogStatus add_program_session_json(TrainlogDatabase *database, !add_column_text(document, session, "note", statement, 5) || !add_column_text(document, session, "execution_state", statement, 6) || !add_column_text(document, session, "execution_session_id", statement, 7) || + !add_column_text(document, session, "execution_started_at", statement, 8) || !yyjson_mut_obj_add_val(document, session, "occurrences", entries) || !yyjson_mut_arr_add_val(sessions, session)) { return TRAINLOG_STATUS_DATABASE_ERROR; @@ -388,9 +389,14 @@ static TrainlogStatus add_program_sessions(TrainlogDatabase *database, "WHEN EXISTS(SELECT 1 FROM session_preparations sp WHERE " "sp.source_program_session_id=ps.program_session_id AND sp.withdrawn_at IS NULL) " "THEN 'prepared' ELSE 'todo' END," - "CASE WHEN pe.state='deleted' THEN NULL ELSE pe.session_id END " + "CASE WHEN pe.state='deleted' THEN NULL ELSE pe.session_id END," + /* WHY: completion belongs to the linked session's actual start date. + * CONTRACT: expose that timestamp only for completed executions. + * INVARIANT: the imported planned_for date and history are read only. */ + "CASE WHEN pe.state='completed' THEN actual.started_at ELSE NULL END " "FROM program_sessions ps LEFT JOIN program_session_executions pe " "ON pe.program_session_id=ps.program_session_id " + "LEFT JOIN sessions actual ON actual.session_id=pe.session_id " "WHERE ps.program_id=?1 ORDER BY ps.position,ps.program_session_id"; sqlite3_stmt *session_statement = NULL; int step; diff --git a/tui/tests/test_web_programs.c b/tui/tests/test_web_programs.c index a69d045..14fb1bc 100644 --- a/tui/tests/test_web_programs.c +++ b/tui/tests/test_web_programs.c @@ -317,7 +317,13 @@ static bool import_archive_and_prepare(void) { free(response); response = NULL; + /* The linked workout owns its actual timestamp; planned_for must not be + * rewritten to make the Program calendar agree with history. */ CHECK(sqlite3_exec(database->connection, + "INSERT INTO sessions(session_id,started_at,ended_at,session_type) " + "VALUES('se_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'," + "'2026-09-23T23:30:00+02:00'," + "'2026-09-24T00:30:00+02:00','training');" "INSERT INTO program_session_executions(" "program_session_id,program_id,session_id,state,observed_at) VALUES(" "'pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'," @@ -334,6 +340,11 @@ static bool import_archive_and_prepare(void) { CHECK(strstr(response, "\"execution_session_id\":" "\"se_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee\"") != NULL); + CHECK(strstr(response, "\"execution_started_at\":\"2026-09-23T23:30:00+02:00\"") != NULL); + CHECK(scalar(database, + "SELECT COUNT(*) FROM sessions WHERE session_id=" + "'se_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee' AND " + "started_at='2026-09-23T23:30:00+02:00'") == 1); free(response); response = NULL; diff --git a/web/src/api/programs.ts b/web/src/api/programs.ts index c041e2d..1186814 100644 --- a/web/src/api/programs.ts +++ b/web/src/api/programs.ts @@ -41,6 +41,7 @@ export interface ProgramSession { note: string | null execution_state: 'todo' | 'prepared' | 'in_progress' | 'completed' | 'deleted' execution_session_id: string | null + execution_started_at: string | null occurrences: ProgramOccurrence[] } diff --git a/web/src/routes/ProgramsCalendarPage.test.tsx b/web/src/routes/ProgramsCalendarPage.test.tsx index 28ab839..45a1eca 100644 --- a/web/src/routes/ProgramsCalendarPage.test.tsx +++ b/web/src/routes/ProgramsCalendarPage.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ProgramDetail, ProgramListItem, ProgramSession } from '../api/programs' import * as sessionsApi from '../api/sessions' +import { formatCivilDate, formatDate } from '../presentation/dateFormat' import { ProgramsCalendarPage } from './ProgramsCalendarPage' const api = vi.hoisted(() => ({ @@ -47,6 +48,7 @@ function session( note: null, execution_state: executionState, execution_session_id: null, + execution_started_at: null, occurrences: [], } } @@ -177,6 +179,104 @@ describe('ProgramsCalendarPage', () => { expect(within(undated).getByText('Date source invalide')).toBeInTheDocument() }) + it('places completed history on its real civil date without changing the source plan or hiding another card', async () => { + const completed = session('pgs_a', 'Séance A', '2026-09-22', 'completed') + completed.execution_session_id = 'se_actual' + completed.execution_started_at = '2026-09-23T23:30:00+02:00' + api.fetchProgram.mockResolvedValue(detail({ sessions: [ + completed, + session('pgs_b', 'Séance B', '2026-09-23'), + ] })) + render() + + const actualDay = await screen.findByLabelText('Mer 23/09/2026') + expect(within(actualDay).getByText('Séance A')).toBeInTheDocument() + expect(within(actualDay).getByText('Séance B')).toBeInTheDocument() + expect(within(actualDay).getByText('Prévue le 22/09/2026 · effectuée le 23/09/2026')) + .toBeInTheDocument() + expect(within(screen.getByLabelText('Mar 22/09/2026')).queryByText('Séance A')) + .not.toBeInTheDocument() + expect(completed.planned_for).toBe('2026-09-22') + expect(api.createPreparationFromProgram).not.toHaveBeenCalled() + }) + + it('does not present a completed session with missing history time as performed on the plan date', async () => { + const completed = session('pgs_a', 'Séance sans timestamp', '2026-09-22', 'completed') + completed.execution_session_id = 'se_missing' + api.fetchProgram.mockResolvedValue(detail({ sessions: [completed] })) + render() + + const undated = await screen.findByRole('region', { name: 'Séances sans date' }) + expect(within(undated).getByText('Séance sans timestamp')).toBeInTheDocument() + expect(within(undated).getByText('Date réelle indisponible')).toBeInTheDocument() + expect(within(screen.getByLabelText('Mar 22/09/2026')).queryByText('Séance sans timestamp')) + .not.toBeInTheDocument() + }) + + it('keeps an invalid completed timestamp undated rather than fabricating its plan date', async () => { + const completed = session('pgs_invalid', 'Séance horodatée invalide', '2026-09-22', 'completed') + completed.execution_session_id = 'se_invalid' + completed.execution_started_at = '2026-02-31T12:00:00Z' + api.fetchProgram.mockResolvedValue(detail({ sessions: [completed] })) + render() + + const undated = await screen.findByRole('region', { name: 'Séances sans date' }) + expect(within(undated).getByText('Séance horodatée invalide')).toBeInTheDocument() + expect(within(undated).getByText('Date réelle indisponible')).toBeInTheDocument() + }) + + it('keeps a prepared session on its plan date even if a timestamp is present', async () => { + const prepared = session('pgs_prepared', 'Séance préparée', '2026-09-22', 'prepared') + prepared.execution_started_at = '2026-09-23T12:00:00+02:00' + api.fetchProgram.mockResolvedValue(detail({ sessions: [prepared] })) + render() + + const plannedDay = await screen.findByLabelText('Mar 22/09/2026') + expect(within(plannedDay).getByText('Séance préparée')).toBeInTheDocument() + expect(within(plannedDay).getByText('Préparée')).toBeInTheDocument() + expect(within(screen.getByLabelText('Mer 23/09/2026')).queryByText('Séance préparée')) + .not.toBeInTheDocument() + }) + + it('extends the calendar to include a completed session outside the original program bounds', async () => { + const completed = session('pgs_late', 'Séance tardive', '2026-09-16', 'completed') + completed.execution_session_id = 'se_late' + completed.execution_started_at = '2026-09-28T12:00:00Z' + api.fetchProgram.mockResolvedValue(detail({ sessions: [completed] })) + render() + + const actualDate = formatDate(completed.execution_started_at, 'iso') + const actualDay = await screen.findByLabelText(new RegExp(formatCivilDate(actualDate, 'fr'))) + expect(within(actualDay).getByText('Séance tardive')).toBeInTheDocument() + expect(screen.getAllByText('Séance tardive')).toHaveLength(1) + expect(within(screen.getByLabelText('Mer 16/09/2026')).queryByText('Séance tardive')) + .not.toBeInTheDocument() + }) + + it.each([ + ['spring transition', '2026-03-28T23:30:00Z', '2026-03-29'], + ['autumn transition', '2026-10-25T23:30:00Z', '2026-10-26'], + ])('uses the same local history day across the %s', async (_name, startedAt, parisDate) => { + const sourceDate = startedAt.slice(0, 10) + const completed = session('pgs_dst', 'Séance réelle', sourceDate, 'completed') + completed.execution_session_id = 'se_dst' + completed.execution_started_at = startedAt + api.fetchProgram.mockResolvedValue(detail({ + start_date: sourceDate, + end_date: sourceDate, + sessions: [completed], + })) + render() + + const actualDate = formatDate(startedAt, 'iso') + if (Intl.DateTimeFormat().resolvedOptions().timeZone === 'Europe/Paris') { + expect(actualDate).toBe(parisDate) + } + const actualDay = await screen.findByLabelText(new RegExp(formatCivilDate(actualDate, 'fr'))) + expect(within(actualDay).getByText('Séance réelle')).toBeInTheDocument() + expect(screen.getAllByText('Séance réelle')).toHaveLength(1) + }) + it('renders a planned session in year 0001 in its calendar day rather than as undated', async () => { api.fetchProgram.mockResolvedValue(detail({ start_date: '0001-01-01', diff --git a/web/src/routes/ProgramsCalendarPage.tsx b/web/src/routes/ProgramsCalendarPage.tsx index f27e3b0..f5bbc33 100644 --- a/web/src/routes/ProgramsCalendarPage.tsx +++ b/web/src/routes/ProgramsCalendarPage.tsx @@ -8,7 +8,7 @@ import { type ProgramSession, } from '../api/programs' import { useDatePreferences } from '../presentation/DatePreferences' -import { formatCivilDate, parseCivilDate } from '../presentation/dateFormat' +import { formatCivilDate, formatDate, parseCivilDate, validTimestampValue } from '../presentation/dateFormat' type ExecutionState = ProgramSession['execution_state'] @@ -44,9 +44,27 @@ function isoDate(value: number): string { return new Date(value).toISOString().slice(0, 10) } +// WHY: Sessions history presents instants in the browser timezone, which may +// place a workout on a different civil day than the timestamp's source offset. +// CONTRACT: completed cards use that same local day; source planned_for stays +// available to explain the move. No schedule mutation happens during render. +function actualExecutionDate(session: ProgramSession): string | null { + if (session.execution_state !== 'completed' || typeof session.execution_started_at !== 'string') return null + if (validTimestampValue(session.execution_started_at) === null) return null + const day = formatDate(session.execution_started_at, 'iso') + return parseCivilDate(day) === null ? null : day +} + +function displayedDate(session: ProgramSession): string | null { + // A completed link without a valid history timestamp must not masquerade as + // a workout performed on the imported plan date. + return session.execution_state === 'completed' + ? actualExecutionDate(session) : session.planned_for +} + function calendarDays(program: ProgramDetail): CalendarDay[] { - const datedSessions = program.sessions.filter((session) => dateValue(session.planned_for) !== null) - const values = [program.start_date, program.end_date, ...datedSessions.map((session) => session.planned_for)] + const datedSessions = program.sessions.filter((session) => dateValue(displayedDate(session)) !== null) + const values = [program.start_date, program.end_date, ...datedSessions.map(displayedDate)] .map(dateValue).filter((value): value is number => value !== null) if (values.length === 0) return [] @@ -58,9 +76,10 @@ function calendarDays(program: ProgramDetail): CalendarDay[] { const end = last + (6 - lastWeekday) * dayMilliseconds const sessionsByDate = new Map() for (const session of datedSessions) { - const sessions = sessionsByDate.get(session.planned_for as string) ?? [] + const day = displayedDate(session) as string + const sessions = sessionsByDate.get(day) ?? [] sessions.push(session) - sessionsByDate.set(session.planned_for as string, sessions) + sessionsByDate.set(day, sessions) } const days: CalendarDay[] = [] @@ -94,17 +113,26 @@ function NavigationLink({ path, onNavigate, children, className }: { }}>{children} } -function ProgramSessionCard({ programId, session, pending, preparationId, onPrepare, onNavigate }: { +function ProgramSessionCard({ programId, session, pending, preparationId, onPrepare, onNavigate, + dateFormat }: { programId: string session: ProgramSession pending: boolean preparationId?: string onPrepare: (programId: string, session: ProgramSession) => void onNavigate: (path: string) => void + dateFormat: ReturnType['dateFormat'] }) { + const actualDate = actualExecutionDate(session) return

{session.title}

{stateLabels[session.execution_state]} + {actualDate !== null && actualDate !== session.planned_for &&

+ Prévue le {session.planned_for === null ? 'date non définie' : formatCivilDate(session.planned_for, dateFormat)} + {' · '}effectuée le {formatCivilDate(actualDate, dateFormat)} +

} + {session.execution_state === 'completed' && actualDate === null && +

Date réelle indisponible

}
{session.execution_state === 'todo' &&