diff --git a/CHANGELOG.md b/CHANGELOG.md index c7b8568..68090e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/current_state.md b/docs/current_state.md index 5ed514c..3f99ddb 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -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, diff --git a/docs/program_format_v1.md b/docs/program_format_v1.md index 991f145..b40d426 100644 --- a/docs/program_format_v1.md +++ b/docs/program_format_v1.md @@ -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 diff --git a/docs/roadmap.md b/docs/roadmap.md index a0db736..338e373 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 diff --git a/tui/src/web_program_reschedule.c b/tui/src/web_program_reschedule.c index 5bce888..ed2c5cf 100644 --- a/tui/src/web_program_reschedule.c +++ b/tui/src/web_program_reschedule.c @@ -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. { diff --git a/tui/tests/test_web_programs.c b/tui/tests/test_web_programs.c index cede6c9..8a6b0e4 100644 --- a/tui/tests/test_web_programs.c +++ b/tui/tests/test_web_programs.c @@ -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; } diff --git a/web/src/routes/ProgramsCalendarPage.test.tsx b/web/src/routes/ProgramsCalendarPage.test.tsx index 2c7d099..5224bc3 100644 --- a/web/src/routes/ProgramsCalendarPage.test.tsx +++ b/web/src/routes/ProgramsCalendarPage.test.tsx @@ -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() - 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() 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() 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() - 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() }) }) diff --git a/web/src/routes/ProgramsCalendarPage.tsx b/web/src/routes/ProgramsCalendarPage.tsx index a1b5039..4af9f1b 100644 --- a/web/src/routes/ProgramsCalendarPage.tsx +++ b/web/src/routes/ProgramsCalendarPage.tsx @@ -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} } -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['dateFormat'] }) { const actualDate = actualExecutionDate(session) - return
-

{session.title}

- {session.planning_state === 'ceded' && - session.execution_state !== 'completed' ? 'Créneau cédé' : stateLabels[session.execution_state]} - {session.planning_state === 'active' && session.current_for !== session.planned_for && - session.execution_state !== 'completed' &&

- 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)} -

} - {actualDate !== null && actualDate !== session.planned_for &&

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

} + 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
{ + if (!movable) { event.preventDefault(); return } + event.dataTransfer.setData('text/plain', session.program_session_id) + event.dataTransfer.effectAllowed = 'move' + }} onDragEnd={onDragEnd}> +

{shortId}{titleParts.length > 0 && {titleParts.join(' — ')}}

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

Date réelle indisponible

} -
- {session.execution_state === 'todo' && session.planning_state === 'active' && - } + {movable && } + {editingDate &&
{ + event.preventDefault() + if (parseCivilDate(newDate) !== null) { onMove(session, newDate); setEditingDate(false) } + }}> +
} + {(session.execution_state === 'todo' || preparationId) &&
+ Actions + {session.execution_state === 'todo' && } {preparationId && Ouvrir la préparation} -
+ }
} @@ -169,18 +222,15 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string const [pendingSessions, setPendingSessions] = useState>(new Set()) const [preparationIds, setPreparationIds] = useState>({}) const [announcement, setAnnouncement] = useState('') - const [rescheduleStart, setRescheduleStart] = useState('') - const [rescheduleThrough, setRescheduleThrough] = useState('') - const [rescheduleDate, setRescheduleDate] = useState('') - const [cededIds, setCededIds] = useState([]) - 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()) const mounted = useRef(true) const selectedIdRef = useRef('') const selectionGeneration = useRef(0) + const announcementRef = useRef(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 && <>
-

PROGRAMME ACTIF

{detail.title}

{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

-
    - {(Object.keys(stateLabels) as ExecutionState[]).map((state) =>
  • {stateLabels[state]}
  • )} -
-

{announcement}

-
-

Recaler la suite

-

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.

-
- - - -
-
Séances qui cèdent leur créneau - {openSessions.map((session) => )} -
- - {reschedulePreview &&
-

Aperçu avant application

- - - {reschedulePreview.result.moves.map((move) => - - - - - )} -
SéanceDate initialeDate actuelleNouvelle dateChangement
{detail.sessions.find((session) => session.program_session_id === move.program_session_id)?.title}{move.original_for ?? '—'}{move.old_for ?? '—'}{move.new_for ?? '—'}{move.change === 'ceded' ? 'Créneau cédé' - : move.change === 'unchanged' ? 'Inchangée' : 'Recalée'}
- -
} -
+

+ {announcement} +

+ {detail.sessions.some((session) => session.planning_state === 'ceded') && + } + {days.length > 0 &&
- {days.map((day) =>
+ {days.map((day) =>
{ + 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) + }}>
{day.weekday}{day.dayNumber}
{day.sessions.length === 0 - ?

Repos

+ ?

—

:
{day.sessions.map(renderSession)}
}
)}
diff --git a/web/src/theme/app.css b/web/src/theme/app.css index da00701..e1f41e9 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -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);