fix(program): display completed sessions on their actual dates

This commit is contained in:
fy59 2026-10-01 05:59:59 +02:00
parent 39a9df7b07
commit c17394ece2
10 changed files with 204 additions and 10 deletions

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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;

View file

@ -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;

View file

@ -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[]
}

View file

@ -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(<ProgramsCalendarPage onNavigate={vi.fn()} />)
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(<ProgramsCalendarPage onNavigate={vi.fn()} />)
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(<ProgramsCalendarPage onNavigate={vi.fn()} />)
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(<ProgramsCalendarPage onNavigate={vi.fn()} />)
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(<ProgramsCalendarPage onNavigate={vi.fn()} />)
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(<ProgramsCalendarPage onNavigate={vi.fn()} />)
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',

View file

@ -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<string, ProgramSession[]>()
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}</a>
}
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<typeof useDatePreferences>['dateFormat']
}) {
const actualDate = actualExecutionDate(session)
return <article className={`program-calendar-session program-calendar-state-${session.execution_state}`}>
<h3>{session.title}</h3>
<span className="program-calendar-state-label">{stateLabels[session.execution_state]}</span>
{actualDate !== null && actualDate !== session.planned_for && <p className="program-calendar-date-note">
Prévue le {session.planned_for === null ? 'date non définie' : formatCivilDate(session.planned_for, dateFormat)}
{' · '}effectuée le {formatCivilDate(actualDate, dateFormat)}
</p>}
{session.execution_state === 'completed' && actualDate === null &&
<p className="program-calendar-date-note">Date réelle indisponible</p>}
<div className="program-calendar-action">
{session.execution_state === 'todo' && <button type="button" disabled={pending}
onClick={() => onPrepare(programId, session)}>
@ -186,7 +214,7 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
}, [selectedId])
const days = useMemo(() => detail === null ? [] : calendarDays(detail), [detail])
const undatedSessions = detail?.sessions.filter((session) => dateValue(session.planned_for) === null) ?? []
const undatedSessions = detail?.sessions.filter((session) => dateValue(displayedDate(session)) === null) ?? []
const prepare = async (programId: string, session: ProgramSession) => {
const sessionId = session.program_session_id
@ -237,6 +265,7 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
preparationId={preparationIds[session.program_session_id]}
onPrepare={prepare}
onNavigate={onNavigate}
dateFormat={dateFormat}
/>
return <section className="page programs-calendar-page">

View file

@ -167,6 +167,7 @@ describe('Programs tab', () => {
note: null,
execution_state: 'todo',
execution_session_id: null,
execution_started_at: null,
occurrences: [{
entry_id: 'pge_test',
position: 0,
@ -217,6 +218,7 @@ describe('Programs tab', () => {
note: null,
execution_state: index === 0 ? 'completed' : 'todo',
execution_session_id: index === 0 ? 'se_completed' : null,
execution_started_at: null,
occurrences: [],
}))
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {

View file

@ -2050,6 +2050,11 @@ h2 {
display: inline-flex;
padding: 0.2rem 0.4rem;
}
.program-calendar-date-note {
margin: 0.45rem 0 0;
color: var(--muted);
font-size: var(--font-size-xs);
}
.program-calendar-action {
margin-top: 0.55rem;
display: grid;