fix(web): restore sleep and body parity with program calendar

This commit is contained in:
fy59 2026-10-07 09:54:33 +02:00
parent 1c50d483f2
commit 700dfc0d5e
9 changed files with 692 additions and 301 deletions

View file

@ -2,6 +2,15 @@
## Unreleased — 0.1.7
- Recovered the Android-matched Web body figures and factual completed-sport
Sleep agenda/PDF overlay after the Program calendar package was built from
`3a4ec34`, before their commits `c2935b8` and `e2c07f1`. The development
branch now includes the later Sleep PDF, paging and Program analysis fixes.
Kept the compact month calendar, individual drag and drop and accessible
date action, green fixed completed cards, same-day sessions, and revisioned
restoration of legacy ceded optional sessions. No Android source, database
format, or synchronization protocol changed in this correction.
- Reconciled the canonical documentation against the `f4ddcfd` development
lineage: current source schemas v37/v34, implemented Web routes and Program
rescheduling, observed package provenance, Sleep row paging, capability

View file

@ -2,22 +2,20 @@
## Audited development line and private installation
This snapshot audits `trainlog-sleep-single-selector-weeks-v1` at `f4ddcfd`
on 2026-10-02. It includes the Program projection fix `8995843`, the body
figure Web change `c2935b8`, and the Sleep PDF, sport and paging changes by
ancestry. A later documentation-only commit does not imply a new runtime
package. The uncommitted Dashboard layout work in the separate
`trainlog-dashboard-polish-v1` worktree is outside this snapshot.
This 0.1.7 correction joins the audited `f4ddcfd` development lineage,
including the Program projection fix `8995843`, Android-matched Web body
figures `c2935b8`, and the Sleep PDF, sport and paging changes, to the compact
Program month calendar. The uncommitted Dashboard layout work in the separate
`trainlog-dashboard-polish-v1` worktree remains outside this snapshot.
The private daily installation runs the ordered Program rescheduling rollout
from `trainlog-program-recalage-v1` at `3a4ec34`, with desktop schema v37,
Android schema v34, and the capability-selected `trainlog-programs` V2
projection. The observed PC/Web launcher points to the
`sleep-single-selector-weeks-v1-f4ddcfd` package; synchronization agents
retain the compatible Program
rollout bundle. The previous PC/Web bundle from
`trainlog-sleep-pdf-treatments-space-v1` at `3758875` remains available for
rollback. The served Web uses Android Compose Canvas head, neck, and 22 body
The private daily Program rollout uses desktop schema v37, Android schema v34,
and the capability-selected `trainlog-programs` V2 projection. The later
`program-calendar-dnd-drop-failure-v1-final` PC/Web package declared source
commit `3a4ec34` and carried the uncommitted calendar UI, but omitted the
subsequent body-figure and Sleep sport commits. The 0.1.7 correction was
validated in Firefox against a temporary snapshot of the real desktop data;
it does not by itself prove a replacement daily package has been installed.
The corrected Web uses Android Compose Canvas head, neck, and 22 body
paths on Dashboard muscle distribution, Analysis overview, and Analysis body
distribution. Firefox captures of all three served views show complete figures
at the intended aspect ratio, with the existing color scale, legend, and focus
@ -36,6 +34,12 @@ PDF inspection also confirmed black-outlined blue sport bands. A separate
synthetic set of 7, 14, 21, and 30 nights in French and English, plus long
continuations, was rendered page by page and checked in grayscale. Rollout
packaging requires a Meson `-Dweb=enabled` build with embedded frontend tests.
The corrected local build was checked in Firefox with a snapshot of the
existing real data: the latest seven Sleep rows showed six blue sport marks,
including the completed 2026-10-07 session at 06:59–08:02. The downloaded
five-page PDF listed that same session and its 1 h 03 min duration on its
sport appendix. The October Program calendar showed 35 day cells, seven
completed fixed cards and twelve movable cards without a data mutation.
Full-generation synchronization uses one generation/ACK business engine across
versioned transports. On the paired 0.1.4 deployment, automatic local selection
@ -482,7 +486,7 @@ persistence with optimistic conflict and CSRF/Origin protection. It provides no
automatic browser launch. Sessions provides complete paged preparation,
resume and history views, stable details, optimistic manual-preparation writes,
explicit proposal derivation and generation-backed Android delivery. The
top-level `/programmes` route is an operational daily active-Program calendar;
top-level `/programmes` route is a compact monthly active-Program calendar;
Sessions → Programmes remains the technical administration/import, list,
detail, archive, and delete surface. `/exercices` is the operational desktop
catalogue administration surface. `/analyse` is implemented with Overview,

View file

@ -69,7 +69,7 @@ resurrected or prepared; an archived program cannot be prepared. The optional
`trainlog-programs` V1 synchronization companion is a separate PC-to-Android
projection and does not alter this import format.
## Explicit ordered rescheduling in the audited development line
## Program calendar planning in the audited development line
The `trainlog-program-recalage-v1` line adds a separate planning
state. `planned_for` remains the imported date. `current_for` is an absolute
@ -78,8 +78,19 @@ future slot without completing or deleting its session identity. Completed
cards still use the linked session's actual start timestamp. Positions from the
imported Program determine the cascade order; titles never determine priority.
The Web action selects a first and last unfinished session, a resumption date,
and the exact session identities whose slots are ceded. Its available days are
The compact Web month calendar moves one eligible unfinished session to a
chosen civil date by drag and drop or an accessible date action. Other sessions
retain their current dates, and several cards may occupy one day. Completed
cards use factual execution dates and cannot move. FACULTATIVE is descriptive
metadata and never authorizes automatic deletion. The calendar no longer offers
the cascade or ceded-slot selection. Existing ceded rows remain readable and
can be restored to their imported dates with the revisioned, idempotent
planning command. The Programs V2 companion projects the resulting current
dates through normal synchronization.
The retained ordered-rescheduling API selects a first and last unfinished
session, a resumption date, and the exact session identities whose slots are
ceded. Its available days are
the dated original Program slots in that interval on or after resumption.
The preview lists original, prior current and proposed dates. It rejects a
missing slot, a date beyond the Program end, a completed/in-progress/prepared

View file

@ -1,11 +1,11 @@
# Roadmap
The Program cascade is deployed in the private daily installation from
`trainlog-program-recalage-v1` at `3a4ec34`. The observed PC/Web launcher points
to the `f4ddcfd` package, which includes
the verified blue Sleep sport overlay and the readable paginated treatment and
remarks PDF. Synchronization services retain their Program rollout paths. No
new personal Program rescheduling is implied by this presentation work.
The Program rescheduling service is deployed in the private daily installation
from `trainlog-program-recalage-v1` at `3a4ec34`. A later compact-calendar
PC/Web package was built from that older source base and omitted the Web body
figure and Sleep sport commits. The corrected 0.1.7 source combines the
calendar with those later changes; replacement installation and verification
on the daily launcher remain operational work.
The controlled private rollout is complete for the paired daily installation:
physical libmtp, verified backups, signing continuity, migrations, correlated
@ -100,10 +100,18 @@ feature cycle rather than widening 0.1.7.
### Program rescheduling in the audited development line
The Web month calendar performs an individual revisioned move by drag and drop
or an accessible date action. It preserves every Program session, including
FACULTATIVE sessions, and offers explicit restoration of legacy ceded rows.
The earlier ordered cascade remains an API compatibility surface but is no
longer offered by the calendar. Completed sessions remain fixed to factual
execution dates. Coordinated deployment of the corrected PC/Web bundle and
verification against the daily launcher are pending.
Ordered Program rescheduling is implemented in the audited development line
and deployed in the recorded private Program rollout. 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
and deployed in the recorded private Program rollout. The retained cascade API
selects the starting session, restart date, affected successors, and usable
slots; previews the old and new dates; then durably records 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

View file

@ -190,8 +190,7 @@ static TrainlogStatus build_preview(TrainlogDatabase *database,
if (program == NULL || count > 64U ||
!yyjson_equals_str(yyjson_obj_get(program, "state"), "active") ||
!yyjson_equals_str(yyjson_obj_get(program, "revision_id"),
yyjson_get_str(yyjson_obj_get(request, "expected_revision"))) ||
!yyjson_is_str(yyjson_obj_get(program, "end_date"))) {
yyjson_get_str(yyjson_obj_get(request, "expected_revision")))) {
status = planning_error(
"stale_or_ineligible_program", TRAINLOG_STATUS_CONFLICT, output, output_size);
goto done;
@ -213,8 +212,9 @@ static TrainlogStatus build_preview(TrainlogDatabase *database,
}
}
if (first >= count || last >= count || first > last ||
strcmp(yyjson_get_str(yyjson_obj_get(request, "start_date")),
yyjson_get_str(yyjson_obj_get(program, "end_date"))) > 0) {
(first != last && (!yyjson_is_str(yyjson_obj_get(program, "end_date")) ||
strcmp(yyjson_get_str(yyjson_obj_get(request, "start_date")),
yyjson_get_str(yyjson_obj_get(program, "end_date"))) > 0))) {
status = planning_error(
"invalid_selection", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
goto done;
@ -240,6 +240,20 @@ static TrainlogStatus build_preview(TrainlogDatabase *database,
goto done;
}
}
/* WHY: calendar moves target one identity, including a formerly ceded
* identity. CONTRACT: its requested civil day is the new current date,
* even when another session already occupies that day. INVARIANT: no
* other planning row is included in the resulting mutation. */
if (first == last && yyjson_arr_size(ceded) == 0U) {
PlanningRow *row = &rows[first];
if (strcmp(row->execution, "todo") != 0 || has_preparation(database, row->id)) {
status = planning_error(
"prepared_or_executed_session", TRAINLOG_STATUS_CONFLICT, output, output_size);
goto done;
}
row->assigned = yyjson_get_str(yyjson_obj_get(request, "start_date"));
goto assigned;
}
for (size_t index = first; index <= last; ++index) {
if (strcmp(rows[index].execution, "todo") != 0 ||
has_preparation(database, rows[index].id) || rows[index].original == NULL ||
@ -295,6 +309,7 @@ static TrainlogStatus build_preview(TrainlogDatabase *database,
}
rows[index].assigned = slots[next_slot++];
}
assigned:
// CONTRACT: the choice digest includes stable identities in their supplied
// order. The fact digest includes the complete current Program projection.
{

View file

@ -940,11 +940,115 @@ static bool ordered_reschedule_is_atomic_and_idempotent(void) {
return true;
}
static bool calendar_move_preserves_other_sessions_and_restores_ceded(void) {
/* CONTRACT: an occupied target and a legacy ceded optional identity are
* independent Program sessions. The repeated command proves durable
* idempotence without changing the imported definitions. */
TrainlogDatabase *database = NULL;
char *preview = NULL;
char *applied = NULL;
char *replay = NULL;
char *request = NULL;
size_t size = 0U;
char fingerprint[80];
char revision[80] = "pgr_calendar";
static const char *const TARGETS[] = {"2026-10-04", "2026-10-03", "2026-10-10"};
static const char *const IDENTITIES[] = {"pgs_main", "pgs_main", "pgs_optional"};
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(sqlite3_exec(database->connection,
"INSERT INTO programs(program_id,title,state,start_date,end_date,"
"created_at,updated_at,revision_id,source_format,source_version,"
"source_payload_sha256) VALUES('pg_calendar','Fixture','active',"
"'2026-10-01','2026-10-18','2026-10-01T00:00:00Z',"
"'2026-10-01T00:00:00Z','pgr_calendar','trainlog-program',1,"
"'0000000000000000000000000000000000000000000000000000000000000000');"
"INSERT INTO program_sessions VALUES"
"('pgs_main','pg_calendar',0,'S2 D','training','2026-10-01',NULL),"
"('pgs_other','pg_calendar',1,'S2 E','training','2026-10-03',NULL),"
"('pgs_optional','pg_calendar',2,'S2 F FACULTATIVE','training',"
"'2026-10-10',NULL);"
"INSERT INTO program_session_planning(program_session_id,current_for,"
"state,operation_id,updated_at) VALUES"
"('pgs_optional',NULL,'ceded','pr_old','2026-10-01T00:00:00Z')",
NULL,
NULL,
NULL) == SQLITE_OK);
for (size_t index = 0U; index < 3U; ++index) {
char choice[512];
char operation_id[48];
(void)snprintf(operation_id, sizeof(operation_id), "pr_calendar_move_%zu", index);
(void)snprintf(choice,
sizeof(choice),
"{\"start_session_id\":\"%s\",\"through_session_id\":\"%s\","
"\"start_date\":\"%s\",\"ceded_session_ids\":[],"
"\"expected_revision\":\"%s\"}",
IDENTITIES[index],
IDENTITIES[index],
TARGETS[index],
revision);
CHECK(trainlog_web_programs_reschedule_json(
database, "pg_calendar", NULL, choice, strlen(choice), false, &preview, &size) ==
TRAINLOG_STATUS_OK);
CHECK(extract_string(preview, "preview_sha256", fingerprint, sizeof(fingerprint)));
CHECK(strstr(preview, "\"change\":\"ceded\"") == NULL);
request = malloc(strlen(choice) + strlen(fingerprint) + 32U);
CHECK(request != NULL);
(void)snprintf(request,
strlen(choice) + strlen(fingerprint) + 32U,
"%.*s,\"preview_sha256\":\"%s\"}",
(int)strlen(choice) - 1,
choice,
fingerprint);
CHECK(trainlog_web_programs_reschedule_json(database,
"pg_calendar",
operation_id,
request,
strlen(request),
true,
&applied,
&size) == TRAINLOG_STATUS_OK);
CHECK(trainlog_web_programs_reschedule_json(database,
"pg_calendar",
operation_id,
request,
strlen(request),
true,
&replay,
&size) == TRAINLOG_STATUS_OK);
CHECK(strcmp(applied, replay) == 0);
CHECK(extract_string(applied, "new_revision_id", revision, sizeof(revision)));
CHECK(scalar(database,
"SELECT COUNT(*) FROM program_sessions WHERE "
"program_id='pg_calendar'") == 3);
CHECK(scalar(database,
"SELECT COUNT(*) FROM program_session_planning WHERE "
"state='ceded'") == (index < 2U ? 1 : 0));
free(request);
free(preview);
free(applied);
free(replay);
request = NULL;
preview = NULL;
applied = NULL;
replay = NULL;
}
CHECK(scalar(database,
"SELECT COUNT(*) FROM program_session_planning WHERE "
"current_for='2026-10-03'") == 1);
CHECK(scalar(database,
"SELECT COUNT(*) FROM program_sessions WHERE "
"planned_for='2026-10-03'") == 1);
trainlog_database_close(database);
return true;
}
int main(void) {
return import_archive_and_prepare() && strict_import_rejections() &&
mixed_profile_prepare_regression() &&
legacy_integer_weight_zero_prepares_editable_draft() &&
ordered_reschedule_is_atomic_and_idempotent()
ordered_reschedule_is_atomic_and_idempotent() &&
calendar_move_preserves_other_sessions_and_restores_ceded()
? EXIT_SUCCESS
: EXIT_FAILURE;
}

View file

@ -107,7 +107,7 @@ describe('ProgramsCalendarPage', () => {
expect(screen.queryByRole('combobox', { name: 'Programme actif' })).not.toBeInTheDocument()
})
it('keeps distinct cards on one date and labels a ceded slot separately', async () => {
it('keeps ceded definitions visible and offers explicit restoration', async () => {
const main = session('pgs_main', 'Séance principale', '2026-09-18')
const evening = session('pgs_evening', 'Étirements du soir', '2026-09-18')
const ceded = { ...session('pgs_ceded', 'Séance écartée', '2026-09-19'),
@ -120,38 +120,161 @@ describe('ProgramsCalendarPage', () => {
expect(within(friday).getByText('Séance principale')).toBeInTheDocument()
expect(within(friday).getByText('Étirements du soir')).toBeInTheDocument()
const saturday = screen.getByLabelText('Sam 19/09/2026')
expect(within(saturday).getByText('Créneau cédé')).toBeInTheDocument()
expect(within(saturday).queryByRole('button', { name: 'Préparer' })).not.toBeInTheDocument()
expect(within(saturday).getByText('Séance écartée')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Restaurer les séances écartées' })).toBeInTheDocument()
expect(screen.queryByText('Créneau cédé')).not.toBeInTheDocument()
})
it('previews ordered changes before one confirmation', async () => {
const before = detail({ sessions: [
session('pgs_b', 'B', '2026-09-18'),
session('pgs_c', 'C', '2026-09-19'),
] })
api.fetchProgram.mockResolvedValue(before)
it('moves one future session onto an occupied day without losing the other card', async () => {
const first = session('pgs_b', 'S2 D — Haut du corps', '2026-09-18')
const optional = session('pgs_f', 'S2 F — Technique (FACULTATIVE)', '2026-09-19')
optional.position = 1
api.fetchProgram.mockResolvedValueOnce(detail({ sessions: [first, optional] }))
.mockResolvedValue(detail({ sessions: [
{ ...first, current_for: '2026-09-19' }, optional,
], revision_id: 'pgr_next' }))
api.previewProgramReschedule.mockResolvedValue({
api_version: 1, program_id: 'pg_one', revision_id: 'pgr_one',
preview_sha256: 'f'.repeat(64), moves: [
{ program_session_id: 'pgs_b', original_for: '2026-09-18',
old_for: '2026-09-18', new_for: '2026-09-19', change: 'rescheduled' },
{ program_session_id: 'pgs_c', original_for: '2026-09-19',
old_for: '2026-09-19', new_for: null, change: 'ceded' },
],
preview_sha256: 'f'.repeat(64), moves: [{ program_session_id: 'pgs_b',
original_for: '2026-09-18', old_for: '2026-09-18',
new_for: '2026-09-19', change: 'rescheduled' }],
})
api.applyProgramReschedule.mockResolvedValue({})
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
await screen.findByRole('heading', { level: 1, name: 'Cycle force' })
fireEvent.change(screen.getByLabelText('Première séance'), { target: { value: 'pgs_b' } })
fireEvent.change(screen.getByLabelText('Dernière séance'), { target: { value: 'pgs_c' } })
fireEvent.change(screen.getByLabelText('Date de reprise'), { target: { value: '2026-09-19' } })
fireEvent.click(screen.getByLabelText(/C \(2026-09-19\)/))
fireEvent.click(screen.getByRole('button', { name: 'Calculer l’aperçu' }))
expect(await screen.findByRole('heading', { name: 'Aperçu avant application' })).toBeInTheDocument()
expect(api.applyProgramReschedule).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Confirmer ce recalage' }))
const view = render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
const source = await screen.findByRole('article', { name: /S2 D/ })
expect(source).toHaveAttribute('draggable', 'true')
const dataTransfer = {
setData: vi.fn(), getData: vi.fn(() => 'pgs_b'), effectAllowed: '', dropEffect: '',
}
fireEvent.dragStart(source, { dataTransfer })
const target = screen.getByRole('region', { name: 'Sam 19/09/2026' })
fireEvent.dragOver(target, { dataTransfer })
expect(target).toHaveClass('program-calendar-drop-target')
fireEvent.drop(target, { dataTransfer })
await waitFor(() => expect(api.applyProgramReschedule).toHaveBeenCalledTimes(1))
expect(api.applyProgramReschedule.mock.calls[0][2]).toBe('f'.repeat(64))
expect(api.previewProgramReschedule.mock.calls[0][1]).toMatchObject({
start_session_id: 'pgs_b', through_session_id: 'pgs_b',
start_date: '2026-09-19', ceded_session_ids: [],
})
await waitFor(() => expect(within(target).getAllByRole('article')).toHaveLength(2))
expect(within(target).getByText('Technique (FACULTATIVE)')).toBeInTheDocument()
expect(document.querySelector('.program-calendar-summary')).toHaveTextContent(
'0 / 2 séances effectuées',
)
view.unmount()
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
expect(within(await screen.findByRole('region', { name: 'Sam 19/09/2026' }))
.getAllByRole('article')).toHaveLength(2)
})
it('keeps a refused drop at its original date and explains a stale program revision', async () => {
api.previewProgramReschedule.mockRejectedValue(new Error('stale_or_ineligible_program'))
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
const source = await screen.findByRole('article', { name: /Jambes/ })
const dataTransfer = { setData: vi.fn(), getData: vi.fn(() => 'pgs_one'),
effectAllowed: '', dropEffect: '' }
fireEvent.dragStart(source, { dataTransfer })
fireEvent.drop(screen.getByRole('region', { name: 'Jeu 17/09/2026' }), { dataTransfer })
expect(await screen.findByRole('alert')).toHaveTextContent(
'Modification impossible : le programme a changé. Rechargez la page puis réessayez.',
)
expect(within(screen.getByRole('region', { name: 'Mer 16/09/2026' }))
.getByRole('article', { name: /Jambes/ })).toBeInTheDocument()
expect(within(screen.getByRole('region', { name: 'Jeu 17/09/2026' }))
.queryByRole('article')).not.toBeInTheDocument()
expect(api.applyProgramReschedule).not.toHaveBeenCalled()
})
it('explains a refused ACK-gated apply without moving or exposing its error code', async () => {
api.previewProgramReschedule.mockResolvedValue({ preview_sha256: 'f'.repeat(64), moves: [] })
api.applyProgramReschedule.mockRejectedValue(new Error('program_peer_not_fresh'))
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
const source = await screen.findByRole('article', { name: /Jambes/ })
const dataTransfer = { setData: vi.fn(), getData: vi.fn(() => 'pgs_one'),
effectAllowed: '', dropEffect: '' }
fireEvent.dragStart(source, { dataTransfer })
fireEvent.drop(screen.getByRole('region', { name: 'Jeu 17/09/2026' }), { dataTransfer })
expect(await screen.findByRole('alert')).toHaveTextContent(
'Modification impossible : synchronisez Trainlog avec le téléphone puis réessayez.',
)
expect(screen.queryByText('program_peer_not_fresh')).not.toBeInTheDocument()
expect(within(screen.getByRole('region', { name: 'Mer 16/09/2026' }))
.getByRole('article', { name: /Jambes/ })).toBeInTheDocument()
expect(within(screen.getByRole('region', { name: 'Jeu 17/09/2026' }))
.queryByRole('article')).not.toBeInTheDocument()
expect(api.fetchProgram).toHaveBeenCalledTimes(2)
})
it('moves an optional session by keyboard to an empty day and survives reload', async () => {
const optional = session('pgs_f', 'S2 F — Technique (FACULTATIVE)', '2026-09-19')
const before = detail({ sessions: [optional] })
const after = detail({ sessions: [{ ...optional, current_for: '2026-09-20' }],
revision_id: 'pgr_next' })
api.fetchProgram.mockResolvedValueOnce(before).mockResolvedValue(after)
api.previewProgramReschedule.mockResolvedValue({ preview_sha256: 'f'.repeat(64), moves: [] })
api.applyProgramReschedule.mockResolvedValue({})
const view = render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
const card = await screen.findByRole('article', { name: /S2 F/ })
expect(card).toHaveAttribute('draggable', 'true')
fireEvent.click(screen.getByRole('button', { name: /Modifier la date de S2 F/ }))
fireEvent.change(screen.getByLabelText('Nouvelle date pour S2 F'), {
target: { value: '2026-09-20' },
})
fireEvent.click(screen.getByRole('button', { name: 'Enregistrer' }))
await waitFor(() => expect(api.applyProgramReschedule).toHaveBeenCalledTimes(1))
expect(api.previewProgramReschedule.mock.calls[0][1]).toMatchObject({
start_session_id: 'pgs_f', through_session_id: 'pgs_f',
start_date: '2026-09-20', ceded_session_ids: [],
})
expect(within(await screen.findByRole('region', { name: 'Dim 20/09/2026' }))
.getByText('Technique (FACULTATIVE)')).toBeInTheDocument()
view.unmount()
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
expect(within(await screen.findByRole('region', { name: 'Dim 20/09/2026' }))
.getByText('Technique (FACULTATIVE)')).toBeInTheDocument()
})
it('rereads after an uncertain apply response before reporting the move as failed', async () => {
const first = session('pgs_a', 'S2 D — Machines', '2026-09-18')
api.fetchProgram.mockResolvedValueOnce(detail({ sessions: [first] }))
.mockResolvedValueOnce(detail({ sessions: [{ ...first, current_for: '2026-09-20' }],
revision_id: 'pgr_next' }))
api.previewProgramReschedule.mockResolvedValue({ preview_sha256: 'f'.repeat(64), moves: [] })
api.applyProgramReschedule.mockRejectedValue(new Error('réponse perdue'))
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
fireEvent.click(await screen.findByRole('button', { name: /Modifier la date de S2 D/ }))
fireEvent.change(screen.getByLabelText('Nouvelle date pour S2 D'), {
target: { value: '2026-09-20' },
})
fireEvent.click(screen.getByRole('button', { name: 'Enregistrer' }))
expect(await screen.findByText(/S2 D — Machines déplacée au 20\/09\/2026/))
.toBeInTheDocument()
expect(api.fetchProgram).toHaveBeenCalledTimes(2)
})
it('restores each ceded identity once using successive authoritative revisions', async () => {
const first = { ...session('pgs_f2', 'S2 F — Technique (FACULTATIVE)', '2026-09-19'),
planning_state: 'ceded' as const, current_for: null }
const second = { ...session('pgs_f3', 'S3 F — Technique (FACULTATIVE)', '2026-09-26'),
planning_state: 'ceded' as const, current_for: null, position: 1 }
api.fetchProgram.mockResolvedValueOnce(detail({ sessions: [first, second] }))
.mockResolvedValueOnce(detail({ sessions: [{ ...first, planning_state: 'active',
current_for: first.planned_for }, second], revision_id: 'pgr_second' }))
.mockResolvedValue(detail({ sessions: [{ ...first, planning_state: 'active',
current_for: first.planned_for }, { ...second, planning_state: 'active',
current_for: second.planned_for }], revision_id: 'pgr_third' }))
api.previewProgramReschedule.mockResolvedValue({ preview_sha256: 'f'.repeat(64), moves: [] })
api.applyProgramReschedule.mockResolvedValue({})
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
fireEvent.click(await screen.findByRole('button', { name: 'Restaurer les séances écartées' }))
await waitFor(() => expect(api.applyProgramReschedule).toHaveBeenCalledTimes(2))
expect(api.previewProgramReschedule.mock.calls.map((call) => call[1].expected_revision))
.toEqual(['pgr_one', 'pgr_second'])
expect(screen.queryByRole('button', { name: 'Restaurer les séances écartées' }))
.not.toBeInTheDocument()
expect(screen.getAllByRole('article', { name: /FACULTATIVE/ })).toHaveLength(2)
})
it('shows the exact empty state and navigates to Sessions program administration', async () => {
@ -205,13 +328,13 @@ describe('ProgramsCalendarPage', () => {
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
await screen.findByRole('heading', { level: 1, name: 'Cycle force' })
expect(screen.getByText('PROGRAMME ACTIF')).toBeInTheDocument()
expect(screen.getByText('16/09/2026 – 24/09/2026 · 1 séance')).toBeInTheDocument()
expect(screen.queryByText('PROGRAMME ACTIF')).not.toBeInTheDocument()
expect(screen.getByText('16/09/2026 – 24/09/2026 · 0 / 1 séances effectuées')).toBeInTheDocument()
const calendar = screen.getByLabelText('Calendrier hebdomadaire du programme')
expect(within(calendar).getAllByRole('region')).toHaveLength(14)
expect(within(calendar).getByLabelText('Lun 14/09/2026')).toBeInTheDocument()
expect(within(calendar).getByLabelText('Dim 27/09/2026')).toBeInTheDocument()
expect(within(calendar).getAllByText('Repos')).toHaveLength(13)
expect(within(calendar).getAllByRole('region')).toHaveLength(35)
expect(within(calendar).getByLabelText('Lun 31/08/2026')).toBeInTheDocument()
expect(within(calendar).getByLabelText('Dim 04/10/2026')).toBeInTheDocument()
expect(within(calendar).getAllByText('—')).toHaveLength(34)
expect(within(calendar).getAllByText('Jambes')).toHaveLength(1)
})
@ -245,8 +368,8 @@ describe('ProgramsCalendarPage', () => {
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(actualDay).getByRole('article', { name: /Séance A/ })).toHaveAttribute('draggable', 'false')
expect(within(actualDay).getByText('✓')).toBeInTheDocument()
expect(within(screen.getByLabelText('Mar 22/09/2026')).queryByText('Séance A'))
.not.toBeInTheDocument()
expect(completed.planned_for).toBe('2026-09-22')
@ -286,7 +409,8 @@ describe('ProgramsCalendarPage', () => {
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(plannedDay).getByRole('article', { name: /Séance préparée/ }))
.toHaveClass('program-calendar-state-prepared')
expect(within(screen.getByLabelText('Mer 23/09/2026')).queryByText('Séance préparée'))
.not.toBeInTheDocument()
})
@ -299,7 +423,7 @@ describe('ProgramsCalendarPage', () => {
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
const actualDate = formatDate(completed.execution_started_at, 'iso')
const actualDay = await screen.findByLabelText(new RegExp(formatCivilDate(actualDate, 'fr')))
const actualDay = await screen.findByRole('region', { name: 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'))
@ -325,7 +449,7 @@ describe('ProgramsCalendarPage', () => {
if (Intl.DateTimeFormat().resolvedOptions().timeZone === 'Europe/Paris') {
expect(actualDate).toBe(parisDate)
}
const actualDay = await screen.findByLabelText(new RegExp(formatCivilDate(actualDate, 'fr')))
const actualDay = await screen.findByRole('region', { name: new RegExp(formatCivilDate(actualDate, 'fr')) })
expect(within(actualDay).getByText('Séance réelle')).toBeInTheDocument()
expect(screen.getAllByText('Séance réelle')).toHaveLength(1)
})
@ -351,12 +475,10 @@ describe('ProgramsCalendarPage', () => {
session(`pgs_${state}`, `Séance ${index}`, `2026-09-${String(16 + index).padStart(2, '0')}`, state)) }))
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
const labels = ['À préparer', 'Préparée', 'En cours', 'Effectuée', 'Retirée']
await screen.findByRole('heading', { level: 1, name: 'Cycle force' })
states.forEach((state, index) => {
const title = screen.getByText(`Séance ${index}`)
expect(title.closest('article')).toHaveClass(`program-calendar-state-${state}`)
expect(within(title.closest('article') as HTMLElement).getByText(labels[index])).toBeInTheDocument()
})
expect(screen.getAllByRole('button', { name: 'Préparer' })).toHaveLength(1)
})
@ -379,7 +501,7 @@ describe('ProgramsCalendarPage', () => {
command.resolve({ preparation_id: 'sp_created' })
await waitFor(() => expect(screen.getByText('Préparation créée.')).toBeInTheDocument())
expect(api.fetchProgram).toHaveBeenCalledTimes(2)
expect(screen.getAllByText('Préparée')).toHaveLength(2)
expect(screen.getByRole('article', { name: /Jambes, Préparée/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Préparer' })).not.toBeInTheDocument()
})
@ -450,7 +572,7 @@ describe('ProgramsCalendarPage', () => {
fireEvent.click(await screen.findByRole('button', { name: 'Préparer' }))
expect(await screen.findByText('Préparation impossible : conflit de révision')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Préparer' })).toBeEnabled()
expect(screen.getAllByText('À préparer')).toHaveLength(2)
expect(screen.getByRole('article', { name: /Jambes, À préparer/ })).toBeInTheDocument()
expect(api.fetchProgram).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('link', { name: 'Ouvrir la préparation' })).not.toBeInTheDocument()
})
@ -463,6 +585,6 @@ describe('ProgramsCalendarPage', () => {
fireEvent.click(await screen.findByRole('button', { name: 'Préparer' }))
expect(await screen.findByText(/programme n’a pas pu être actualisé : réseau indisponible/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Préparer' })).toBeEnabled()
expect(screen.getAllByText('À préparer')).toHaveLength(2)
expect(screen.getByRole('article', { name: /Jambes, À préparer/ })).toBeInTheDocument()
})
})

View file

@ -10,7 +10,6 @@ import {
type ProgramListItem,
type ProgramSession,
type RescheduleChoice,
type ReschedulePreview,
} from '../api/programs'
import { useDatePreferences } from '../presentation/DatePreferences'
import { formatCivilDate, formatDate, parseCivilDate, validTimestampValue } from '../presentation/dateFormat'
@ -51,8 +50,9 @@ function isoDate(value: number): string {
// 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.
// CONTRACT: completed cards use that same local day; imported planned_for
// remains immutable while unfinished cards use their current planning date.
// 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
@ -65,17 +65,19 @@ function displayedDate(session: ProgramSession): string | null {
// a workout performed on the imported plan date.
return session.execution_state === 'completed'
? actualExecutionDate(session)
: session.planning_state === 'ceded' ? session.planned_for : session.current_for
: session.current_for ?? session.planned_for
}
function calendarDays(program: ProgramDetail): CalendarDay[] {
function calendarDays(program: ProgramDetail, month: string): CalendarDay[] {
// INVARIANT: render one civil month plus week padding. A distant keyboard
// move must not allocate every intervening day in the browser.
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 []
const first = Math.min(...values)
const last = Math.max(...values)
const first = dateValue(`${month}-01`)
if (first === null) return []
const date = new Date(first)
date.setUTCMonth(date.getUTCMonth() + 1)
date.setUTCDate(0)
const last = date.valueOf()
const firstWeekday = (new Date(first).getUTCDay() + 6) % 7
const lastWeekday = (new Date(last).getUTCDay() + 6) % 7
const start = first - firstWeekday * dayMilliseconds
@ -87,6 +89,10 @@ function calendarDays(program: ProgramDetail): CalendarDay[] {
sessions.push(session)
sessionsByDate.set(day, sessions)
}
for (const sessions of sessionsByDate.values()) {
sessions.sort((left, right) => left.position - right.position ||
left.program_session_id.localeCompare(right.program_session_id))
}
const days: CalendarDay[] = []
for (let value = start; value <= end; value += dayMilliseconds) {
@ -102,10 +108,43 @@ function calendarDays(program: ProgramDetail): CalendarDay[] {
return days
}
function monthForProgram(program: ProgramDetail): string {
const today = formatDate(new Date().toISOString(), 'iso')
if (dateValue(program.start_date) !== null && dateValue(program.end_date) !== null &&
today >= (program.start_date as string) && today <= (program.end_date as string)) {
return today.slice(0, 7)
}
const first = [program.start_date, ...program.sessions.map(displayedDate)]
.find((value) => dateValue(value) !== null)
return first?.slice(0, 7) ?? today.slice(0, 7)
}
function adjacentMonth(month: string, delta: number): string {
const value = dateValue(`${month}-01`)
if (value === null) return month
const date = new Date(value)
date.setUTCMonth(date.getUTCMonth() + delta)
return date.getUTCFullYear() < 1 || date.getUTCFullYear() > 9999
? month : isoDate(date.valueOf()).slice(0, 7)
}
function errorMessage(reason: unknown, fallback: string): string {
return reason instanceof Error && reason.message ? reason.message : fallback
}
function moveFailureMessage(reason: unknown, commitAttempted: boolean): string {
const code = errorMessage(reason, '')
if (code === 'program_peer_not_fresh') {
return 'Modification impossible : synchronisez Trainlog avec le téléphone puis réessayez.'
}
if (code === 'stale_or_ineligible_program' || code === 'stale_reschedule_preview') {
return 'Modification impossible : le programme a changé. Rechargez la page puis réessayez.'
}
return commitAttempted
? 'Modification non confirmée : rechargez la page pour vérifier la date.'
: 'Modification impossible : le planning n’a pas été modifié. Réessayez.'
}
function NavigationLink({ path, onNavigate, children, className }: {
path: string
onNavigate: (path: string) => void
@ -119,41 +158,55 @@ function NavigationLink({ path, onNavigate, children, className }: {
}}>{children}</a>
}
function ProgramSessionCard({ programId, session, pending, preparationId, onPrepare, onNavigate,
dateFormat }: {
function ProgramSessionCard({ programId, session, pending, moving, preparationId, onPrepare,
onMove, onDragEnd, onNavigate, dateFormat }: {
programId: string
session: ProgramSession
pending: boolean
moving: boolean
preparationId?: string
onPrepare: (programId: string, session: ProgramSession) => void
onMove: (session: ProgramSession, date: string) => void
onDragEnd: () => 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">{session.planning_state === 'ceded' &&
session.execution_state !== 'completed' ? 'Créneau cédé' : stateLabels[session.execution_state]}</span>
{session.planning_state === 'active' && session.current_for !== session.planned_for &&
session.execution_state !== 'completed' && <p className="program-calendar-date-note">
Recalée du {session.planned_for === null ? 'jour initial non défini' : formatCivilDate(session.planned_for, dateFormat)}
{' au '}{session.current_for === null ? 'jour non défini' : formatCivilDate(session.current_for, dateFormat)}
</p>}
{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>}
const [editingDate, setEditingDate] = useState(false)
const [newDate, setNewDate] = useState(displayedDate(session) ?? '')
const movable = session.execution_state === 'todo' && !moving
const [shortId, ...titleParts] = session.title.split(' — ')
return <article className={`program-calendar-session program-calendar-state-${session.execution_state}`}
draggable={movable} tabIndex={0}
aria-label={`${session.title}, ${stateLabels[session.execution_state]}, ${
displayedDate(session) ? formatCivilDate(displayedDate(session) as string, dateFormat) : 'sans date'}`}
title={session.note ?? session.title}
onDragStart={(event) => {
if (!movable) { event.preventDefault(); return }
event.dataTransfer.setData('text/plain', session.program_session_id)
event.dataTransfer.effectAllowed = 'move'
}} onDragEnd={onDragEnd}>
<h3><strong>{shortId}</strong>{titleParts.length > 0 && <span>{titleParts.join(' — ')}</span>}</h3>
{session.execution_state === 'completed' && <span aria-label="Effectuée">✓</span>}
{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' && session.planning_state === 'active' &&
<button type="button" disabled={pending}
onClick={() => onPrepare(programId, session)}>
{pending ? 'Préparation…' : 'Préparer'}
</button>}
{movable && <button className="program-calendar-edit-date" type="button"
aria-label={`Modifier la date de ${session.title}`} onClick={() => setEditingDate(!editingDate)}>
Déplacer
</button>}
{editingDate && <form className="program-calendar-date-form" onSubmit={(event) => {
event.preventDefault()
if (parseCivilDate(newDate) !== null) { onMove(session, newDate); setEditingDate(false) }
}}><label>Nouvelle date pour {shortId}<input type="date" required value={newDate}
onChange={(event) => setNewDate(event.target.value)} /></label>
<button type="submit">Enregistrer</button></form>}
{(session.execution_state === 'todo' || preparationId) && <details className="program-calendar-details">
<summary>Actions</summary>
{session.execution_state === 'todo' && <button type="button" disabled={pending}
onClick={() => onPrepare(programId, session)}>{pending ? 'Préparation…' : 'Préparer'}</button>}
{preparationId && <NavigationLink path={`/seances/preparation/${encodeURIComponent(preparationId)}`}
onNavigate={onNavigate}>Ouvrir la préparation</NavigationLink>}
</div>
</details>}
</article>
}
@ -169,18 +222,15 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
const [pendingSessions, setPendingSessions] = useState<Set<string>>(new Set())
const [preparationIds, setPreparationIds] = useState<Record<string, string>>({})
const [announcement, setAnnouncement] = useState('')
const [rescheduleStart, setRescheduleStart] = useState('')
const [rescheduleThrough, setRescheduleThrough] = useState('')
const [rescheduleDate, setRescheduleDate] = useState('')
const [cededIds, setCededIds] = useState<string[]>([])
const [reschedulePreview, setReschedulePreview] = useState<{
choice: RescheduleChoice, result: ReschedulePreview, operationId: string
} | null>(null)
const [reschedulePending, setReschedulePending] = useState(false)
const [moveError, setMoveError] = useState(false)
const [visibleMonth, setVisibleMonth] = useState('')
const [movingSession, setMovingSession] = useState('')
const [dropDate, setDropDate] = useState('')
const preparing = useRef(new Set<string>())
const mounted = useRef(true)
const selectedIdRef = useRef('')
const selectionGeneration = useRef(0)
const announcementRef = useRef<HTMLParagraphElement>(null)
useEffect(() => {
mounted.current = true
@ -221,14 +271,15 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
setDetailPending(true)
setDetailError('')
setAnnouncement('')
setReschedulePreview(null)
setRescheduleStart('')
setRescheduleThrough('')
setRescheduleDate('')
setCededIds([])
setMoveError(false)
setMovingSession('')
setDropDate('')
setPreparationIds({})
fetchProgram(selectedId, controller.signal).then((value) => {
if (isCurrent()) setDetail(value)
if (isCurrent()) {
setDetail(value)
setVisibleMonth(monthForProgram(value))
}
}).catch((reason) => {
if (isCurrent()) {
setDetailError(errorMessage(reason, 'Chargement du programme impossible.'))
@ -239,54 +290,119 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
return () => controller.abort()
}, [selectedId])
const days = useMemo(() => detail === null ? [] : calendarDays(detail), [detail])
const days = useMemo(() => detail === null || !visibleMonth ? []
: calendarDays(detail, visibleMonth), [detail, visibleMonth])
const undatedSessions = detail?.sessions.filter((session) => dateValue(displayedDate(session)) === null) ?? []
const openSessions = detail?.sessions.filter((session) => session.execution_state === 'todo') ?? []
const previewReschedule = async () => {
if (!detail || !rescheduleStart || !rescheduleThrough || !rescheduleDate) return
const moveSession = async (session: ProgramSession, date: string) => {
if (!detail || movingSession || session.execution_state !== 'todo' ||
parseCivilDate(date) === null) return
const programId = detail.program_id
const generation = selectionGeneration.current
const isCurrent = () => mounted.current && selectedIdRef.current === programId &&
selectionGeneration.current === generation
const choice: RescheduleChoice = {
start_session_id: rescheduleStart,
through_session_id: rescheduleThrough,
start_date: rescheduleDate,
ceded_session_ids: cededIds,
start_session_id: session.program_session_id,
through_session_id: session.program_session_id,
start_date: date,
ceded_session_ids: [],
expected_revision: detail.revision_id,
}
setReschedulePending(true)
setReschedulePreview(null)
if (session.planning_state === 'active' && session.current_for === date) return
setMovingSession(session.program_session_id)
setAnnouncement('')
setMoveError(false)
let commitAttempted = false
try {
const result = await previewProgramReschedule(programId, choice)
if (mounted.current && selectionGeneration.current === generation) {
setReschedulePreview({ choice, result, operationId: newRescheduleOperationId() })
setAnnouncement('Aperçu calculé. Vérifiez les dates avant de confirmer.')
}
// CONTRACT: one drop changes one identity through the existing revisioned
// preview/apply service. The authoritative reread publishes the new day.
const preview = await previewProgramReschedule(programId, choice)
if (!isCurrent()) return
commitAttempted = true
await applyProgramReschedule(programId, choice, preview.preview_sha256,
newRescheduleOperationId())
if (!isCurrent()) return
const reread = await fetchProgram(programId)
if (!isCurrent()) return
setDetail(reread)
setVisibleMonth(date.slice(0, 7))
setAnnouncement(`${session.title} déplacée au ${formatCivilDate(date, dateFormat)}.`)
} catch (reason) {
setAnnouncement(`Recalage impossible : ${errorMessage(reason, 'erreur inconnue')}`)
if (isCurrent()) {
if (commitAttempted) {
try {
const reread = await fetchProgram(programId)
if (!isCurrent()) return
setDetail(reread)
const saved = reread.sessions.find((candidate) =>
candidate.program_session_id === session.program_session_id)
if (saved?.planning_state === 'active' && saved.current_for === date) {
setVisibleMonth(date.slice(0, 7))
setAnnouncement(`${session.title} déplacée au ${formatCivilDate(date, dateFormat)}.`)
return
}
} catch {
// The original, specific command error remains the user diagnostic.
}
}
// WHY: the service may reject a drop after a successful preview.
// CONTRACT: keep the canonical card in place and make the refusal
// visible without exposing the peer or revision protocol to users.
setMoveError(true)
setAnnouncement(moveFailureMessage(reason, commitAttempted))
announcementRef.current?.scrollIntoView?.({ block: 'nearest' })
}
} finally {
setReschedulePending(false)
setDropDate('')
setMovingSession('')
}
}
const confirmReschedule = async () => {
if (!detail || !reschedulePreview) return
const { choice, result, operationId } = reschedulePreview
const restoreCeded = async () => {
if (!detail || movingSession) return
const programId = detail.program_id
const generation = selectionGeneration.current
setReschedulePending(true)
const isCurrent = () => mounted.current && selectedIdRef.current === programId &&
selectionGeneration.current === generation
let current = detail
setMovingSession('restore')
setMoveError(false)
try {
await applyProgramReschedule(programId, choice, result.preview_sha256, operationId)
const reread = await fetchProgram(programId)
if (mounted.current && selectionGeneration.current === generation) {
setDetail(reread)
setReschedulePreview(null)
setAnnouncement('Recalage enregistré. La synchronisation Android reste à vérifier.')
// WHY: a past ceded decision removed the current date without removing
// its definition. CONTRACT: restore each exact identity to its original
// date; a reread supplies the next revision. Retrying skips active rows.
for (const session of detail.sessions.filter((candidate) =>
candidate.planning_state === 'ceded')) {
if (!session.planned_for) throw new Error(`Date initiale absente : ${session.title}`)
const choice: RescheduleChoice = {
start_session_id: session.program_session_id,
through_session_id: session.program_session_id,
start_date: session.planned_for,
ceded_session_ids: [],
expected_revision: current.revision_id,
}
const preview = await previewProgramReschedule(programId, choice)
if (!isCurrent()) return
await applyProgramReschedule(programId, choice, preview.preview_sha256,
newRescheduleOperationId())
if (!isCurrent()) return
current = await fetchProgram(programId)
if (!isCurrent()) return
setDetail(current)
}
setAnnouncement('Séances restaurées dans le programme.')
} catch (reason) {
setAnnouncement(`Recalage non confirmé : ${errorMessage(reason, 'erreur inconnue')}`)
if (isCurrent()) {
try {
setDetail(await fetchProgram(programId))
} catch {
// A later page load can reread any repair that committed before failure.
}
if (isCurrent()) {
setAnnouncement(`Restauration interrompue : ${errorMessage(reason, 'erreur inconnue')}`)
}
}
} finally {
setReschedulePending(false)
setMovingSession('')
}
}
@ -299,6 +415,7 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
preparing.current.add(sessionId)
setPendingSessions((current) => new Set(current).add(sessionId))
setAnnouncement('')
setMoveError(false)
try {
const result = await createPreparationFromProgram(programId, sessionId)
if (!isCurrent()) return
@ -336,8 +453,11 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
programId={detail?.program_id ?? ''}
session={session}
pending={pendingSessions.has(session.program_session_id)}
moving={Boolean(movingSession)}
preparationId={preparationIds[session.program_session_id]}
onPrepare={prepare}
onMove={(target, date) => void moveSession(target, date)}
onDragEnd={() => setDropDate('')}
onNavigate={onNavigate}
dateFormat={dateFormat}
/>
@ -373,7 +493,6 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
{!detailPending && detail && <>
<header className="program-calendar-header">
<div>
<p className="eyebrow">PROGRAMME ACTIF</p>
<h1>{detail.title}</h1>
<p className="program-calendar-summary">
{detail.start_date && detail.end_date
@ -381,72 +500,54 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
: detail.start_date ? `À partir du ${formatCivilDate(detail.start_date, dateFormat)}`
: detail.end_date ? `Jusqu’au ${formatCivilDate(detail.end_date, dateFormat)}`
: 'Dates non définies'}
{' · '}{detail.sessions.length} séance{detail.sessions.length === 1 ? '' : 's'}
{' · '}{detail.sessions.filter((session) => session.execution_state === 'completed').length}
{' / '}{detail.sessions.length} séances effectuées
</p>
</div>
<ul className="program-calendar-legend" aria-label="Légende des états">
{(Object.keys(stateLabels) as ExecutionState[]).map((state) => <li key={state}
className={`program-calendar-state-${state}`}>{stateLabels[state]}</li>)}
</ul>
</header>
<p className="program-calendar-announcement" aria-live="polite">{announcement}</p>
<section className="program-calendar-reschedule" aria-label="Recaler le programme">
<h2>Recaler la suite</h2>
<p>Choisissez les séances concernées dans leur ordre, la reprise et les créneaux cédés.
L’aperçu utilise les dates de créneaux déjà inscrites au programme.</p>
<div className="program-calendar-reschedule-fields">
<label>Première séance<select value={rescheduleStart} onChange={(event) => {
setRescheduleStart(event.target.value); setReschedulePreview(null)
}}><option value="">Choisir</option>{openSessions.map((session) =>
<option key={session.program_session_id} value={session.program_session_id}>
Début : {session.title}</option>)}</select></label>
<label>Dernière séance<select value={rescheduleThrough} onChange={(event) => {
setRescheduleThrough(event.target.value); setReschedulePreview(null)
}}><option value="">Choisir</option>{openSessions.map((session) =>
<option key={session.program_session_id} value={session.program_session_id}>
Fin : {session.title}</option>)}</select></label>
<label>Date de reprise<input type="date" value={rescheduleDate}
min={detail.start_date ?? undefined} max={detail.end_date ?? undefined}
onChange={(event) => { setRescheduleDate(event.target.value); setReschedulePreview(null) }} /></label>
</div>
<fieldset><legend>Séances qui cèdent leur créneau</legend>
{openSessions.map((session) => <label key={session.program_session_id}>
<input type="checkbox" checked={cededIds.includes(session.program_session_id)}
onChange={(event) => {
setCededIds((current) => event.target.checked
? [...current, session.program_session_id]
: current.filter((id) => id !== session.program_session_id))
setReschedulePreview(null)
}} />{session.title} ({session.planned_for ?? 'sans date'})
</label>)}
</fieldset>
<button type="button" disabled={reschedulePending || !rescheduleStart ||
!rescheduleThrough || !rescheduleDate} onClick={previewReschedule}>Calculer l’aperçu</button>
{reschedulePreview && <div className="program-calendar-reschedule-preview">
<h3>Aperçu avant application</h3>
<table><thead><tr><th>Séance</th><th>Date initiale</th><th>Date actuelle</th>
<th>Nouvelle date</th><th>Changement</th></tr></thead><tbody>
{reschedulePreview.result.moves.map((move) => <tr key={move.program_session_id}>
<td>{detail.sessions.find((session) => session.program_session_id === move.program_session_id)?.title}</td>
<td>{move.original_for ?? '—'}</td><td>{move.old_for ?? '—'}</td>
<td>{move.new_for ?? '—'}</td>
<td>{move.change === 'ceded' ? 'Créneau cédé'
: move.change === 'unchanged' ? 'Inchangée' : 'Recalée'}</td>
</tr>)}
</tbody></table>
<button type="button" disabled={reschedulePending} onClick={confirmReschedule}>
Confirmer ce recalage</button>
</div>}
</section>
<p ref={announcementRef} className="program-calendar-announcement"
role={moveError ? 'alert' : undefined} aria-live={moveError ? 'assertive' : 'polite'}>
{announcement}
</p>
{detail.sessions.some((session) => session.planning_state === 'ceded') &&
<button className="program-calendar-restore" type="button" disabled={Boolean(movingSession)}
onClick={() => void restoreCeded()}>
Restaurer les séances écartées
</button>}
<nav className="program-calendar-navigation" aria-label="Navigation du calendrier">
<button type="button" onClick={() => setVisibleMonth(
formatDate(new Date().toISOString(), 'iso').slice(0, 7))}>
Aujourd’hui</button>
<button type="button" aria-label="Mois précédent"
onClick={() => setVisibleMonth(adjacentMonth(visibleMonth, -1))}>‹</button>
<strong>{visibleMonth && new Intl.DateTimeFormat('fr-FR', { month: 'long', year: 'numeric',
timeZone: 'UTC' }).format(new Date(`${visibleMonth}-01T00:00:00Z`))}</strong>
<button type="button" aria-label="Mois suivant"
onClick={() => setVisibleMonth(adjacentMonth(visibleMonth, 1))}>›</button>
</nav>
{days.length > 0 && <div className="program-calendar-scroll" tabIndex={0}
aria-label="Calendrier hebdomadaire du programme">
<div className="program-calendar-grid">
{days.map((day) => <section className="program-calendar-day" key={day.date}
aria-label={`${day.weekday} ${formatCivilDate(day.date, dateFormat)}`}>
{days.map((day) => <section className={`program-calendar-day${
dropDate === day.date ? ' program-calendar-drop-target' : ''}`} key={day.date}
aria-label={`${day.weekday} ${formatCivilDate(day.date, dateFormat)}`}
onDragOver={(event) => {
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
setDropDate(day.date)
}}
onDrop={(event) => {
event.preventDefault()
setDropDate('')
const id = event.dataTransfer.getData('text/plain')
const session = detail.sessions.find((candidate) =>
candidate.program_session_id === id && candidate.execution_state === 'todo')
if (session) void moveSession(session, day.date)
}}>
<header><span>{day.weekday}</span><strong>{day.dayNumber}</strong>
<time dateTime={day.date}>{formatCivilDate(day.date, dateFormat)}</time></header>
{day.sessions.length === 0
? <p className="program-calendar-rest">Repos</p>
? <p className="program-calendar-rest">—</p>
: <div className="program-calendar-day-sessions">{day.sessions.map(renderSession)}</div>}
</section>)}
</div>

View file

@ -1743,20 +1743,20 @@ h2 {
border-radius: 0.55rem;
}
.program-calendar-header {
margin-bottom: 1.25rem;
margin-bottom: 0.75rem;
display: flex;
align-items: end;
justify-content: space-between;
gap: 2rem;
}
.program-calendar-header h1 {
max-width: 24ch;
display: -webkit-box;
overflow: hidden;
font-size: clamp(2.15rem, 4vw, 2.8rem);
line-height: 1.02;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
max-width: 55ch;
margin: 0;
font-size: clamp(1.4rem, 2.4vw, 1.9rem);
line-height: 1.15;
}
.app-main:has(.programs-calendar-page) {
padding-top: 1.25rem;
}
.settings-grid,
@ -2040,47 +2040,47 @@ h2 {
background: color-mix(in srgb, var(--surface-1) 35%, transparent);
}
.program-calendar-announcement {
min-height: 1.5rem;
margin: 0 0 0.5rem;
color: var(--subtext-0);
font-size: var(--font-size-sm);
}
.program-calendar-reschedule {
margin: 0 0 1rem;
padding: 1rem;
.program-calendar-announcement:empty {
display: none;
}
.program-calendar-announcement[role="alert"] {
padding: 0.55rem 0.7rem;
border: 1px solid var(--red);
border-radius: 0.5rem;
color: var(--text);
background: color-mix(in srgb, var(--red) 10%, var(--mantle));
}
.program-calendar-restore {
margin-bottom: 0.75rem;
padding: 0.35rem 0.6rem;
border: 1px solid var(--peach);
border-radius: 0.5rem;
color: var(--text);
background: color-mix(in srgb, var(--peach) 9%, var(--mantle));
cursor: pointer;
}
.program-calendar-navigation {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.6rem;
}
.program-calendar-navigation strong {
min-width: 8rem;
text-align: center;
text-transform: capitalize;
}
.program-calendar-navigation button {
border: 1px solid var(--surface-1);
border-radius: 0.75rem;
}
.program-calendar-reschedule-fields {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.program-calendar-reschedule-fields label {
display: grid;
gap: 0.25rem;
min-width: min(100%, 12rem);
}
.program-calendar-reschedule fieldset {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
margin: 0.75rem 0;
}
.program-calendar-reschedule-preview {
max-width: 100%;
overflow-x: auto;
margin-top: 1rem;
}
.program-calendar-reschedule-preview table {
width: 100%;
border-collapse: collapse;
}
.program-calendar-reschedule-preview th,
.program-calendar-reschedule-preview td {
padding: 0.35rem;
text-align: left;
border-bottom: 1px solid var(--surface-1);
border-radius: 0.5rem;
background: var(--mantle);
color: var(--text);
cursor: pointer;
padding: 0.3rem 0.55rem;
}
.program-calendar-scroll {
max-width: 100%;
@ -2092,25 +2092,29 @@ h2 {
outline: 2px solid var(--lavender);
}
.program-calendar-grid {
min-width: 72rem;
min-width: 56rem;
display: grid;
grid-template-columns: repeat(7, minmax(9rem, 1fr));
grid-template-columns: repeat(7, minmax(7rem, 1fr));
border-top: 1px solid var(--surface-1);
border-left: 1px solid var(--surface-1);
}
.program-calendar-day {
min-width: 0;
min-height: 12rem;
padding: 0.7rem;
min-height: 6.5rem;
padding: 0.4rem;
background: var(--mantle);
border-right: 1px solid var(--surface-1);
border-bottom: 1px solid var(--surface-1);
}
.program-calendar-drop-target {
background: color-mix(in srgb, var(--lavender) 18%, var(--mantle));
outline: 2px solid var(--lavender);
outline-offset: -2px;
}
.program-calendar-day > header {
display: grid;
grid-template-columns: 1fr auto;
display: flex;
align-items: baseline;
gap: 0.15rem 0.5rem;
gap: 0.35rem;
}
.program-calendar-day > header span {
color: var(--subtext-0);
@ -2119,68 +2123,81 @@ h2 {
}
.program-calendar-day > header strong {
color: var(--text);
font-size: var(--font-size-lg);
font-size: var(--font-size-sm);
}
.program-calendar-day > header time {
grid-column: 1 / -1;
color: var(--overlay-1);
font-size: var(--font-size-xs);
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
.program-calendar-rest {
margin: 2rem 0 0;
margin: 0.7rem 0 0;
color: var(--overlay-1);
font-size: var(--font-size-sm);
font-size: var(--font-size-xs);
text-align: center;
}
.program-calendar-day-sessions {
margin-top: 0.75rem;
margin-top: 0.35rem;
display: grid;
gap: 0.55rem;
gap: 0.3rem;
}
.program-calendar-session {
padding: 0.65rem;
padding: 0.4rem;
background: var(--base);
border: 1px solid var(--surface-1);
border-left: 0.22rem solid currentColor;
border-radius: 0.55rem;
border-left: 0.2rem solid currentColor;
border-radius: 0.5rem;
}
.program-calendar-session h3 {
margin: 0 0 0.5rem;
margin: 0;
color: var(--text);
font-size: var(--font-size-sm);
line-height: 1.35;
font-size: var(--font-size-xs);
line-height: 1.25;
}
.program-calendar-state-label {
display: inline-flex;
padding: 0.2rem 0.4rem;
.program-calendar-session h3 strong,
.program-calendar-session h3 span {
display: block;
}
.program-calendar-session[draggable="true"] {
cursor: grab;
}
.program-calendar-session:focus-visible {
outline: 2px solid var(--lavender);
}
.program-calendar-session.program-calendar-state-completed {
border-color: var(--green);
color: var(--green);
}
.program-calendar-edit-date,
.program-calendar-details summary {
color: var(--subtext-0);
font-size: var(--font-size-xs);
}
.program-calendar-edit-date {
padding: 0;
border: 0;
background: none;
text-decoration: underline;
cursor: pointer;
}
.program-calendar-date-form {
display: grid;
gap: 0.3rem;
margin-top: 0.35rem;
}
.program-calendar-date-form label {
display: grid;
}
.program-calendar-details {
margin-top: 0.25rem;
}
.program-calendar-date-note {
margin: 0.45rem 0 0;
margin: 0.35rem 0 0;
color: var(--muted);
font-size: var(--font-size-xs);
}
.program-calendar-action {
margin-top: 0.55rem;
display: grid;
gap: 0.4rem;
}
.program-calendar-action button,
.program-calendar-action a {
min-height: 2.35rem;
padding: 0.48rem 0.6rem;
border: 1px solid var(--lavender);
border-radius: 0.45rem;
color: var(--text);
background: transparent;
cursor: pointer;
font-size: var(--font-size-xs);
text-align: center;
text-decoration: none;
}
.program-calendar-action button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.program-calendar-undated {
margin-top: 2rem;
padding: clamp(1rem, 3vw, 1.5rem);