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 ## 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 - Reconciled the canonical documentation against the `f4ddcfd` development
lineage: current source schemas v37/v34, implemented Web routes and Program lineage: current source schemas v37/v34, implemented Web routes and Program
rescheduling, observed package provenance, Sleep row paging, capability rescheduling, observed package provenance, Sleep row paging, capability

View file

@ -2,22 +2,20 @@
## Audited development line and private installation ## Audited development line and private installation
This snapshot audits `trainlog-sleep-single-selector-weeks-v1` at `f4ddcfd` This 0.1.7 correction joins the audited `f4ddcfd` development lineage,
on 2026-10-02. It includes the Program projection fix `8995843`, the body including the Program projection fix `8995843`, Android-matched Web body
figure Web change `c2935b8`, and the Sleep PDF, sport and paging changes by figures `c2935b8`, and the Sleep PDF, sport and paging changes, to the compact
ancestry. A later documentation-only commit does not imply a new runtime Program month calendar. The uncommitted Dashboard layout work in the separate
package. The uncommitted Dashboard layout work in the separate `trainlog-dashboard-polish-v1` worktree remains outside this snapshot.
`trainlog-dashboard-polish-v1` worktree is outside this snapshot.
The private daily installation runs the ordered Program rescheduling rollout The private daily Program rollout uses desktop schema v37, Android schema v34,
from `trainlog-program-recalage-v1` at `3a4ec34`, with desktop schema v37, and the capability-selected `trainlog-programs` V2 projection. The later
Android schema v34, and the capability-selected `trainlog-programs` V2 `program-calendar-dnd-drop-failure-v1-final` PC/Web package declared source
projection. The observed PC/Web launcher points to the commit `3a4ec34` and carried the uncommitted calendar UI, but omitted the
`sleep-single-selector-weeks-v1-f4ddcfd` package; synchronization agents subsequent body-figure and Sleep sport commits. The 0.1.7 correction was
retain the compatible Program validated in Firefox against a temporary snapshot of the real desktop data;
rollout bundle. The previous PC/Web bundle from it does not by itself prove a replacement daily package has been installed.
`trainlog-sleep-pdf-treatments-space-v1` at `3758875` remains available for The corrected Web uses Android Compose Canvas head, neck, and 22 body
rollback. The served Web uses Android Compose Canvas head, neck, and 22 body
paths on Dashboard muscle distribution, Analysis overview, and Analysis body paths on Dashboard muscle distribution, Analysis overview, and Analysis body
distribution. Firefox captures of all three served views show complete figures distribution. Firefox captures of all three served views show complete figures
at the intended aspect ratio, with the existing color scale, legend, and focus 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 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 continuations, was rendered page by page and checked in grayscale. Rollout
packaging requires a Meson `-Dweb=enabled` build with embedded frontend tests. 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 Full-generation synchronization uses one generation/ACK business engine across
versioned transports. On the paired 0.1.4 deployment, automatic local selection 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, automatic browser launch. Sessions provides complete paged preparation,
resume and history views, stable details, optimistic manual-preparation writes, resume and history views, stable details, optimistic manual-preparation writes,
explicit proposal derivation and generation-backed Android delivery. The 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, Sessions → Programmes remains the technical administration/import, list,
detail, archive, and delete surface. `/exercices` is the operational desktop detail, archive, and delete surface. `/exercices` is the operational desktop
catalogue administration surface. `/analyse` is implemented with Overview, 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 `trainlog-programs` V1 synchronization companion is a separate PC-to-Android
projection and does not alter this import format. 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 The `trainlog-program-recalage-v1` line adds a separate planning
state. `planned_for` remains the imported date. `current_for` is an absolute 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 cards still use the linked session's actual start timestamp. Positions from the
imported Program determine the cascade order; titles never determine priority. imported Program determine the cascade order; titles never determine priority.
The Web action selects a first and last unfinished session, a resumption date, The compact Web month calendar moves one eligible unfinished session to a
and the exact session identities whose slots are ceded. Its available days are 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 dated original Program slots in that interval on or after resumption.
The preview lists original, prior current and proposed dates. It rejects a 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 missing slot, a date beyond the Program end, a completed/in-progress/prepared

View file

@ -1,11 +1,11 @@
# Roadmap # Roadmap
The Program cascade is deployed in the private daily installation from The Program rescheduling service is deployed in the private daily installation
`trainlog-program-recalage-v1` at `3a4ec34`. The observed PC/Web launcher points from `trainlog-program-recalage-v1` at `3a4ec34`. A later compact-calendar
to the `f4ddcfd` package, which includes PC/Web package was built from that older source base and omitted the Web body
the verified blue Sleep sport overlay and the readable paginated treatment and figure and Sleep sport commits. The corrected 0.1.7 source combines the
remarks PDF. Synchronization services retain their Program rollout paths. No calendar with those later changes; replacement installation and verification
new personal Program rescheduling is implied by this presentation work. on the daily launcher remain operational work.
The controlled private rollout is complete for the paired daily installation: The controlled private rollout is complete for the paired daily installation:
physical libmtp, verified backups, signing continuity, migrations, correlated 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 ### 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 Ordered Program rescheduling is implemented in the audited development line
and deployed in the recorded private Program rollout. One explicit user action and deployed in the recorded private Program rollout. The retained cascade API
must select the starting session, restart date, affected successors, and usable selects the starting session, restart date, affected successors, and usable
slots; preview the old and new dates; then durably record one decision. Only slots; previews the old and new dates; then durably records one decision. Only
unfinished sessions move, in Program position order. Explicitly ceded optional unfinished sessions move, in Program position order. Explicitly ceded optional
slots may absorb required sessions without marking optional sessions completed slots may absorb required sessions without marking optional sessions completed
or removing historical identities. At most one principal Program session per 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 || if (program == NULL || count > 64U ||
!yyjson_equals_str(yyjson_obj_get(program, "state"), "active") || !yyjson_equals_str(yyjson_obj_get(program, "state"), "active") ||
!yyjson_equals_str(yyjson_obj_get(program, "revision_id"), !yyjson_equals_str(yyjson_obj_get(program, "revision_id"),
yyjson_get_str(yyjson_obj_get(request, "expected_revision"))) || yyjson_get_str(yyjson_obj_get(request, "expected_revision")))) {
!yyjson_is_str(yyjson_obj_get(program, "end_date"))) {
status = planning_error( status = planning_error(
"stale_or_ineligible_program", TRAINLOG_STATUS_CONFLICT, output, output_size); "stale_or_ineligible_program", TRAINLOG_STATUS_CONFLICT, output, output_size);
goto done; goto done;
@ -213,8 +212,9 @@ static TrainlogStatus build_preview(TrainlogDatabase *database,
} }
} }
if (first >= count || last >= count || first > last || if (first >= count || last >= count || first > last ||
strcmp(yyjson_get_str(yyjson_obj_get(request, "start_date")), (first != last && (!yyjson_is_str(yyjson_obj_get(program, "end_date")) ||
yyjson_get_str(yyjson_obj_get(program, "end_date"))) > 0) { strcmp(yyjson_get_str(yyjson_obj_get(request, "start_date")),
yyjson_get_str(yyjson_obj_get(program, "end_date"))) > 0))) {
status = planning_error( status = planning_error(
"invalid_selection", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size); "invalid_selection", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
goto done; goto done;
@ -240,6 +240,20 @@ static TrainlogStatus build_preview(TrainlogDatabase *database,
goto done; 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) { for (size_t index = first; index <= last; ++index) {
if (strcmp(rows[index].execution, "todo") != 0 || if (strcmp(rows[index].execution, "todo") != 0 ||
has_preparation(database, rows[index].id) || rows[index].original == NULL || 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++]; rows[index].assigned = slots[next_slot++];
} }
assigned:
// CONTRACT: the choice digest includes stable identities in their supplied // CONTRACT: the choice digest includes stable identities in their supplied
// order. The fact digest includes the complete current Program projection. // 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; 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) { int main(void) {
return import_archive_and_prepare() && strict_import_rejections() && return import_archive_and_prepare() && strict_import_rejections() &&
mixed_profile_prepare_regression() && mixed_profile_prepare_regression() &&
legacy_integer_weight_zero_prepares_editable_draft() && 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_SUCCESS
: EXIT_FAILURE; : EXIT_FAILURE;
} }

View file

@ -107,7 +107,7 @@ describe('ProgramsCalendarPage', () => {
expect(screen.queryByRole('combobox', { name: 'Programme actif' })).not.toBeInTheDocument() 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 main = session('pgs_main', 'Séance principale', '2026-09-18')
const evening = session('pgs_evening', 'Étirements du soir', '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'), 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('Séance principale')).toBeInTheDocument()
expect(within(friday).getByText('Étirements du soir')).toBeInTheDocument() expect(within(friday).getByText('Étirements du soir')).toBeInTheDocument()
const saturday = screen.getByLabelText('Sam 19/09/2026') const saturday = screen.getByLabelText('Sam 19/09/2026')
expect(within(saturday).getByText('Créneau cédé')).toBeInTheDocument() expect(within(saturday).getByText('Séance écartée')).toBeInTheDocument()
expect(within(saturday).queryByRole('button', { name: 'Préparer' })).not.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 () => { it('moves one future session onto an occupied day without losing the other card', async () => {
const before = detail({ sessions: [ const first = session('pgs_b', 'S2 D — Haut du corps', '2026-09-18')
session('pgs_b', 'B', '2026-09-18'), const optional = session('pgs_f', 'S2 F — Technique (FACULTATIVE)', '2026-09-19')
session('pgs_c', 'C', '2026-09-19'), optional.position = 1
] }) api.fetchProgram.mockResolvedValueOnce(detail({ sessions: [first, optional] }))
api.fetchProgram.mockResolvedValue(before) .mockResolvedValue(detail({ sessions: [
{ ...first, current_for: '2026-09-19' }, optional,
], revision_id: 'pgr_next' }))
api.previewProgramReschedule.mockResolvedValue({ api.previewProgramReschedule.mockResolvedValue({
api_version: 1, program_id: 'pg_one', revision_id: 'pgr_one', api_version: 1, program_id: 'pg_one', revision_id: 'pgr_one',
preview_sha256: 'f'.repeat(64), moves: [ preview_sha256: 'f'.repeat(64), moves: [{ program_session_id: 'pgs_b',
{ program_session_id: 'pgs_b', original_for: '2026-09-18', original_for: '2026-09-18', old_for: '2026-09-18',
old_for: '2026-09-18', new_for: '2026-09-19', change: 'rescheduled' }, 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' },
],
}) })
api.applyProgramReschedule.mockResolvedValue({}) api.applyProgramReschedule.mockResolvedValue({})
render(<ProgramsCalendarPage onNavigate={vi.fn()} />) const view = render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
await screen.findByRole('heading', { level: 1, name: 'Cycle force' }) const source = await screen.findByRole('article', { name: /S2 D/ })
fireEvent.change(screen.getByLabelText('Première séance'), { target: { value: 'pgs_b' } }) expect(source).toHaveAttribute('draggable', 'true')
fireEvent.change(screen.getByLabelText('Dernière séance'), { target: { value: 'pgs_c' } }) const dataTransfer = {
fireEvent.change(screen.getByLabelText('Date de reprise'), { target: { value: '2026-09-19' } }) setData: vi.fn(), getData: vi.fn(() => 'pgs_b'), effectAllowed: '', dropEffect: '',
fireEvent.click(screen.getByLabelText(/C \(2026-09-19\)/)) }
fireEvent.click(screen.getByRole('button', { name: 'Calculer l’aperçu' })) fireEvent.dragStart(source, { dataTransfer })
expect(await screen.findByRole('heading', { name: 'Aperçu avant application' })).toBeInTheDocument() const target = screen.getByRole('region', { name: 'Sam 19/09/2026' })
expect(api.applyProgramReschedule).not.toHaveBeenCalled() fireEvent.dragOver(target, { dataTransfer })
fireEvent.click(screen.getByRole('button', { name: 'Confirmer ce recalage' })) expect(target).toHaveClass('program-calendar-drop-target')
fireEvent.drop(target, { dataTransfer })
await waitFor(() => expect(api.applyProgramReschedule).toHaveBeenCalledTimes(1)) 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 () => { it('shows the exact empty state and navigates to Sessions program administration', async () => {
@ -205,13 +328,13 @@ describe('ProgramsCalendarPage', () => {
render(<ProgramsCalendarPage onNavigate={vi.fn()} />) render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
await screen.findByRole('heading', { level: 1, name: 'Cycle force' }) await screen.findByRole('heading', { level: 1, name: 'Cycle force' })
expect(screen.getByText('PROGRAMME ACTIF')).toBeInTheDocument() expect(screen.queryByText('PROGRAMME ACTIF')).not.toBeInTheDocument()
expect(screen.getByText('16/09/2026 – 24/09/2026 · 1 séance')).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') const calendar = screen.getByLabelText('Calendrier hebdomadaire du programme')
expect(within(calendar).getAllByRole('region')).toHaveLength(14) expect(within(calendar).getAllByRole('region')).toHaveLength(35)
expect(within(calendar).getByLabelText('Lun 14/09/2026')).toBeInTheDocument() expect(within(calendar).getByLabelText('Lun 31/08/2026')).toBeInTheDocument()
expect(within(calendar).getByLabelText('Dim 27/09/2026')).toBeInTheDocument() expect(within(calendar).getByLabelText('Dim 04/10/2026')).toBeInTheDocument()
expect(within(calendar).getAllByText('Repos')).toHaveLength(13) expect(within(calendar).getAllByText('—')).toHaveLength(34)
expect(within(calendar).getAllByText('Jambes')).toHaveLength(1) expect(within(calendar).getAllByText('Jambes')).toHaveLength(1)
}) })
@ -245,8 +368,8 @@ describe('ProgramsCalendarPage', () => {
const actualDay = await screen.findByLabelText('Mer 23/09/2026') const actualDay = await screen.findByLabelText('Mer 23/09/2026')
expect(within(actualDay).getByText('Séance A')).toBeInTheDocument() expect(within(actualDay).getByText('Séance A')).toBeInTheDocument()
expect(within(actualDay).getByText('Séance B')).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')) expect(within(actualDay).getByRole('article', { name: /Séance A/ })).toHaveAttribute('draggable', 'false')
.toBeInTheDocument() expect(within(actualDay).getByText('✓')).toBeInTheDocument()
expect(within(screen.getByLabelText('Mar 22/09/2026')).queryByText('Séance A')) expect(within(screen.getByLabelText('Mar 22/09/2026')).queryByText('Séance A'))
.not.toBeInTheDocument() .not.toBeInTheDocument()
expect(completed.planned_for).toBe('2026-09-22') expect(completed.planned_for).toBe('2026-09-22')
@ -286,7 +409,8 @@ describe('ProgramsCalendarPage', () => {
const plannedDay = await screen.findByLabelText('Mar 22/09/2026') const plannedDay = await screen.findByLabelText('Mar 22/09/2026')
expect(within(plannedDay).getByText('Séance préparée')).toBeInTheDocument() 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')) expect(within(screen.getByLabelText('Mer 23/09/2026')).queryByText('Séance préparée'))
.not.toBeInTheDocument() .not.toBeInTheDocument()
}) })
@ -299,7 +423,7 @@ describe('ProgramsCalendarPage', () => {
render(<ProgramsCalendarPage onNavigate={vi.fn()} />) render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
const actualDate = formatDate(completed.execution_started_at, 'iso') 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(within(actualDay).getByText('Séance tardive')).toBeInTheDocument()
expect(screen.getAllByText('Séance tardive')).toHaveLength(1) expect(screen.getAllByText('Séance tardive')).toHaveLength(1)
expect(within(screen.getByLabelText('Mer 16/09/2026')).queryByText('Séance tardive')) 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') { if (Intl.DateTimeFormat().resolvedOptions().timeZone === 'Europe/Paris') {
expect(actualDate).toBe(parisDate) 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(within(actualDay).getByText('Séance réelle')).toBeInTheDocument()
expect(screen.getAllByText('Séance réelle')).toHaveLength(1) 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)) })) session(`pgs_${state}`, `Séance ${index}`, `2026-09-${String(16 + index).padStart(2, '0')}`, state)) }))
render(<ProgramsCalendarPage onNavigate={vi.fn()} />) 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' }) await screen.findByRole('heading', { level: 1, name: 'Cycle force' })
states.forEach((state, index) => { states.forEach((state, index) => {
const title = screen.getByText(`Séance ${index}`) const title = screen.getByText(`Séance ${index}`)
expect(title.closest('article')).toHaveClass(`program-calendar-state-${state}`) 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) expect(screen.getAllByRole('button', { name: 'Préparer' })).toHaveLength(1)
}) })
@ -379,7 +501,7 @@ describe('ProgramsCalendarPage', () => {
command.resolve({ preparation_id: 'sp_created' }) command.resolve({ preparation_id: 'sp_created' })
await waitFor(() => expect(screen.getByText('Préparation créée.')).toBeInTheDocument()) await waitFor(() => expect(screen.getByText('Préparation créée.')).toBeInTheDocument())
expect(api.fetchProgram).toHaveBeenCalledTimes(2) 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() expect(screen.queryByRole('button', { name: 'Préparer' })).not.toBeInTheDocument()
}) })
@ -450,7 +572,7 @@ describe('ProgramsCalendarPage', () => {
fireEvent.click(await screen.findByRole('button', { name: 'Préparer' })) fireEvent.click(await screen.findByRole('button', { name: 'Préparer' }))
expect(await screen.findByText('Préparation impossible : conflit de révision')).toBeInTheDocument() expect(await screen.findByText('Préparation impossible : conflit de révision')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Préparer' })).toBeEnabled() 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(api.fetchProgram).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('link', { name: 'Ouvrir la préparation' })).not.toBeInTheDocument() 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' })) 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(await screen.findByText(/programme n’a pas pu être actualisé : réseau indisponible/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Préparer' })).toBeEnabled() 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 ProgramListItem,
type ProgramSession, type ProgramSession,
type RescheduleChoice, type RescheduleChoice,
type ReschedulePreview,
} from '../api/programs' } from '../api/programs'
import { useDatePreferences } from '../presentation/DatePreferences' import { useDatePreferences } from '../presentation/DatePreferences'
import { formatCivilDate, formatDate, parseCivilDate, validTimestampValue } from '../presentation/dateFormat' 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 // 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. // 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 // CONTRACT: completed cards use that same local day; imported planned_for
// available to explain the move. No schedule mutation happens during render. // remains immutable while unfinished cards use their current planning date.
// No schedule mutation happens during render.
function actualExecutionDate(session: ProgramSession): string | null { function actualExecutionDate(session: ProgramSession): string | null {
if (session.execution_state !== 'completed' || typeof session.execution_started_at !== 'string') return null if (session.execution_state !== 'completed' || typeof session.execution_started_at !== 'string') return null
if (validTimestampValue(session.execution_started_at) === null) 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. // a workout performed on the imported plan date.
return session.execution_state === 'completed' return session.execution_state === 'completed'
? actualExecutionDate(session) ? 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 datedSessions = program.sessions.filter((session) => dateValue(displayedDate(session)) !== null)
const values = [program.start_date, program.end_date, ...datedSessions.map(displayedDate)] const first = dateValue(`${month}-01`)
.map(dateValue).filter((value): value is number => value !== null) if (first === null) return []
if (values.length === 0) return [] const date = new Date(first)
date.setUTCMonth(date.getUTCMonth() + 1)
const first = Math.min(...values) date.setUTCDate(0)
const last = Math.max(...values) const last = date.valueOf()
const firstWeekday = (new Date(first).getUTCDay() + 6) % 7 const firstWeekday = (new Date(first).getUTCDay() + 6) % 7
const lastWeekday = (new Date(last).getUTCDay() + 6) % 7 const lastWeekday = (new Date(last).getUTCDay() + 6) % 7
const start = first - firstWeekday * dayMilliseconds const start = first - firstWeekday * dayMilliseconds
@ -87,6 +89,10 @@ function calendarDays(program: ProgramDetail): CalendarDay[] {
sessions.push(session) sessions.push(session)
sessionsByDate.set(day, sessions) 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[] = [] const days: CalendarDay[] = []
for (let value = start; value <= end; value += dayMilliseconds) { for (let value = start; value <= end; value += dayMilliseconds) {
@ -102,10 +108,43 @@ function calendarDays(program: ProgramDetail): CalendarDay[] {
return days 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 { function errorMessage(reason: unknown, fallback: string): string {
return reason instanceof Error && reason.message ? reason.message : fallback 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 }: { function NavigationLink({ path, onNavigate, children, className }: {
path: string path: string
onNavigate: (path: string) => void onNavigate: (path: string) => void
@ -119,41 +158,55 @@ function NavigationLink({ path, onNavigate, children, className }: {
}}>{children}</a> }}>{children}</a>
} }
function ProgramSessionCard({ programId, session, pending, preparationId, onPrepare, onNavigate, function ProgramSessionCard({ programId, session, pending, moving, preparationId, onPrepare,
dateFormat }: { onMove, onDragEnd, onNavigate, dateFormat }: {
programId: string programId: string
session: ProgramSession session: ProgramSession
pending: boolean pending: boolean
moving: boolean
preparationId?: string preparationId?: string
onPrepare: (programId: string, session: ProgramSession) => void onPrepare: (programId: string, session: ProgramSession) => void
onMove: (session: ProgramSession, date: string) => void
onDragEnd: () => void
onNavigate: (path: string) => void onNavigate: (path: string) => void
dateFormat: ReturnType<typeof useDatePreferences>['dateFormat'] dateFormat: ReturnType<typeof useDatePreferences>['dateFormat']
}) { }) {
const actualDate = actualExecutionDate(session) const actualDate = actualExecutionDate(session)
return <article className={`program-calendar-session program-calendar-state-${session.execution_state}`}> const [editingDate, setEditingDate] = useState(false)
<h3>{session.title}</h3> const [newDate, setNewDate] = useState(displayedDate(session) ?? '')
<span className="program-calendar-state-label">{session.planning_state === 'ceded' && const movable = session.execution_state === 'todo' && !moving
session.execution_state !== 'completed' ? 'Créneau cédé' : stateLabels[session.execution_state]}</span> const [shortId, ...titleParts] = session.title.split(' — ')
{session.planning_state === 'active' && session.current_for !== session.planned_for && return <article className={`program-calendar-session program-calendar-state-${session.execution_state}`}
session.execution_state !== 'completed' && <p className="program-calendar-date-note"> draggable={movable} tabIndex={0}
Recalée du {session.planned_for === null ? 'jour initial non défini' : formatCivilDate(session.planned_for, dateFormat)} aria-label={`${session.title}, ${stateLabels[session.execution_state]}, ${
{' au '}{session.current_for === null ? 'jour non défini' : formatCivilDate(session.current_for, dateFormat)} displayedDate(session) ? formatCivilDate(displayedDate(session) as string, dateFormat) : 'sans date'}`}
</p>} title={session.note ?? session.title}
{actualDate !== null && actualDate !== session.planned_for && <p className="program-calendar-date-note"> onDragStart={(event) => {
Prévue le {session.planned_for === null ? 'date non définie' : formatCivilDate(session.planned_for, dateFormat)} if (!movable) { event.preventDefault(); return }
{' · '}effectuée le {formatCivilDate(actualDate, dateFormat)} event.dataTransfer.setData('text/plain', session.program_session_id)
</p>} 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 && {session.execution_state === 'completed' && actualDate === null &&
<p className="program-calendar-date-note">Date réelle indisponible</p>} <p className="program-calendar-date-note">Date réelle indisponible</p>}
<div className="program-calendar-action"> {movable && <button className="program-calendar-edit-date" type="button"
{session.execution_state === 'todo' && session.planning_state === 'active' && aria-label={`Modifier la date de ${session.title}`} onClick={() => setEditingDate(!editingDate)}>
<button type="button" disabled={pending} Déplacer
onClick={() => onPrepare(programId, session)}> </button>}
{pending ? 'Préparation…' : 'Préparer'} {editingDate && <form className="program-calendar-date-form" onSubmit={(event) => {
</button>} 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)}`} {preparationId && <NavigationLink path={`/seances/preparation/${encodeURIComponent(preparationId)}`}
onNavigate={onNavigate}>Ouvrir la préparation</NavigationLink>} onNavigate={onNavigate}>Ouvrir la préparation</NavigationLink>}
</div> </details>}
</article> </article>
} }
@ -169,18 +222,15 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
const [pendingSessions, setPendingSessions] = useState<Set<string>>(new Set()) const [pendingSessions, setPendingSessions] = useState<Set<string>>(new Set())
const [preparationIds, setPreparationIds] = useState<Record<string, string>>({}) const [preparationIds, setPreparationIds] = useState<Record<string, string>>({})
const [announcement, setAnnouncement] = useState('') const [announcement, setAnnouncement] = useState('')
const [rescheduleStart, setRescheduleStart] = useState('') const [moveError, setMoveError] = useState(false)
const [rescheduleThrough, setRescheduleThrough] = useState('') const [visibleMonth, setVisibleMonth] = useState('')
const [rescheduleDate, setRescheduleDate] = useState('') const [movingSession, setMovingSession] = useState('')
const [cededIds, setCededIds] = useState<string[]>([]) const [dropDate, setDropDate] = useState('')
const [reschedulePreview, setReschedulePreview] = useState<{
choice: RescheduleChoice, result: ReschedulePreview, operationId: string
} | null>(null)
const [reschedulePending, setReschedulePending] = useState(false)
const preparing = useRef(new Set<string>()) const preparing = useRef(new Set<string>())
const mounted = useRef(true) const mounted = useRef(true)
const selectedIdRef = useRef('') const selectedIdRef = useRef('')
const selectionGeneration = useRef(0) const selectionGeneration = useRef(0)
const announcementRef = useRef<HTMLParagraphElement>(null)
useEffect(() => { useEffect(() => {
mounted.current = true mounted.current = true
@ -221,14 +271,15 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
setDetailPending(true) setDetailPending(true)
setDetailError('') setDetailError('')
setAnnouncement('') setAnnouncement('')
setReschedulePreview(null) setMoveError(false)
setRescheduleStart('') setMovingSession('')
setRescheduleThrough('') setDropDate('')
setRescheduleDate('')
setCededIds([])
setPreparationIds({}) setPreparationIds({})
fetchProgram(selectedId, controller.signal).then((value) => { fetchProgram(selectedId, controller.signal).then((value) => {
if (isCurrent()) setDetail(value) if (isCurrent()) {
setDetail(value)
setVisibleMonth(monthForProgram(value))
}
}).catch((reason) => { }).catch((reason) => {
if (isCurrent()) { if (isCurrent()) {
setDetailError(errorMessage(reason, 'Chargement du programme impossible.')) setDetailError(errorMessage(reason, 'Chargement du programme impossible.'))
@ -239,54 +290,119 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
return () => controller.abort() return () => controller.abort()
}, [selectedId]) }, [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 undatedSessions = detail?.sessions.filter((session) => dateValue(displayedDate(session)) === null) ?? []
const openSessions = detail?.sessions.filter((session) => session.execution_state === 'todo') ?? [] const moveSession = async (session: ProgramSession, date: string) => {
if (!detail || movingSession || session.execution_state !== 'todo' ||
const previewReschedule = async () => { parseCivilDate(date) === null) return
if (!detail || !rescheduleStart || !rescheduleThrough || !rescheduleDate) return
const programId = detail.program_id const programId = detail.program_id
const generation = selectionGeneration.current const generation = selectionGeneration.current
const isCurrent = () => mounted.current && selectedIdRef.current === programId &&
selectionGeneration.current === generation
const choice: RescheduleChoice = { const choice: RescheduleChoice = {
start_session_id: rescheduleStart, start_session_id: session.program_session_id,
through_session_id: rescheduleThrough, through_session_id: session.program_session_id,
start_date: rescheduleDate, start_date: date,
ceded_session_ids: cededIds, ceded_session_ids: [],
expected_revision: detail.revision_id, expected_revision: detail.revision_id,
} }
setReschedulePending(true) if (session.planning_state === 'active' && session.current_for === date) return
setReschedulePreview(null) setMovingSession(session.program_session_id)
setAnnouncement('')
setMoveError(false)
let commitAttempted = false
try { try {
const result = await previewProgramReschedule(programId, choice) // CONTRACT: one drop changes one identity through the existing revisioned
if (mounted.current && selectionGeneration.current === generation) { // preview/apply service. The authoritative reread publishes the new day.
setReschedulePreview({ choice, result, operationId: newRescheduleOperationId() }) const preview = await previewProgramReschedule(programId, choice)
setAnnouncement('Aperçu calculé. Vérifiez les dates avant de confirmer.') 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) { } 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 { } finally {
setReschedulePending(false) setDropDate('')
setMovingSession('')
} }
} }
const confirmReschedule = async () => { const restoreCeded = async () => {
if (!detail || !reschedulePreview) return if (!detail || movingSession) return
const { choice, result, operationId } = reschedulePreview
const programId = detail.program_id const programId = detail.program_id
const generation = selectionGeneration.current const generation = selectionGeneration.current
setReschedulePending(true) const isCurrent = () => mounted.current && selectedIdRef.current === programId &&
selectionGeneration.current === generation
let current = detail
setMovingSession('restore')
setMoveError(false)
try { try {
await applyProgramReschedule(programId, choice, result.preview_sha256, operationId) // WHY: a past ceded decision removed the current date without removing
const reread = await fetchProgram(programId) // its definition. CONTRACT: restore each exact identity to its original
if (mounted.current && selectionGeneration.current === generation) { // date; a reread supplies the next revision. Retrying skips active rows.
setDetail(reread) for (const session of detail.sessions.filter((candidate) =>
setReschedulePreview(null) candidate.planning_state === 'ceded')) {
setAnnouncement('Recalage enregistré. La synchronisation Android reste à vérifier.') 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) { } 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 { } finally {
setReschedulePending(false) setMovingSession('')
} }
} }
@ -299,6 +415,7 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
preparing.current.add(sessionId) preparing.current.add(sessionId)
setPendingSessions((current) => new Set(current).add(sessionId)) setPendingSessions((current) => new Set(current).add(sessionId))
setAnnouncement('') setAnnouncement('')
setMoveError(false)
try { try {
const result = await createPreparationFromProgram(programId, sessionId) const result = await createPreparationFromProgram(programId, sessionId)
if (!isCurrent()) return if (!isCurrent()) return
@ -336,8 +453,11 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
programId={detail?.program_id ?? ''} programId={detail?.program_id ?? ''}
session={session} session={session}
pending={pendingSessions.has(session.program_session_id)} pending={pendingSessions.has(session.program_session_id)}
moving={Boolean(movingSession)}
preparationId={preparationIds[session.program_session_id]} preparationId={preparationIds[session.program_session_id]}
onPrepare={prepare} onPrepare={prepare}
onMove={(target, date) => void moveSession(target, date)}
onDragEnd={() => setDropDate('')}
onNavigate={onNavigate} onNavigate={onNavigate}
dateFormat={dateFormat} dateFormat={dateFormat}
/> />
@ -373,7 +493,6 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
{!detailPending && detail && <> {!detailPending && detail && <>
<header className="program-calendar-header"> <header className="program-calendar-header">
<div> <div>
<p className="eyebrow">PROGRAMME ACTIF</p>
<h1>{detail.title}</h1> <h1>{detail.title}</h1>
<p className="program-calendar-summary"> <p className="program-calendar-summary">
{detail.start_date && detail.end_date {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.start_date ? `À partir du ${formatCivilDate(detail.start_date, dateFormat)}`
: detail.end_date ? `Jusqu’au ${formatCivilDate(detail.end_date, dateFormat)}` : detail.end_date ? `Jusqu’au ${formatCivilDate(detail.end_date, dateFormat)}`
: 'Dates non définies'} : '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> </p>
</div> </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> </header>
<p className="program-calendar-announcement" aria-live="polite">{announcement}</p> <p ref={announcementRef} className="program-calendar-announcement"
<section className="program-calendar-reschedule" aria-label="Recaler le programme"> role={moveError ? 'alert' : undefined} aria-live={moveError ? 'assertive' : 'polite'}>
<h2>Recaler la suite</h2> {announcement}
<p>Choisissez les séances concernées dans leur ordre, la reprise et les créneaux cédés. </p>
L’aperçu utilise les dates de créneaux déjà inscrites au programme.</p> {detail.sessions.some((session) => session.planning_state === 'ceded') &&
<div className="program-calendar-reschedule-fields"> <button className="program-calendar-restore" type="button" disabled={Boolean(movingSession)}
<label>Première séance<select value={rescheduleStart} onChange={(event) => { onClick={() => void restoreCeded()}>
setRescheduleStart(event.target.value); setReschedulePreview(null) Restaurer les séances écartées
}}><option value="">Choisir</option>{openSessions.map((session) => </button>}
<option key={session.program_session_id} value={session.program_session_id}> <nav className="program-calendar-navigation" aria-label="Navigation du calendrier">
Début : {session.title}</option>)}</select></label> <button type="button" onClick={() => setVisibleMonth(
<label>Dernière séance<select value={rescheduleThrough} onChange={(event) => { formatDate(new Date().toISOString(), 'iso').slice(0, 7))}>
setRescheduleThrough(event.target.value); setReschedulePreview(null) Aujourd’hui</button>
}}><option value="">Choisir</option>{openSessions.map((session) => <button type="button" aria-label="Mois précédent"
<option key={session.program_session_id} value={session.program_session_id}> onClick={() => setVisibleMonth(adjacentMonth(visibleMonth, -1))}>‹</button>
Fin : {session.title}</option>)}</select></label> <strong>{visibleMonth && new Intl.DateTimeFormat('fr-FR', { month: 'long', year: 'numeric',
<label>Date de reprise<input type="date" value={rescheduleDate} timeZone: 'UTC' }).format(new Date(`${visibleMonth}-01T00:00:00Z`))}</strong>
min={detail.start_date ?? undefined} max={detail.end_date ?? undefined} <button type="button" aria-label="Mois suivant"
onChange={(event) => { setRescheduleDate(event.target.value); setReschedulePreview(null) }} /></label> onClick={() => setVisibleMonth(adjacentMonth(visibleMonth, 1))}>›</button>
</div> </nav>
<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>
{days.length > 0 && <div className="program-calendar-scroll" tabIndex={0} {days.length > 0 && <div className="program-calendar-scroll" tabIndex={0}
aria-label="Calendrier hebdomadaire du programme"> aria-label="Calendrier hebdomadaire du programme">
<div className="program-calendar-grid"> <div className="program-calendar-grid">
{days.map((day) => <section className="program-calendar-day" key={day.date} {days.map((day) => <section className={`program-calendar-day${
aria-label={`${day.weekday} ${formatCivilDate(day.date, dateFormat)}`}> 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> <header><span>{day.weekday}</span><strong>{day.dayNumber}</strong>
<time dateTime={day.date}>{formatCivilDate(day.date, dateFormat)}</time></header> <time dateTime={day.date}>{formatCivilDate(day.date, dateFormat)}</time></header>
{day.sessions.length === 0 {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>} : <div className="program-calendar-day-sessions">{day.sessions.map(renderSession)}</div>}
</section>)} </section>)}
</div> </div>

View file

@ -1743,20 +1743,20 @@ h2 {
border-radius: 0.55rem; border-radius: 0.55rem;
} }
.program-calendar-header { .program-calendar-header {
margin-bottom: 1.25rem; margin-bottom: 0.75rem;
display: flex; display: flex;
align-items: end; align-items: end;
justify-content: space-between; justify-content: space-between;
gap: 2rem; gap: 2rem;
} }
.program-calendar-header h1 { .program-calendar-header h1 {
max-width: 24ch; max-width: 55ch;
display: -webkit-box; margin: 0;
overflow: hidden; font-size: clamp(1.4rem, 2.4vw, 1.9rem);
font-size: clamp(2.15rem, 4vw, 2.8rem); line-height: 1.15;
line-height: 1.02; }
-webkit-box-orient: vertical; .app-main:has(.programs-calendar-page) {
-webkit-line-clamp: 2; padding-top: 1.25rem;
} }
.settings-grid, .settings-grid,
@ -2040,47 +2040,47 @@ h2 {
background: color-mix(in srgb, var(--surface-1) 35%, transparent); background: color-mix(in srgb, var(--surface-1) 35%, transparent);
} }
.program-calendar-announcement { .program-calendar-announcement {
min-height: 1.5rem;
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
color: var(--subtext-0); color: var(--subtext-0);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }
.program-calendar-reschedule { .program-calendar-announcement:empty {
margin: 0 0 1rem; display: none;
padding: 1rem; }
.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: 1px solid var(--surface-1);
border-radius: 0.75rem; border-radius: 0.5rem;
} background: var(--mantle);
.program-calendar-reschedule-fields { color: var(--text);
display: flex; cursor: pointer;
flex-wrap: wrap; padding: 0.3rem 0.55rem;
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);
} }
.program-calendar-scroll { .program-calendar-scroll {
max-width: 100%; max-width: 100%;
@ -2092,25 +2092,29 @@ h2 {
outline: 2px solid var(--lavender); outline: 2px solid var(--lavender);
} }
.program-calendar-grid { .program-calendar-grid {
min-width: 72rem; min-width: 56rem;
display: grid; 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-top: 1px solid var(--surface-1);
border-left: 1px solid var(--surface-1); border-left: 1px solid var(--surface-1);
} }
.program-calendar-day { .program-calendar-day {
min-width: 0; min-width: 0;
min-height: 12rem; min-height: 6.5rem;
padding: 0.7rem; padding: 0.4rem;
background: var(--mantle); background: var(--mantle);
border-right: 1px solid var(--surface-1); border-right: 1px solid var(--surface-1);
border-bottom: 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 { .program-calendar-day > header {
display: grid; display: flex;
grid-template-columns: 1fr auto;
align-items: baseline; align-items: baseline;
gap: 0.15rem 0.5rem; gap: 0.35rem;
} }
.program-calendar-day > header span { .program-calendar-day > header span {
color: var(--subtext-0); color: var(--subtext-0);
@ -2119,68 +2123,81 @@ h2 {
} }
.program-calendar-day > header strong { .program-calendar-day > header strong {
color: var(--text); color: var(--text);
font-size: var(--font-size-lg); font-size: var(--font-size-sm);
} }
.program-calendar-day > header time { .program-calendar-day > header time {
grid-column: 1 / -1; position: absolute;
color: var(--overlay-1); width: 1px;
font-size: var(--font-size-xs); height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
} }
.program-calendar-rest { .program-calendar-rest {
margin: 2rem 0 0; margin: 0.7rem 0 0;
color: var(--overlay-1); color: var(--overlay-1);
font-size: var(--font-size-sm); font-size: var(--font-size-xs);
text-align: center; text-align: center;
} }
.program-calendar-day-sessions { .program-calendar-day-sessions {
margin-top: 0.75rem; margin-top: 0.35rem;
display: grid; display: grid;
gap: 0.55rem; gap: 0.3rem;
} }
.program-calendar-session { .program-calendar-session {
padding: 0.65rem; padding: 0.4rem;
background: var(--base); background: var(--base);
border: 1px solid var(--surface-1); border: 1px solid var(--surface-1);
border-left: 0.22rem solid currentColor; border-left: 0.2rem solid currentColor;
border-radius: 0.55rem; border-radius: 0.5rem;
} }
.program-calendar-session h3 { .program-calendar-session h3 {
margin: 0 0 0.5rem; margin: 0;
color: var(--text); color: var(--text);
font-size: var(--font-size-sm); font-size: var(--font-size-xs);
line-height: 1.35; line-height: 1.25;
} }
.program-calendar-state-label { .program-calendar-session h3 strong,
display: inline-flex; .program-calendar-session h3 span {
padding: 0.2rem 0.4rem; 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 { .program-calendar-date-note {
margin: 0.45rem 0 0; margin: 0.35rem 0 0;
color: var(--muted); color: var(--muted);
font-size: var(--font-size-xs); 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 { .program-calendar-undated {
margin-top: 2rem; margin-top: 2rem;
padding: clamp(1rem, 3vw, 1.5rem); padding: clamp(1rem, 3vw, 1.5rem);