fix(program): display completed sessions on their actual dates
This commit is contained in:
parent
39a9df7b07
commit
c17394ece2
10 changed files with 204 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue