fix(web): restore sleep and body parity with program calendar
This commit is contained in:
parent
1c50d483f2
commit
700dfc0d5e
9 changed files with 692 additions and 301 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ describe('ProgramsCalendarPage', () => {
|
|||
expect(screen.queryByRole('combobox', { name: 'Programme actif' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps distinct cards on one date and labels a ceded slot separately', async () => {
|
||||
it('keeps ceded definitions visible and offers explicit restoration', async () => {
|
||||
const main = session('pgs_main', 'Séance principale', '2026-09-18')
|
||||
const evening = session('pgs_evening', 'Étirements du soir', '2026-09-18')
|
||||
const ceded = { ...session('pgs_ceded', 'Séance écartée', '2026-09-19'),
|
||||
|
|
@ -120,38 +120,161 @@ describe('ProgramsCalendarPage', () => {
|
|||
expect(within(friday).getByText('Séance principale')).toBeInTheDocument()
|
||||
expect(within(friday).getByText('Étirements du soir')).toBeInTheDocument()
|
||||
const saturday = screen.getByLabelText('Sam 19/09/2026')
|
||||
expect(within(saturday).getByText('Créneau cédé')).toBeInTheDocument()
|
||||
expect(within(saturday).queryByRole('button', { name: 'Préparer' })).not.toBeInTheDocument()
|
||||
expect(within(saturday).getByText('Séance écartée')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Restaurer les séances écartées' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Créneau cédé')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('previews ordered changes before one confirmation', async () => {
|
||||
const before = detail({ sessions: [
|
||||
session('pgs_b', 'B', '2026-09-18'),
|
||||
session('pgs_c', 'C', '2026-09-19'),
|
||||
] })
|
||||
api.fetchProgram.mockResolvedValue(before)
|
||||
it('moves one future session onto an occupied day without losing the other card', async () => {
|
||||
const first = session('pgs_b', 'S2 D — Haut du corps', '2026-09-18')
|
||||
const optional = session('pgs_f', 'S2 F — Technique (FACULTATIVE)', '2026-09-19')
|
||||
optional.position = 1
|
||||
api.fetchProgram.mockResolvedValueOnce(detail({ sessions: [first, optional] }))
|
||||
.mockResolvedValue(detail({ sessions: [
|
||||
{ ...first, current_for: '2026-09-19' }, optional,
|
||||
], revision_id: 'pgr_next' }))
|
||||
api.previewProgramReschedule.mockResolvedValue({
|
||||
api_version: 1, program_id: 'pg_one', revision_id: 'pgr_one',
|
||||
preview_sha256: 'f'.repeat(64), moves: [
|
||||
{ program_session_id: 'pgs_b', original_for: '2026-09-18',
|
||||
old_for: '2026-09-18', new_for: '2026-09-19', change: 'rescheduled' },
|
||||
{ program_session_id: 'pgs_c', original_for: '2026-09-19',
|
||||
old_for: '2026-09-19', new_for: null, change: 'ceded' },
|
||||
],
|
||||
preview_sha256: 'f'.repeat(64), moves: [{ program_session_id: 'pgs_b',
|
||||
original_for: '2026-09-18', old_for: '2026-09-18',
|
||||
new_for: '2026-09-19', change: 'rescheduled' }],
|
||||
})
|
||||
api.applyProgramReschedule.mockResolvedValue({})
|
||||
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
await screen.findByRole('heading', { level: 1, name: 'Cycle force' })
|
||||
fireEvent.change(screen.getByLabelText('Première séance'), { target: { value: 'pgs_b' } })
|
||||
fireEvent.change(screen.getByLabelText('Dernière séance'), { target: { value: 'pgs_c' } })
|
||||
fireEvent.change(screen.getByLabelText('Date de reprise'), { target: { value: '2026-09-19' } })
|
||||
fireEvent.click(screen.getByLabelText(/C \(2026-09-19\)/))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Calculer l’aperçu' }))
|
||||
expect(await screen.findByRole('heading', { name: 'Aperçu avant application' })).toBeInTheDocument()
|
||||
expect(api.applyProgramReschedule).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Confirmer ce recalage' }))
|
||||
const view = render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
const source = await screen.findByRole('article', { name: /S2 D/ })
|
||||
expect(source).toHaveAttribute('draggable', 'true')
|
||||
const dataTransfer = {
|
||||
setData: vi.fn(), getData: vi.fn(() => 'pgs_b'), effectAllowed: '', dropEffect: '',
|
||||
}
|
||||
fireEvent.dragStart(source, { dataTransfer })
|
||||
const target = screen.getByRole('region', { name: 'Sam 19/09/2026' })
|
||||
fireEvent.dragOver(target, { dataTransfer })
|
||||
expect(target).toHaveClass('program-calendar-drop-target')
|
||||
fireEvent.drop(target, { dataTransfer })
|
||||
await waitFor(() => expect(api.applyProgramReschedule).toHaveBeenCalledTimes(1))
|
||||
expect(api.applyProgramReschedule.mock.calls[0][2]).toBe('f'.repeat(64))
|
||||
expect(api.previewProgramReschedule.mock.calls[0][1]).toMatchObject({
|
||||
start_session_id: 'pgs_b', through_session_id: 'pgs_b',
|
||||
start_date: '2026-09-19', ceded_session_ids: [],
|
||||
})
|
||||
await waitFor(() => expect(within(target).getAllByRole('article')).toHaveLength(2))
|
||||
expect(within(target).getByText('Technique (FACULTATIVE)')).toBeInTheDocument()
|
||||
expect(document.querySelector('.program-calendar-summary')).toHaveTextContent(
|
||||
'0 / 2 séances effectuées',
|
||||
)
|
||||
view.unmount()
|
||||
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
expect(within(await screen.findByRole('region', { name: 'Sam 19/09/2026' }))
|
||||
.getAllByRole('article')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps a refused drop at its original date and explains a stale program revision', async () => {
|
||||
api.previewProgramReschedule.mockRejectedValue(new Error('stale_or_ineligible_program'))
|
||||
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
const source = await screen.findByRole('article', { name: /Jambes/ })
|
||||
const dataTransfer = { setData: vi.fn(), getData: vi.fn(() => 'pgs_one'),
|
||||
effectAllowed: '', dropEffect: '' }
|
||||
fireEvent.dragStart(source, { dataTransfer })
|
||||
fireEvent.drop(screen.getByRole('region', { name: 'Jeu 17/09/2026' }), { dataTransfer })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Modification impossible : le programme a changé. Rechargez la page puis réessayez.',
|
||||
)
|
||||
expect(within(screen.getByRole('region', { name: 'Mer 16/09/2026' }))
|
||||
.getByRole('article', { name: /Jambes/ })).toBeInTheDocument()
|
||||
expect(within(screen.getByRole('region', { name: 'Jeu 17/09/2026' }))
|
||||
.queryByRole('article')).not.toBeInTheDocument()
|
||||
expect(api.applyProgramReschedule).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('explains a refused ACK-gated apply without moving or exposing its error code', async () => {
|
||||
api.previewProgramReschedule.mockResolvedValue({ preview_sha256: 'f'.repeat(64), moves: [] })
|
||||
api.applyProgramReschedule.mockRejectedValue(new Error('program_peer_not_fresh'))
|
||||
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
const source = await screen.findByRole('article', { name: /Jambes/ })
|
||||
const dataTransfer = { setData: vi.fn(), getData: vi.fn(() => 'pgs_one'),
|
||||
effectAllowed: '', dropEffect: '' }
|
||||
fireEvent.dragStart(source, { dataTransfer })
|
||||
fireEvent.drop(screen.getByRole('region', { name: 'Jeu 17/09/2026' }), { dataTransfer })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Modification impossible : synchronisez Trainlog avec le téléphone puis réessayez.',
|
||||
)
|
||||
expect(screen.queryByText('program_peer_not_fresh')).not.toBeInTheDocument()
|
||||
expect(within(screen.getByRole('region', { name: 'Mer 16/09/2026' }))
|
||||
.getByRole('article', { name: /Jambes/ })).toBeInTheDocument()
|
||||
expect(within(screen.getByRole('region', { name: 'Jeu 17/09/2026' }))
|
||||
.queryByRole('article')).not.toBeInTheDocument()
|
||||
expect(api.fetchProgram).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('moves an optional session by keyboard to an empty day and survives reload', async () => {
|
||||
const optional = session('pgs_f', 'S2 F — Technique (FACULTATIVE)', '2026-09-19')
|
||||
const before = detail({ sessions: [optional] })
|
||||
const after = detail({ sessions: [{ ...optional, current_for: '2026-09-20' }],
|
||||
revision_id: 'pgr_next' })
|
||||
api.fetchProgram.mockResolvedValueOnce(before).mockResolvedValue(after)
|
||||
api.previewProgramReschedule.mockResolvedValue({ preview_sha256: 'f'.repeat(64), moves: [] })
|
||||
api.applyProgramReschedule.mockResolvedValue({})
|
||||
const view = render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
const card = await screen.findByRole('article', { name: /S2 F/ })
|
||||
expect(card).toHaveAttribute('draggable', 'true')
|
||||
fireEvent.click(screen.getByRole('button', { name: /Modifier la date de S2 F/ }))
|
||||
fireEvent.change(screen.getByLabelText('Nouvelle date pour S2 F'), {
|
||||
target: { value: '2026-09-20' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Enregistrer' }))
|
||||
await waitFor(() => expect(api.applyProgramReschedule).toHaveBeenCalledTimes(1))
|
||||
expect(api.previewProgramReschedule.mock.calls[0][1]).toMatchObject({
|
||||
start_session_id: 'pgs_f', through_session_id: 'pgs_f',
|
||||
start_date: '2026-09-20', ceded_session_ids: [],
|
||||
})
|
||||
expect(within(await screen.findByRole('region', { name: 'Dim 20/09/2026' }))
|
||||
.getByText('Technique (FACULTATIVE)')).toBeInTheDocument()
|
||||
view.unmount()
|
||||
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
expect(within(await screen.findByRole('region', { name: 'Dim 20/09/2026' }))
|
||||
.getByText('Technique (FACULTATIVE)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('rereads after an uncertain apply response before reporting the move as failed', async () => {
|
||||
const first = session('pgs_a', 'S2 D — Machines', '2026-09-18')
|
||||
api.fetchProgram.mockResolvedValueOnce(detail({ sessions: [first] }))
|
||||
.mockResolvedValueOnce(detail({ sessions: [{ ...first, current_for: '2026-09-20' }],
|
||||
revision_id: 'pgr_next' }))
|
||||
api.previewProgramReschedule.mockResolvedValue({ preview_sha256: 'f'.repeat(64), moves: [] })
|
||||
api.applyProgramReschedule.mockRejectedValue(new Error('réponse perdue'))
|
||||
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Modifier la date de S2 D/ }))
|
||||
fireEvent.change(screen.getByLabelText('Nouvelle date pour S2 D'), {
|
||||
target: { value: '2026-09-20' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Enregistrer' }))
|
||||
expect(await screen.findByText(/S2 D — Machines déplacée au 20\/09\/2026/))
|
||||
.toBeInTheDocument()
|
||||
expect(api.fetchProgram).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('restores each ceded identity once using successive authoritative revisions', async () => {
|
||||
const first = { ...session('pgs_f2', 'S2 F — Technique (FACULTATIVE)', '2026-09-19'),
|
||||
planning_state: 'ceded' as const, current_for: null }
|
||||
const second = { ...session('pgs_f3', 'S3 F — Technique (FACULTATIVE)', '2026-09-26'),
|
||||
planning_state: 'ceded' as const, current_for: null, position: 1 }
|
||||
api.fetchProgram.mockResolvedValueOnce(detail({ sessions: [first, second] }))
|
||||
.mockResolvedValueOnce(detail({ sessions: [{ ...first, planning_state: 'active',
|
||||
current_for: first.planned_for }, second], revision_id: 'pgr_second' }))
|
||||
.mockResolvedValue(detail({ sessions: [{ ...first, planning_state: 'active',
|
||||
current_for: first.planned_for }, { ...second, planning_state: 'active',
|
||||
current_for: second.planned_for }], revision_id: 'pgr_third' }))
|
||||
api.previewProgramReschedule.mockResolvedValue({ preview_sha256: 'f'.repeat(64), moves: [] })
|
||||
api.applyProgramReschedule.mockResolvedValue({})
|
||||
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Restaurer les séances écartées' }))
|
||||
await waitFor(() => expect(api.applyProgramReschedule).toHaveBeenCalledTimes(2))
|
||||
expect(api.previewProgramReschedule.mock.calls.map((call) => call[1].expected_revision))
|
||||
.toEqual(['pgr_one', 'pgr_second'])
|
||||
expect(screen.queryByRole('button', { name: 'Restaurer les séances écartées' }))
|
||||
.not.toBeInTheDocument()
|
||||
expect(screen.getAllByRole('article', { name: /FACULTATIVE/ })).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows the exact empty state and navigates to Sessions program administration', async () => {
|
||||
|
|
@ -205,13 +328,13 @@ describe('ProgramsCalendarPage', () => {
|
|||
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
|
||||
await screen.findByRole('heading', { level: 1, name: 'Cycle force' })
|
||||
expect(screen.getByText('PROGRAMME ACTIF')).toBeInTheDocument()
|
||||
expect(screen.getByText('16/09/2026 – 24/09/2026 · 1 séance')).toBeInTheDocument()
|
||||
expect(screen.queryByText('PROGRAMME ACTIF')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('16/09/2026 – 24/09/2026 · 0 / 1 séances effectuées')).toBeInTheDocument()
|
||||
const calendar = screen.getByLabelText('Calendrier hebdomadaire du programme')
|
||||
expect(within(calendar).getAllByRole('region')).toHaveLength(14)
|
||||
expect(within(calendar).getByLabelText('Lun 14/09/2026')).toBeInTheDocument()
|
||||
expect(within(calendar).getByLabelText('Dim 27/09/2026')).toBeInTheDocument()
|
||||
expect(within(calendar).getAllByText('Repos')).toHaveLength(13)
|
||||
expect(within(calendar).getAllByRole('region')).toHaveLength(35)
|
||||
expect(within(calendar).getByLabelText('Lun 31/08/2026')).toBeInTheDocument()
|
||||
expect(within(calendar).getByLabelText('Dim 04/10/2026')).toBeInTheDocument()
|
||||
expect(within(calendar).getAllByText('—')).toHaveLength(34)
|
||||
expect(within(calendar).getAllByText('Jambes')).toHaveLength(1)
|
||||
})
|
||||
|
||||
|
|
@ -245,8 +368,8 @@ describe('ProgramsCalendarPage', () => {
|
|||
const actualDay = await screen.findByLabelText('Mer 23/09/2026')
|
||||
expect(within(actualDay).getByText('Séance A')).toBeInTheDocument()
|
||||
expect(within(actualDay).getByText('Séance B')).toBeInTheDocument()
|
||||
expect(within(actualDay).getByText('Prévue le 22/09/2026 · effectuée le 23/09/2026'))
|
||||
.toBeInTheDocument()
|
||||
expect(within(actualDay).getByRole('article', { name: /Séance A/ })).toHaveAttribute('draggable', 'false')
|
||||
expect(within(actualDay).getByText('✓')).toBeInTheDocument()
|
||||
expect(within(screen.getByLabelText('Mar 22/09/2026')).queryByText('Séance A'))
|
||||
.not.toBeInTheDocument()
|
||||
expect(completed.planned_for).toBe('2026-09-22')
|
||||
|
|
@ -286,7 +409,8 @@ describe('ProgramsCalendarPage', () => {
|
|||
|
||||
const plannedDay = await screen.findByLabelText('Mar 22/09/2026')
|
||||
expect(within(plannedDay).getByText('Séance préparée')).toBeInTheDocument()
|
||||
expect(within(plannedDay).getByText('Préparée')).toBeInTheDocument()
|
||||
expect(within(plannedDay).getByRole('article', { name: /Séance préparée/ }))
|
||||
.toHaveClass('program-calendar-state-prepared')
|
||||
expect(within(screen.getByLabelText('Mer 23/09/2026')).queryByText('Séance préparée'))
|
||||
.not.toBeInTheDocument()
|
||||
})
|
||||
|
|
@ -299,7 +423,7 @@ describe('ProgramsCalendarPage', () => {
|
|||
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
|
||||
const actualDate = formatDate(completed.execution_started_at, 'iso')
|
||||
const actualDay = await screen.findByLabelText(new RegExp(formatCivilDate(actualDate, 'fr')))
|
||||
const actualDay = await screen.findByRole('region', { name: new RegExp(formatCivilDate(actualDate, 'fr')) })
|
||||
expect(within(actualDay).getByText('Séance tardive')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Séance tardive')).toHaveLength(1)
|
||||
expect(within(screen.getByLabelText('Mer 16/09/2026')).queryByText('Séance tardive'))
|
||||
|
|
@ -325,7 +449,7 @@ describe('ProgramsCalendarPage', () => {
|
|||
if (Intl.DateTimeFormat().resolvedOptions().timeZone === 'Europe/Paris') {
|
||||
expect(actualDate).toBe(parisDate)
|
||||
}
|
||||
const actualDay = await screen.findByLabelText(new RegExp(formatCivilDate(actualDate, 'fr')))
|
||||
const actualDay = await screen.findByRole('region', { name: new RegExp(formatCivilDate(actualDate, 'fr')) })
|
||||
expect(within(actualDay).getByText('Séance réelle')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Séance réelle')).toHaveLength(1)
|
||||
})
|
||||
|
|
@ -351,12 +475,10 @@ describe('ProgramsCalendarPage', () => {
|
|||
session(`pgs_${state}`, `Séance ${index}`, `2026-09-${String(16 + index).padStart(2, '0')}`, state)) }))
|
||||
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
|
||||
|
||||
const labels = ['À préparer', 'Préparée', 'En cours', 'Effectuée', 'Retirée']
|
||||
await screen.findByRole('heading', { level: 1, name: 'Cycle force' })
|
||||
states.forEach((state, index) => {
|
||||
const title = screen.getByText(`Séance ${index}`)
|
||||
expect(title.closest('article')).toHaveClass(`program-calendar-state-${state}`)
|
||||
expect(within(title.closest('article') as HTMLElement).getByText(labels[index])).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getAllByRole('button', { name: 'Préparer' })).toHaveLength(1)
|
||||
})
|
||||
|
|
@ -379,7 +501,7 @@ describe('ProgramsCalendarPage', () => {
|
|||
command.resolve({ preparation_id: 'sp_created' })
|
||||
await waitFor(() => expect(screen.getByText('Préparation créée.')).toBeInTheDocument())
|
||||
expect(api.fetchProgram).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getAllByText('Préparée')).toHaveLength(2)
|
||||
expect(screen.getByRole('article', { name: /Jambes, Préparée/ })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Préparer' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
|
@ -450,7 +572,7 @@ describe('ProgramsCalendarPage', () => {
|
|||
fireEvent.click(await screen.findByRole('button', { name: 'Préparer' }))
|
||||
expect(await screen.findByText('Préparation impossible : conflit de révision')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Préparer' })).toBeEnabled()
|
||||
expect(screen.getAllByText('À préparer')).toHaveLength(2)
|
||||
expect(screen.getByRole('article', { name: /Jambes, À préparer/ })).toBeInTheDocument()
|
||||
expect(api.fetchProgram).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByRole('link', { name: 'Ouvrir la préparation' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
|
@ -463,6 +585,6 @@ describe('ProgramsCalendarPage', () => {
|
|||
fireEvent.click(await screen.findByRole('button', { name: 'Préparer' }))
|
||||
expect(await screen.findByText(/programme n’a pas pu être actualisé : réseau indisponible/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Préparer' })).toBeEnabled()
|
||||
expect(screen.getAllByText('À préparer')).toHaveLength(2)
|
||||
expect(screen.getByRole('article', { name: /Jambes, À préparer/ })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
type ProgramListItem,
|
||||
type ProgramSession,
|
||||
type RescheduleChoice,
|
||||
type ReschedulePreview,
|
||||
} from '../api/programs'
|
||||
import { useDatePreferences } from '../presentation/DatePreferences'
|
||||
import { formatCivilDate, formatDate, parseCivilDate, validTimestampValue } from '../presentation/dateFormat'
|
||||
|
|
@ -51,8 +50,9 @@ function isoDate(value: number): string {
|
|||
|
||||
// WHY: Sessions history presents instants in the browser timezone, which may
|
||||
// place a workout on a different civil day than the timestamp's source offset.
|
||||
// CONTRACT: completed cards use that same local day; source planned_for stays
|
||||
// available to explain the move. No schedule mutation happens during render.
|
||||
// CONTRACT: completed cards use that same local day; imported planned_for
|
||||
// remains immutable while unfinished cards use their current planning date.
|
||||
// No schedule mutation happens during render.
|
||||
function actualExecutionDate(session: ProgramSession): string | null {
|
||||
if (session.execution_state !== 'completed' || typeof session.execution_started_at !== 'string') return null
|
||||
if (validTimestampValue(session.execution_started_at) === null) return null
|
||||
|
|
@ -65,17 +65,19 @@ function displayedDate(session: ProgramSession): string | null {
|
|||
// a workout performed on the imported plan date.
|
||||
return session.execution_state === 'completed'
|
||||
? actualExecutionDate(session)
|
||||
: session.planning_state === 'ceded' ? session.planned_for : session.current_for
|
||||
: session.current_for ?? session.planned_for
|
||||
}
|
||||
|
||||
function calendarDays(program: ProgramDetail): CalendarDay[] {
|
||||
function calendarDays(program: ProgramDetail, month: string): CalendarDay[] {
|
||||
// INVARIANT: render one civil month plus week padding. A distant keyboard
|
||||
// move must not allocate every intervening day in the browser.
|
||||
const datedSessions = program.sessions.filter((session) => dateValue(displayedDate(session)) !== null)
|
||||
const values = [program.start_date, program.end_date, ...datedSessions.map(displayedDate)]
|
||||
.map(dateValue).filter((value): value is number => value !== null)
|
||||
if (values.length === 0) return []
|
||||
|
||||
const first = Math.min(...values)
|
||||
const last = Math.max(...values)
|
||||
const first = dateValue(`${month}-01`)
|
||||
if (first === null) return []
|
||||
const date = new Date(first)
|
||||
date.setUTCMonth(date.getUTCMonth() + 1)
|
||||
date.setUTCDate(0)
|
||||
const last = date.valueOf()
|
||||
const firstWeekday = (new Date(first).getUTCDay() + 6) % 7
|
||||
const lastWeekday = (new Date(last).getUTCDay() + 6) % 7
|
||||
const start = first - firstWeekday * dayMilliseconds
|
||||
|
|
@ -87,6 +89,10 @@ function calendarDays(program: ProgramDetail): CalendarDay[] {
|
|||
sessions.push(session)
|
||||
sessionsByDate.set(day, sessions)
|
||||
}
|
||||
for (const sessions of sessionsByDate.values()) {
|
||||
sessions.sort((left, right) => left.position - right.position ||
|
||||
left.program_session_id.localeCompare(right.program_session_id))
|
||||
}
|
||||
|
||||
const days: CalendarDay[] = []
|
||||
for (let value = start; value <= end; value += dayMilliseconds) {
|
||||
|
|
@ -102,10 +108,43 @@ function calendarDays(program: ProgramDetail): CalendarDay[] {
|
|||
return days
|
||||
}
|
||||
|
||||
function monthForProgram(program: ProgramDetail): string {
|
||||
const today = formatDate(new Date().toISOString(), 'iso')
|
||||
if (dateValue(program.start_date) !== null && dateValue(program.end_date) !== null &&
|
||||
today >= (program.start_date as string) && today <= (program.end_date as string)) {
|
||||
return today.slice(0, 7)
|
||||
}
|
||||
const first = [program.start_date, ...program.sessions.map(displayedDate)]
|
||||
.find((value) => dateValue(value) !== null)
|
||||
return first?.slice(0, 7) ?? today.slice(0, 7)
|
||||
}
|
||||
|
||||
function adjacentMonth(month: string, delta: number): string {
|
||||
const value = dateValue(`${month}-01`)
|
||||
if (value === null) return month
|
||||
const date = new Date(value)
|
||||
date.setUTCMonth(date.getUTCMonth() + delta)
|
||||
return date.getUTCFullYear() < 1 || date.getUTCFullYear() > 9999
|
||||
? month : isoDate(date.valueOf()).slice(0, 7)
|
||||
}
|
||||
|
||||
function errorMessage(reason: unknown, fallback: string): string {
|
||||
return reason instanceof Error && reason.message ? reason.message : fallback
|
||||
}
|
||||
|
||||
function moveFailureMessage(reason: unknown, commitAttempted: boolean): string {
|
||||
const code = errorMessage(reason, '')
|
||||
if (code === 'program_peer_not_fresh') {
|
||||
return 'Modification impossible : synchronisez Trainlog avec le téléphone puis réessayez.'
|
||||
}
|
||||
if (code === 'stale_or_ineligible_program' || code === 'stale_reschedule_preview') {
|
||||
return 'Modification impossible : le programme a changé. Rechargez la page puis réessayez.'
|
||||
}
|
||||
return commitAttempted
|
||||
? 'Modification non confirmée : rechargez la page pour vérifier la date.'
|
||||
: 'Modification impossible : le planning n’a pas été modifié. Réessayez.'
|
||||
}
|
||||
|
||||
function NavigationLink({ path, onNavigate, children, className }: {
|
||||
path: string
|
||||
onNavigate: (path: string) => void
|
||||
|
|
@ -119,41 +158,55 @@ function NavigationLink({ path, onNavigate, children, className }: {
|
|||
}}>{children}</a>
|
||||
}
|
||||
|
||||
function ProgramSessionCard({ programId, session, pending, preparationId, onPrepare, onNavigate,
|
||||
dateFormat }: {
|
||||
function ProgramSessionCard({ programId, session, pending, moving, preparationId, onPrepare,
|
||||
onMove, onDragEnd, onNavigate, dateFormat }: {
|
||||
programId: string
|
||||
session: ProgramSession
|
||||
pending: boolean
|
||||
moving: boolean
|
||||
preparationId?: string
|
||||
onPrepare: (programId: string, session: ProgramSession) => void
|
||||
onMove: (session: ProgramSession, date: string) => void
|
||||
onDragEnd: () => void
|
||||
onNavigate: (path: string) => void
|
||||
dateFormat: ReturnType<typeof useDatePreferences>['dateFormat']
|
||||
}) {
|
||||
const actualDate = actualExecutionDate(session)
|
||||
return <article className={`program-calendar-session program-calendar-state-${session.execution_state}`}>
|
||||
<h3>{session.title}</h3>
|
||||
<span className="program-calendar-state-label">{session.planning_state === 'ceded' &&
|
||||
session.execution_state !== 'completed' ? 'Créneau cédé' : stateLabels[session.execution_state]}</span>
|
||||
{session.planning_state === 'active' && session.current_for !== session.planned_for &&
|
||||
session.execution_state !== 'completed' && <p className="program-calendar-date-note">
|
||||
Recalée du {session.planned_for === null ? 'jour initial non défini' : formatCivilDate(session.planned_for, dateFormat)}
|
||||
{' au '}{session.current_for === null ? 'jour non défini' : formatCivilDate(session.current_for, dateFormat)}
|
||||
</p>}
|
||||
{actualDate !== null && actualDate !== session.planned_for && <p className="program-calendar-date-note">
|
||||
Prévue le {session.planned_for === null ? 'date non définie' : formatCivilDate(session.planned_for, dateFormat)}
|
||||
{' · '}effectuée le {formatCivilDate(actualDate, dateFormat)}
|
||||
</p>}
|
||||
const [editingDate, setEditingDate] = useState(false)
|
||||
const [newDate, setNewDate] = useState(displayedDate(session) ?? '')
|
||||
const movable = session.execution_state === 'todo' && !moving
|
||||
const [shortId, ...titleParts] = session.title.split(' — ')
|
||||
return <article className={`program-calendar-session program-calendar-state-${session.execution_state}`}
|
||||
draggable={movable} tabIndex={0}
|
||||
aria-label={`${session.title}, ${stateLabels[session.execution_state]}, ${
|
||||
displayedDate(session) ? formatCivilDate(displayedDate(session) as string, dateFormat) : 'sans date'}`}
|
||||
title={session.note ?? session.title}
|
||||
onDragStart={(event) => {
|
||||
if (!movable) { event.preventDefault(); return }
|
||||
event.dataTransfer.setData('text/plain', session.program_session_id)
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}} onDragEnd={onDragEnd}>
|
||||
<h3><strong>{shortId}</strong>{titleParts.length > 0 && <span>{titleParts.join(' — ')}</span>}</h3>
|
||||
{session.execution_state === 'completed' && <span aria-label="Effectuée">✓</span>}
|
||||
{session.execution_state === 'completed' && actualDate === null &&
|
||||
<p className="program-calendar-date-note">Date réelle indisponible</p>}
|
||||
<div className="program-calendar-action">
|
||||
{session.execution_state === 'todo' && session.planning_state === 'active' &&
|
||||
<button type="button" disabled={pending}
|
||||
onClick={() => onPrepare(programId, session)}>
|
||||
{pending ? 'Préparation…' : 'Préparer'}
|
||||
</button>}
|
||||
{movable && <button className="program-calendar-edit-date" type="button"
|
||||
aria-label={`Modifier la date de ${session.title}`} onClick={() => setEditingDate(!editingDate)}>
|
||||
Déplacer
|
||||
</button>}
|
||||
{editingDate && <form className="program-calendar-date-form" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (parseCivilDate(newDate) !== null) { onMove(session, newDate); setEditingDate(false) }
|
||||
}}><label>Nouvelle date pour {shortId}<input type="date" required value={newDate}
|
||||
onChange={(event) => setNewDate(event.target.value)} /></label>
|
||||
<button type="submit">Enregistrer</button></form>}
|
||||
{(session.execution_state === 'todo' || preparationId) && <details className="program-calendar-details">
|
||||
<summary>Actions</summary>
|
||||
{session.execution_state === 'todo' && <button type="button" disabled={pending}
|
||||
onClick={() => onPrepare(programId, session)}>{pending ? 'Préparation…' : 'Préparer'}</button>}
|
||||
{preparationId && <NavigationLink path={`/seances/preparation/${encodeURIComponent(preparationId)}`}
|
||||
onNavigate={onNavigate}>Ouvrir la préparation</NavigationLink>}
|
||||
</div>
|
||||
</details>}
|
||||
</article>
|
||||
}
|
||||
|
||||
|
|
@ -169,18 +222,15 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
|
|||
const [pendingSessions, setPendingSessions] = useState<Set<string>>(new Set())
|
||||
const [preparationIds, setPreparationIds] = useState<Record<string, string>>({})
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
const [rescheduleStart, setRescheduleStart] = useState('')
|
||||
const [rescheduleThrough, setRescheduleThrough] = useState('')
|
||||
const [rescheduleDate, setRescheduleDate] = useState('')
|
||||
const [cededIds, setCededIds] = useState<string[]>([])
|
||||
const [reschedulePreview, setReschedulePreview] = useState<{
|
||||
choice: RescheduleChoice, result: ReschedulePreview, operationId: string
|
||||
} | null>(null)
|
||||
const [reschedulePending, setReschedulePending] = useState(false)
|
||||
const [moveError, setMoveError] = useState(false)
|
||||
const [visibleMonth, setVisibleMonth] = useState('')
|
||||
const [movingSession, setMovingSession] = useState('')
|
||||
const [dropDate, setDropDate] = useState('')
|
||||
const preparing = useRef(new Set<string>())
|
||||
const mounted = useRef(true)
|
||||
const selectedIdRef = useRef('')
|
||||
const selectionGeneration = useRef(0)
|
||||
const announcementRef = useRef<HTMLParagraphElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
|
|
@ -221,14 +271,15 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
|
|||
setDetailPending(true)
|
||||
setDetailError('')
|
||||
setAnnouncement('')
|
||||
setReschedulePreview(null)
|
||||
setRescheduleStart('')
|
||||
setRescheduleThrough('')
|
||||
setRescheduleDate('')
|
||||
setCededIds([])
|
||||
setMoveError(false)
|
||||
setMovingSession('')
|
||||
setDropDate('')
|
||||
setPreparationIds({})
|
||||
fetchProgram(selectedId, controller.signal).then((value) => {
|
||||
if (isCurrent()) setDetail(value)
|
||||
if (isCurrent()) {
|
||||
setDetail(value)
|
||||
setVisibleMonth(monthForProgram(value))
|
||||
}
|
||||
}).catch((reason) => {
|
||||
if (isCurrent()) {
|
||||
setDetailError(errorMessage(reason, 'Chargement du programme impossible.'))
|
||||
|
|
@ -239,54 +290,119 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
|
|||
return () => controller.abort()
|
||||
}, [selectedId])
|
||||
|
||||
const days = useMemo(() => detail === null ? [] : calendarDays(detail), [detail])
|
||||
const days = useMemo(() => detail === null || !visibleMonth ? []
|
||||
: calendarDays(detail, visibleMonth), [detail, visibleMonth])
|
||||
const undatedSessions = detail?.sessions.filter((session) => dateValue(displayedDate(session)) === null) ?? []
|
||||
const openSessions = detail?.sessions.filter((session) => session.execution_state === 'todo') ?? []
|
||||
|
||||
const previewReschedule = async () => {
|
||||
if (!detail || !rescheduleStart || !rescheduleThrough || !rescheduleDate) return
|
||||
const moveSession = async (session: ProgramSession, date: string) => {
|
||||
if (!detail || movingSession || session.execution_state !== 'todo' ||
|
||||
parseCivilDate(date) === null) return
|
||||
const programId = detail.program_id
|
||||
const generation = selectionGeneration.current
|
||||
const isCurrent = () => mounted.current && selectedIdRef.current === programId &&
|
||||
selectionGeneration.current === generation
|
||||
const choice: RescheduleChoice = {
|
||||
start_session_id: rescheduleStart,
|
||||
through_session_id: rescheduleThrough,
|
||||
start_date: rescheduleDate,
|
||||
ceded_session_ids: cededIds,
|
||||
start_session_id: session.program_session_id,
|
||||
through_session_id: session.program_session_id,
|
||||
start_date: date,
|
||||
ceded_session_ids: [],
|
||||
expected_revision: detail.revision_id,
|
||||
}
|
||||
setReschedulePending(true)
|
||||
setReschedulePreview(null)
|
||||
if (session.planning_state === 'active' && session.current_for === date) return
|
||||
setMovingSession(session.program_session_id)
|
||||
setAnnouncement('')
|
||||
setMoveError(false)
|
||||
let commitAttempted = false
|
||||
try {
|
||||
const result = await previewProgramReschedule(programId, choice)
|
||||
if (mounted.current && selectionGeneration.current === generation) {
|
||||
setReschedulePreview({ choice, result, operationId: newRescheduleOperationId() })
|
||||
setAnnouncement('Aperçu calculé. Vérifiez les dates avant de confirmer.')
|
||||
}
|
||||
// CONTRACT: one drop changes one identity through the existing revisioned
|
||||
// preview/apply service. The authoritative reread publishes the new day.
|
||||
const preview = await previewProgramReschedule(programId, choice)
|
||||
if (!isCurrent()) return
|
||||
commitAttempted = true
|
||||
await applyProgramReschedule(programId, choice, preview.preview_sha256,
|
||||
newRescheduleOperationId())
|
||||
if (!isCurrent()) return
|
||||
const reread = await fetchProgram(programId)
|
||||
if (!isCurrent()) return
|
||||
setDetail(reread)
|
||||
setVisibleMonth(date.slice(0, 7))
|
||||
setAnnouncement(`${session.title} déplacée au ${formatCivilDate(date, dateFormat)}.`)
|
||||
} catch (reason) {
|
||||
setAnnouncement(`Recalage impossible : ${errorMessage(reason, 'erreur inconnue')}`)
|
||||
if (isCurrent()) {
|
||||
if (commitAttempted) {
|
||||
try {
|
||||
const reread = await fetchProgram(programId)
|
||||
if (!isCurrent()) return
|
||||
setDetail(reread)
|
||||
const saved = reread.sessions.find((candidate) =>
|
||||
candidate.program_session_id === session.program_session_id)
|
||||
if (saved?.planning_state === 'active' && saved.current_for === date) {
|
||||
setVisibleMonth(date.slice(0, 7))
|
||||
setAnnouncement(`${session.title} déplacée au ${formatCivilDate(date, dateFormat)}.`)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// The original, specific command error remains the user diagnostic.
|
||||
}
|
||||
}
|
||||
// WHY: the service may reject a drop after a successful preview.
|
||||
// CONTRACT: keep the canonical card in place and make the refusal
|
||||
// visible without exposing the peer or revision protocol to users.
|
||||
setMoveError(true)
|
||||
setAnnouncement(moveFailureMessage(reason, commitAttempted))
|
||||
announcementRef.current?.scrollIntoView?.({ block: 'nearest' })
|
||||
}
|
||||
} finally {
|
||||
setReschedulePending(false)
|
||||
setDropDate('')
|
||||
setMovingSession('')
|
||||
}
|
||||
}
|
||||
|
||||
const confirmReschedule = async () => {
|
||||
if (!detail || !reschedulePreview) return
|
||||
const { choice, result, operationId } = reschedulePreview
|
||||
const restoreCeded = async () => {
|
||||
if (!detail || movingSession) return
|
||||
const programId = detail.program_id
|
||||
const generation = selectionGeneration.current
|
||||
setReschedulePending(true)
|
||||
const isCurrent = () => mounted.current && selectedIdRef.current === programId &&
|
||||
selectionGeneration.current === generation
|
||||
let current = detail
|
||||
setMovingSession('restore')
|
||||
setMoveError(false)
|
||||
try {
|
||||
await applyProgramReschedule(programId, choice, result.preview_sha256, operationId)
|
||||
const reread = await fetchProgram(programId)
|
||||
if (mounted.current && selectionGeneration.current === generation) {
|
||||
setDetail(reread)
|
||||
setReschedulePreview(null)
|
||||
setAnnouncement('Recalage enregistré. La synchronisation Android reste à vérifier.')
|
||||
// WHY: a past ceded decision removed the current date without removing
|
||||
// its definition. CONTRACT: restore each exact identity to its original
|
||||
// date; a reread supplies the next revision. Retrying skips active rows.
|
||||
for (const session of detail.sessions.filter((candidate) =>
|
||||
candidate.planning_state === 'ceded')) {
|
||||
if (!session.planned_for) throw new Error(`Date initiale absente : ${session.title}`)
|
||||
const choice: RescheduleChoice = {
|
||||
start_session_id: session.program_session_id,
|
||||
through_session_id: session.program_session_id,
|
||||
start_date: session.planned_for,
|
||||
ceded_session_ids: [],
|
||||
expected_revision: current.revision_id,
|
||||
}
|
||||
const preview = await previewProgramReschedule(programId, choice)
|
||||
if (!isCurrent()) return
|
||||
await applyProgramReschedule(programId, choice, preview.preview_sha256,
|
||||
newRescheduleOperationId())
|
||||
if (!isCurrent()) return
|
||||
current = await fetchProgram(programId)
|
||||
if (!isCurrent()) return
|
||||
setDetail(current)
|
||||
}
|
||||
setAnnouncement('Séances restaurées dans le programme.')
|
||||
} catch (reason) {
|
||||
setAnnouncement(`Recalage non confirmé : ${errorMessage(reason, 'erreur inconnue')}`)
|
||||
if (isCurrent()) {
|
||||
try {
|
||||
setDetail(await fetchProgram(programId))
|
||||
} catch {
|
||||
// A later page load can reread any repair that committed before failure.
|
||||
}
|
||||
if (isCurrent()) {
|
||||
setAnnouncement(`Restauration interrompue : ${errorMessage(reason, 'erreur inconnue')}`)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setReschedulePending(false)
|
||||
setMovingSession('')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -299,6 +415,7 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
|
|||
preparing.current.add(sessionId)
|
||||
setPendingSessions((current) => new Set(current).add(sessionId))
|
||||
setAnnouncement('')
|
||||
setMoveError(false)
|
||||
try {
|
||||
const result = await createPreparationFromProgram(programId, sessionId)
|
||||
if (!isCurrent()) return
|
||||
|
|
@ -336,8 +453,11 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
|
|||
programId={detail?.program_id ?? ''}
|
||||
session={session}
|
||||
pending={pendingSessions.has(session.program_session_id)}
|
||||
moving={Boolean(movingSession)}
|
||||
preparationId={preparationIds[session.program_session_id]}
|
||||
onPrepare={prepare}
|
||||
onMove={(target, date) => void moveSession(target, date)}
|
||||
onDragEnd={() => setDropDate('')}
|
||||
onNavigate={onNavigate}
|
||||
dateFormat={dateFormat}
|
||||
/>
|
||||
|
|
@ -373,7 +493,6 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
|
|||
{!detailPending && detail && <>
|
||||
<header className="program-calendar-header">
|
||||
<div>
|
||||
<p className="eyebrow">PROGRAMME ACTIF</p>
|
||||
<h1>{detail.title}</h1>
|
||||
<p className="program-calendar-summary">
|
||||
{detail.start_date && detail.end_date
|
||||
|
|
@ -381,72 +500,54 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
|
|||
: detail.start_date ? `À partir du ${formatCivilDate(detail.start_date, dateFormat)}`
|
||||
: detail.end_date ? `Jusqu’au ${formatCivilDate(detail.end_date, dateFormat)}`
|
||||
: 'Dates non définies'}
|
||||
{' · '}{detail.sessions.length} séance{detail.sessions.length === 1 ? '' : 's'}
|
||||
{' · '}{detail.sessions.filter((session) => session.execution_state === 'completed').length}
|
||||
{' / '}{detail.sessions.length} séances effectuées
|
||||
</p>
|
||||
</div>
|
||||
<ul className="program-calendar-legend" aria-label="Légende des états">
|
||||
{(Object.keys(stateLabels) as ExecutionState[]).map((state) => <li key={state}
|
||||
className={`program-calendar-state-${state}`}>{stateLabels[state]}</li>)}
|
||||
</ul>
|
||||
</header>
|
||||
<p className="program-calendar-announcement" aria-live="polite">{announcement}</p>
|
||||
<section className="program-calendar-reschedule" aria-label="Recaler le programme">
|
||||
<h2>Recaler la suite</h2>
|
||||
<p>Choisissez les séances concernées dans leur ordre, la reprise et les créneaux cédés.
|
||||
L’aperçu utilise les dates de créneaux déjà inscrites au programme.</p>
|
||||
<div className="program-calendar-reschedule-fields">
|
||||
<label>Première séance<select value={rescheduleStart} onChange={(event) => {
|
||||
setRescheduleStart(event.target.value); setReschedulePreview(null)
|
||||
}}><option value="">Choisir</option>{openSessions.map((session) =>
|
||||
<option key={session.program_session_id} value={session.program_session_id}>
|
||||
Début : {session.title}</option>)}</select></label>
|
||||
<label>Dernière séance<select value={rescheduleThrough} onChange={(event) => {
|
||||
setRescheduleThrough(event.target.value); setReschedulePreview(null)
|
||||
}}><option value="">Choisir</option>{openSessions.map((session) =>
|
||||
<option key={session.program_session_id} value={session.program_session_id}>
|
||||
Fin : {session.title}</option>)}</select></label>
|
||||
<label>Date de reprise<input type="date" value={rescheduleDate}
|
||||
min={detail.start_date ?? undefined} max={detail.end_date ?? undefined}
|
||||
onChange={(event) => { setRescheduleDate(event.target.value); setReschedulePreview(null) }} /></label>
|
||||
</div>
|
||||
<fieldset><legend>Séances qui cèdent leur créneau</legend>
|
||||
{openSessions.map((session) => <label key={session.program_session_id}>
|
||||
<input type="checkbox" checked={cededIds.includes(session.program_session_id)}
|
||||
onChange={(event) => {
|
||||
setCededIds((current) => event.target.checked
|
||||
? [...current, session.program_session_id]
|
||||
: current.filter((id) => id !== session.program_session_id))
|
||||
setReschedulePreview(null)
|
||||
}} />{session.title} ({session.planned_for ?? 'sans date'})
|
||||
</label>)}
|
||||
</fieldset>
|
||||
<button type="button" disabled={reschedulePending || !rescheduleStart ||
|
||||
!rescheduleThrough || !rescheduleDate} onClick={previewReschedule}>Calculer l’aperçu</button>
|
||||
{reschedulePreview && <div className="program-calendar-reschedule-preview">
|
||||
<h3>Aperçu avant application</h3>
|
||||
<table><thead><tr><th>Séance</th><th>Date initiale</th><th>Date actuelle</th>
|
||||
<th>Nouvelle date</th><th>Changement</th></tr></thead><tbody>
|
||||
{reschedulePreview.result.moves.map((move) => <tr key={move.program_session_id}>
|
||||
<td>{detail.sessions.find((session) => session.program_session_id === move.program_session_id)?.title}</td>
|
||||
<td>{move.original_for ?? '—'}</td><td>{move.old_for ?? '—'}</td>
|
||||
<td>{move.new_for ?? '—'}</td>
|
||||
<td>{move.change === 'ceded' ? 'Créneau cédé'
|
||||
: move.change === 'unchanged' ? 'Inchangée' : 'Recalée'}</td>
|
||||
</tr>)}
|
||||
</tbody></table>
|
||||
<button type="button" disabled={reschedulePending} onClick={confirmReschedule}>
|
||||
Confirmer ce recalage</button>
|
||||
</div>}
|
||||
</section>
|
||||
<p ref={announcementRef} className="program-calendar-announcement"
|
||||
role={moveError ? 'alert' : undefined} aria-live={moveError ? 'assertive' : 'polite'}>
|
||||
{announcement}
|
||||
</p>
|
||||
{detail.sessions.some((session) => session.planning_state === 'ceded') &&
|
||||
<button className="program-calendar-restore" type="button" disabled={Boolean(movingSession)}
|
||||
onClick={() => void restoreCeded()}>
|
||||
Restaurer les séances écartées
|
||||
</button>}
|
||||
<nav className="program-calendar-navigation" aria-label="Navigation du calendrier">
|
||||
<button type="button" onClick={() => setVisibleMonth(
|
||||
formatDate(new Date().toISOString(), 'iso').slice(0, 7))}>
|
||||
Aujourd’hui</button>
|
||||
<button type="button" aria-label="Mois précédent"
|
||||
onClick={() => setVisibleMonth(adjacentMonth(visibleMonth, -1))}>‹</button>
|
||||
<strong>{visibleMonth && new Intl.DateTimeFormat('fr-FR', { month: 'long', year: 'numeric',
|
||||
timeZone: 'UTC' }).format(new Date(`${visibleMonth}-01T00:00:00Z`))}</strong>
|
||||
<button type="button" aria-label="Mois suivant"
|
||||
onClick={() => setVisibleMonth(adjacentMonth(visibleMonth, 1))}>›</button>
|
||||
</nav>
|
||||
{days.length > 0 && <div className="program-calendar-scroll" tabIndex={0}
|
||||
aria-label="Calendrier hebdomadaire du programme">
|
||||
<div className="program-calendar-grid">
|
||||
{days.map((day) => <section className="program-calendar-day" key={day.date}
|
||||
aria-label={`${day.weekday} ${formatCivilDate(day.date, dateFormat)}`}>
|
||||
{days.map((day) => <section className={`program-calendar-day${
|
||||
dropDate === day.date ? ' program-calendar-drop-target' : ''}`} key={day.date}
|
||||
aria-label={`${day.weekday} ${formatCivilDate(day.date, dateFormat)}`}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
setDropDate(day.date)
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
setDropDate('')
|
||||
const id = event.dataTransfer.getData('text/plain')
|
||||
const session = detail.sessions.find((candidate) =>
|
||||
candidate.program_session_id === id && candidate.execution_state === 'todo')
|
||||
if (session) void moveSession(session, day.date)
|
||||
}}>
|
||||
<header><span>{day.weekday}</span><strong>{day.dayNumber}</strong>
|
||||
<time dateTime={day.date}>{formatCivilDate(day.date, dateFormat)}</time></header>
|
||||
{day.sessions.length === 0
|
||||
? <p className="program-calendar-rest">Repos</p>
|
||||
? <p className="program-calendar-rest">—</p>
|
||||
: <div className="program-calendar-day-sessions">{day.sessions.map(renderSession)}</div>}
|
||||
</section>)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue