From 3ad50be247b3bc303e751bb20a1b6eec91afbe93 Mon Sep 17 00:00:00 2001 From: fy59 Date: Sun, 20 Sep 2026 08:03:03 +0200 Subject: [PATCH] feat(web): add active program calendar --- README.md | 16 +- docs/current_state.md | 24 +- docs/roadmap.md | 16 +- tui/meson.build | 1 + web/src/app/App.test.tsx | 36 ++- web/src/app/App.tsx | 5 +- web/src/routes/ProgramsCalendarPage.test.tsx | 315 +++++++++++++++++++ web/src/routes/ProgramsCalendarPage.tsx | 312 ++++++++++++++++++ web/src/theme/app.css | 41 +++ 9 files changed, 741 insertions(+), 25 deletions(-) create mode 100644 web/src/routes/ProgramsCalendarPage.test.tsx create mode 100644 web/src/routes/ProgramsCalendarPage.tsx diff --git a/README.md b/README.md index 8430be1..f130f1e 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ Trainlog is a local-first workout and body-data system. Trainlog Core owns business truth and canonical desktop persistence. Its native Android app is the field companion, its C17/Notcurses desktop TUI is the administration and technical surface, and its local browser Web sibling provides the implemented -Dashboard while reserving later analysis, program, session, and exercise -modules for separately contracted work. +Dashboard, Sessions, and Programmes surfaces while reserving Analyse and +Exercises for separately contracted work. Android and desktop each own a local SQLite database. The desktop database is the canonical long-term history. Trainlog synchronizes versioned JSON artifacts @@ -17,15 +17,16 @@ over direct MTP; it never copies SQLite database files between devices. |---|---| | Android | Capture and quickly correct sets, repetitions, loads, durations, and continuous activities; reorder active and completed-session occurrences; capture feedback, J+1 follow-ups, body measurements, and AI proposals; trigger sync; show quick summaries. | | Desktop TUI | Administer, inspect, maintain, import/export, correct canonical history, and provide technical tools. | -| Local Web (0.1.2 development) | Display the Dashboard and the implemented Sessions inventory, deletion, manual preparation, Android-delivery, and embedded Programs workflow through typed Trainlog Core/API boundaries. Programs has responsive detail, terminal logical deletion, and a privately deployed read-only Android projection. Analyse, the standalone Programmes route, and Exercises remain placeholders. | +| Local Web (0.1.2 development) | Display the Dashboard, the operational top-level Programmes daily active-program calendar, and the implemented Sessions inventory, deletion, manual preparation, Android-delivery, and Programs administration workflow through typed Trainlog Core/API boundaries. Programs has responsive detail, terminal logical deletion, and a privately deployed read-only Android projection. Analyse and Exercises remain placeholders. | No interface reconstructs business truth from SQLite tables. The local Web is a sibling adapter, not an extension of the TUI. On `main`, its loopback-only CLI/HTTP adapter, embedded frontend, read-only Dashboard API, factual tiles, visualizations, and private layout persistence are implemented. This does not make the remaining placeholder routes functional or publish version 0.1.2. -Programs is intentionally implemented as a Sessions subtab; the standalone -top-level Programmes route remains a placeholder. +The top-level `/programmes` route is an operational daily calendar for active +Programs. Sessions → Programmes remains the technical administration/import, +list, detail, archive, and delete surface. ## Releases @@ -333,8 +334,9 @@ As of 2026-09-18: Android-triggered bidirectional smoke test. - `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` and `WEB_DASHBOARD_V1=PASS/FROZEN` and `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN`; - Programs is implemented inside Sessions; Analyse, Exercises, and the - standalone top-level Programmes route remain placeholders; + `/programmes` is an operational active-Program calendar, while + Sessions → Programmes remains the administration surface; Analyse and + Exercises remain placeholders; - `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS`: the coordinated private desktop/Android deployment, real Firefox presentation, correlated `programs-v1` deletion ACK, restart/replay, and non-resurrection checks pass; diff --git a/docs/current_state.md b/docs/current_state.md index 3d6c8b7..93b4af4 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -230,9 +230,23 @@ progression series, an original BODY ZONES SVG, and private versioned layout 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. Programmes -is implemented as the fourth Sessions subtab; Analyse and Exercises remain -explicit placeholders, and no standalone Programs route exists. +explicit proposal derivation and generation-backed Android delivery. The +top-level `/programmes` route is an operational daily active-Program calendar; +Sessions → Programmes remains the technical administration/import, list, +detail, archive, and delete surface. Analyse and Exercises remain explicit +placeholders. + +The calendar uses the existing typed Core services `fetchAllPrograms(active)`, +`fetchProgram`, and `createPreparationFromProgram`; it derives no local program +or execution state. With no active Program it shows an empty state and +navigation to Sessions administration; with one it selects it automatically; +with several it offers a non-persisted selector. Its continuous Monday–Sunday +weeks derive from Program bounds and real `planned_for` dates, do not invent +rest-day sessions, and retain undated sessions in a separate section. It shows +all five Core execution states; only `todo` offers Prepare. Each Prepare result +is followed by an authoritative Core reread and may expose the returned +preparation link. This calendar adds no Android delivery, start, or execution +behavior. Desktop schema v26 owns immutable manual-preparation revisions, stable ordered occurrences, persistent HTTP idempotency keys, delivery-to-execution identity, @@ -487,8 +501,8 @@ tests, Android debug assembly, and 211 Android tests (207 passed, four optional historical-fixture skips). Strict desktop builds use Clang 22.1.8; the compared pre-existing GCC 16.2.1 Web warning gate remains documented rather than green. -- `WEB_NEXT_MODULE_SELECTION_V1`: Analyse, Programmes and Exercices remain - shell placeholders. Exercises is the next proposed Web module. +- `WEB_NEXT_MODULE_SELECTION_V1`: Analyse and Exercices remain shell + placeholders. Exercises is the next proposed Web module. - `APP_SHELL_V1` still awaits the recorded human visual/accessibility review. - Session Generator V1 is hidden while V2 planning semantics are developed. - Hardware MTP validation requires a connected unlocked Android device. diff --git a/docs/roadmap.md b/docs/roadmap.md index 25d9f04..b97fae6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -33,9 +33,11 @@ exchange and APP_SHELL_V1 retain their explicit manual validation/review gates. CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data contract, frozen interactive grid, versioned layout persistence and factual tile rendering exist. The Dashboard's bounded progression and BODY ZONES -visualizations and Sessions V1 are implemented. Programs is implemented inside -Sessions; Analyse, Exercises and the standalone top-level Programs route remain -placeholders or out of scope. +visualizations and Sessions V1 are implemented. The top-level `/programmes` +route is an operational daily active-Program calendar, while +Sessions → Programmes remains the technical administration/import, list, +detail, archive, and delete surface. Analyse and Exercises remain placeholders +or out of scope. The corrective prepared-item projection, bounded phase-owned MTP outbox, Sessions presentation correction, local date preference and durable preparation withdrawal are implemented. The withdrawal uses the separate versioned @@ -145,10 +147,10 @@ rendering those metrics. Later Dashboard evolution must preserve those frozen boundaries. `WEB_NEXT_MODULE_SELECTION_V1` remains a future Web documentary cursor. The current -roadmap does not yet establish a contract order between Analyse, Programmes, -Séances and Exercices. This cursor must inventory their existing Core services, -read/write boundaries and dependencies, then select one bounded module contract; -it does not authorize silently implementing a page. +roadmap does not yet establish a contract order between the remaining placeholder +routes, Analyse and Exercices. This cursor must inventory their existing Core +services, read/write boundaries and dependencies, then select one bounded module +contract; it does not authorize silently implementing a page. ## Next diff --git a/tui/meson.build b/tui/meson.build index 31b018d..8875bd8 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -78,6 +78,7 @@ if web_assets_enabled '../web/src/api/sessions.ts', '../web/src/api/programs.ts', '../web/src/routes/ProgramsTab.tsx', + '../web/src/routes/ProgramsCalendarPage.tsx', '../web/src/theme/app.css', '../web/src/theme/tokens.css', ) diff --git a/web/src/app/App.test.tsx b/web/src/app/App.test.tsx index 894386c..6c4a38d 100644 --- a/web/src/app/App.test.tsx +++ b/web/src/app/App.test.tsx @@ -1,8 +1,19 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { App } from './App' import { parseDashboard, type DashboardSnapshot } from '../api/dashboard' +const programsApi = vi.hoisted(() => ({ + fetchAllPrograms: vi.fn(), + fetchProgram: vi.fn(), + createPreparationFromProgram: vi.fn(), +})) + +vi.mock('../api/programs', async (importOriginal) => ({ + ...await importOriginal(), + ...programsApi, +})) + const health = { api_version: 1, status: 'ok', @@ -52,6 +63,13 @@ function mockHealth(value: unknown = health) { } describe('shell Trainlog', () => { + beforeEach(() => { + programsApi.fetchAllPrograms.mockReset() + programsApi.fetchProgram.mockReset() + programsApi.createPreparationFromProgram.mockReset() + programsApi.fetchAllPrograms.mockResolvedValue([]) + }) + it('rend le shell, les sept états vides et le health sans données fictives', async () => { mockHealth() render() @@ -85,19 +103,29 @@ describe('shell Trainlog', () => { const footer = screen.getByRole('contentinfo') fireEvent.click(screen.getByRole('link', { name: label })) expect(window.location.pathname).toBe(path) - expect(screen.getByRole('heading', { level: 1, name: label })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: label })).toBeInTheDocument() expect(screen.getByRole('link', { name: label })).toHaveAttribute('aria-current', 'page') expect(screen.getByRole('banner')).toBe(header) expect(screen.getByRole('contentinfo')).toBe(footer) }) - it('suit la navigation arrière du navigateur', () => { + it('suit la navigation arrière du navigateur', async () => { mockHealth() render() fireEvent.click(screen.getByRole('link', { name: 'Analyse' })) window.history.pushState(null, '', '/programmes') fireEvent.popState(window) - expect(screen.getByRole('heading', { level: 1, name: 'Programmes' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: 'Programmes' })).toBeInTheDocument() + }) + + it('route /programmes vers la page calendrier réelle', async () => { + window.history.replaceState(null, '', '/programmes') + mockHealth() + render() + + expect(await screen.findByText('Aucun programme actif.')).toBeInTheDocument() + expect(programsApi.fetchAllPrograms).toHaveBeenCalledWith('', 'active', expect.any(AbortSignal)) + expect(screen.queryByText(/command services Core explicites/)).not.toBeInTheDocument() }) it('présente un échec health discret et accessible', async () => { diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index eb7c147..26927cd 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -6,14 +6,14 @@ import { Footer } from '../components/Footer' import { Header } from '../components/Header' import { DashboardPage } from '../routes/DashboardPage' import { PlaceholderPage } from '../routes/PlaceholderPage' +import { ProgramsCalendarPage } from '../routes/ProgramsCalendarPage' import { SessionsPage } from '../routes/SessionsPage' import type { RouteId } from './routes' import { useRoute } from './useRoute' import { DatePreferencesProvider } from '../presentation/DatePreferences' -const placeholderContent: Record, [string, string, string]> = { +const placeholderContent: Record, [string, string, string]> = { analysis: ['Analyse', 'COMPRENDRE', 'Les analyses détaillées apparaîtront ici lorsque leurs read models Core seront disponibles.'], - programs: ['Programmes', 'PRÉPARER', 'La préparation des programmes utilisera des command services Core explicites.'], sessions: ['Séances', 'ORGANISER', 'Les séances préparées et terminées seront présentées sans confondre plan et réalisé.'], exercises: ['Exercices', 'EXPLORER', 'Le catalogue canonique des exercices sera consultable depuis cette page.'], } @@ -75,6 +75,7 @@ function AppContent() { preparedItemsFailed={preparedItemsFailed} /> ) : route.id === 'sessions' ? : + route.id === 'programs' ? : diff --git a/web/src/routes/ProgramsCalendarPage.test.tsx b/web/src/routes/ProgramsCalendarPage.test.tsx new file mode 100644 index 0000000..28ab839 --- /dev/null +++ b/web/src/routes/ProgramsCalendarPage.test.tsx @@ -0,0 +1,315 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProgramDetail, ProgramListItem, ProgramSession } from '../api/programs' +import * as sessionsApi from '../api/sessions' +import { ProgramsCalendarPage } from './ProgramsCalendarPage' + +const api = vi.hoisted(() => ({ + fetchAllPrograms: vi.fn(), + fetchProgram: vi.fn(), + createPreparationFromProgram: vi.fn(), +})) + +vi.mock('../api/programs', async (importOriginal) => ({ + ...await importOriginal(), + ...api, +})) + +function item(programId = 'pg_one', title = 'Cycle force'): ProgramListItem { + return { + program_id: programId, + title, + state: 'active', + start_date: '2026-09-16', + end_date: '2026-09-24', + session_count: 1, + provenance: 'trainlog-program', + updated_at: '2026-09-19T10:00:00Z', + imported_at: '2026-09-19T10:00:00Z', + revision_id: 'pgr_one', + preparation_count: 0, + usage: 'unused', + } +} + +function session( + programSessionId: string, + title: string, + plannedFor: string | null, + executionState: ProgramSession['execution_state'] = 'todo', +): ProgramSession { + return { + program_session_id: programSessionId, + position: 0, + title, + session_type: 'training', + planned_for: plannedFor, + note: null, + execution_state: executionState, + execution_session_id: null, + occurrences: [], + } +} + +function detail(overrides: Partial = {}): ProgramDetail { + return { + api_version: 1, + program_id: 'pg_one', + title: 'Cycle force', + note: null, + state: 'active', + start_date: '2026-09-16', + end_date: '2026-09-24', + created_at: '2026-09-19T10:00:00Z', + updated_at: '2026-09-19T10:00:00Z', + revision_id: 'pgr_one', + source_format: 'trainlog-program', + source_version: 1, + source_payload_sha256: 'a'.repeat(64), + sessions: [session('pgs_one', 'Jambes', '2026-09-16')], + ...overrides, + } +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +describe('ProgramsCalendarPage', () => { + beforeEach(() => { + api.fetchAllPrograms.mockReset() + api.fetchProgram.mockReset() + api.createPreparationFromProgram.mockReset() + api.fetchAllPrograms.mockResolvedValue([item()]) + api.fetchProgram.mockResolvedValue(detail()) + }) + + it('requests only active programs and automatically selects the sole result', async () => { + render() + + expect(await screen.findByRole('heading', { level: 1, name: 'Cycle force' })).toBeInTheDocument() + expect(api.fetchAllPrograms).toHaveBeenCalledWith('', 'active', expect.any(AbortSignal)) + expect(api.fetchProgram).toHaveBeenCalledWith('pg_one', expect.any(AbortSignal)) + expect(screen.queryByRole('combobox', { name: 'Programme actif' })).not.toBeInTheDocument() + }) + + it('shows the exact empty state and navigates to Sessions program administration', async () => { + api.fetchAllPrograms.mockResolvedValue([]) + const onNavigate = vi.fn() + render() + + expect(await screen.findByText('Aucun programme actif.')).toBeInTheDocument() + expect(screen.getByText(/onglet Programmes de la page Séances/)).toBeInTheDocument() + fireEvent.click(screen.getByRole('link', { name: 'Aller à Séances — Programmes' })) + expect(onNavigate).toHaveBeenCalledWith('/seances') + expect(api.fetchProgram).not.toHaveBeenCalled() + }) + + it('offers a non-persisted selector for multiple programs and fetches the chosen detail', async () => { + const second = item('pg_two', 'Cycle mobilité') + api.fetchAllPrograms.mockResolvedValue([item(), second]) + api.fetchProgram.mockImplementation((programId: string) => Promise.resolve(detail({ + program_id: programId, + title: programId === 'pg_two' ? 'Cycle mobilité' : 'Cycle force', + }))) + const storageSpy = vi.spyOn(Storage.prototype, 'setItem') + render() + + const selector = await screen.findByRole('combobox', { name: 'Programme actif' }) + expect(selector).toHaveValue('pg_one') + fireEvent.change(selector, { target: { value: 'pg_two' } }) + expect(await screen.findByRole('heading', { level: 1, name: 'Cycle mobilité' })).toBeInTheDocument() + expect(api.fetchProgram).toHaveBeenLastCalledWith('pg_two', expect.any(AbortSignal)) + expect(storageSpy).not.toHaveBeenCalled() + storageSpy.mockRestore() + }) + + it('ignores a stale detail response after selecting another program', async () => { + const first = deferred() + api.fetchAllPrograms.mockResolvedValue([item(), item('pg_two', 'Cycle mobilité')]) + api.fetchProgram + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce(detail({ program_id: 'pg_two', title: 'Cycle mobilité' })) + render() + + const selector = await screen.findByRole('combobox', { name: 'Programme actif' }) + fireEvent.change(selector, { target: { value: 'pg_two' } }) + expect(await screen.findByRole('heading', { level: 1, name: 'Cycle mobilité' })).toBeInTheDocument() + first.resolve(detail({ title: 'Réponse périmée' })) + await waitFor(() => expect(screen.queryByText('Réponse périmée')).not.toBeInTheDocument()) + expect(screen.getByRole('heading', { level: 1, name: 'Cycle mobilité' })).toBeInTheDocument() + }) + + it('renders full Monday-Sunday weeks, program facts, and light rest days without inventing sessions', async () => { + render() + + await screen.findByRole('heading', { level: 1, name: 'Cycle force' }) + expect(screen.getByText('PROGRAMME ACTIF')).toBeInTheDocument() + expect(screen.getByText('16/09/2026 – 24/09/2026 · 1 séance')).toBeInTheDocument() + 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).getAllByText('Jambes')).toHaveLength(1) + }) + + it('preserves every session sharing a date and keeps null or invalid dates in the undated section', async () => { + api.fetchProgram.mockResolvedValue(detail({ sessions: [ + session('pgs_a', 'Haut A', '2026-09-16'), + session('pgs_b', 'Haut B', '2026-09-16', 'prepared'), + session('pgs_c', 'Libre', null), + session('pgs_d', 'Date source invalide', 'not-a-date'), + ] })) + render() + + const day = await screen.findByLabelText('Mer 16/09/2026') + expect(within(day).getByText('Haut A')).toBeInTheDocument() + expect(within(day).getByText('Haut B')).toBeInTheDocument() + const undated = screen.getByRole('region', { name: 'Séances sans date' }) + expect(within(undated).getByText('Libre')).toBeInTheDocument() + expect(within(undated).getByText('Date source invalide')).toBeInTheDocument() + }) + + it('renders a planned session in year 0001 in its calendar day rather than as undated', async () => { + api.fetchProgram.mockResolvedValue(detail({ + start_date: '0001-01-01', + end_date: '0001-01-01', + sessions: [session('pgs_early', 'Séance ancienne', '0001-01-01')], + })) + render() + + const day = await screen.findByLabelText('Lun 01/01/0001') + expect(within(day).getByText('Séance ancienne')).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'Séances sans date' })).not.toBeInTheDocument() + }) + + it('renders every Core execution state with its exact semantic class and label', async () => { + const states: ProgramSession['execution_state'][] = [ + 'todo', 'prepared', 'in_progress', 'completed', 'deleted', + ] + api.fetchProgram.mockResolvedValue(detail({ sessions: states.map((state, index) => + session(`pgs_${state}`, `Séance ${index}`, `2026-09-${String(16 + index).padStart(2, '0')}`, state)) })) + render() + + const labels = ['À préparer', 'Préparée', 'En cours', 'Effectuée', 'Retirée'] + await screen.findByRole('heading', { level: 1, name: 'Cycle force' }) + states.forEach((state, index) => { + const title = screen.getByText(`Séance ${index}`) + expect(title.closest('article')).toHaveClass(`program-calendar-state-${state}`) + expect(within(title.closest('article') as HTMLElement).getByText(labels[index])).toBeInTheDocument() + }) + expect(screen.getAllByRole('button', { name: 'Préparer' })).toHaveLength(1) + }) + + it('guards a pending preparation against double clicks and rereads authoritative state', async () => { + const command = deferred<{ preparation_id: string }>() + api.createPreparationFromProgram.mockReturnValue(command.promise) + api.fetchProgram + .mockResolvedValueOnce(detail()) + .mockResolvedValueOnce(detail({ sessions: [session('pgs_one', 'Jambes', '2026-09-16', 'prepared')] })) + render() + + const button = await screen.findByRole('button', { name: 'Préparer' }) + fireEvent.click(button) + fireEvent.click(button) + expect(screen.getByRole('button', { name: 'Préparation…' })).toBeDisabled() + expect(api.createPreparationFromProgram).toHaveBeenCalledTimes(1) + expect(api.createPreparationFromProgram).toHaveBeenCalledWith('pg_one', 'pgs_one') + + 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.queryByRole('button', { name: 'Préparer' })).not.toBeInTheDocument() + }) + + it('does not publish a preparation reread after switching programs or deliver to Android', async () => { + const reread = deferred() + const deliver = vi.spyOn(sessionsApi, 'prepareForAndroid') + api.fetchAllPrograms.mockResolvedValue([item(), item('pg_two', 'Cycle mobilité')]) + api.createPreparationFromProgram.mockResolvedValue({ preparation_id: 'sp_created' }) + api.fetchProgram + .mockResolvedValueOnce(detail()) + .mockReturnValueOnce(reread.promise) + .mockResolvedValueOnce(detail({ program_id: 'pg_two', title: 'Cycle mobilité' })) + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Préparer' })) + await waitFor(() => expect(api.fetchProgram).toHaveBeenCalledTimes(2)) + fireEvent.change(screen.getByRole('combobox', { name: 'Programme actif' }), { + target: { value: 'pg_two' }, + }) + expect(await screen.findByRole('heading', { level: 1, name: 'Cycle mobilité' })).toBeInTheDocument() + reread.resolve(detail({ + title: 'Réponse de préparation périmée', + sessions: [session('pgs_one', 'Jambes', '2026-09-16', 'prepared')], + })) + await waitFor(() => { + expect(screen.queryByText('Réponse de préparation périmée')).not.toBeInTheDocument() + expect(screen.queryByText('Préparation créée.')).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'Ouvrir la préparation' })).not.toBeInTheDocument() + }) + expect(deliver).not.toHaveBeenCalled() + }) + + it('settles a pending preparation after unmount without rereading or updating delivery state', async () => { + const command = deferred<{ preparation_id: string }>() + const deliver = vi.spyOn(sessionsApi, 'prepareForAndroid') + api.createPreparationFromProgram.mockReturnValue(command.promise) + const view = render() + + fireEvent.click(await screen.findByRole('button', { name: 'Préparer' })) + view.unmount() + command.resolve({ preparation_id: 'sp_created' }) + await command.promise + await Promise.resolve() + expect(api.fetchProgram).toHaveBeenCalledTimes(1) + expect(deliver).not.toHaveBeenCalled() + }) + + it('offers the returned preparation destination only after success', async () => { + const onNavigate = vi.fn() + api.createPreparationFromProgram.mockResolvedValue({ preparation_id: 'sp_created' }) + api.fetchProgram + .mockResolvedValueOnce(detail()) + .mockResolvedValueOnce(detail({ sessions: [session('pgs_one', 'Jambes', '2026-09-16', 'prepared')] })) + render() + + expect(screen.queryByRole('link', { name: 'Ouvrir la préparation' })).not.toBeInTheDocument() + fireEvent.click(await screen.findByRole('button', { name: 'Préparer' })) + const link = await screen.findByRole('link', { name: 'Ouvrir la préparation' }) + expect(link).toHaveAttribute('href', '/seances/preparation/sp_created') + fireEvent.click(link) + expect(onNavigate).toHaveBeenCalledWith('/seances/preparation/sp_created') + }) + + it('announces a specific command failure and leaves the Core-derived todo state intact', async () => { + api.createPreparationFromProgram.mockRejectedValue(new Error('conflit de révision')) + render() + + 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(api.fetchProgram).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('link', { name: 'Ouvrir la préparation' })).not.toBeInTheDocument() + }) + + it('reports a failed authoritative reread without fabricating prepared state', async () => { + api.createPreparationFromProgram.mockResolvedValue({ preparation_id: 'sp_created' }) + api.fetchProgram.mockResolvedValueOnce(detail()).mockRejectedValueOnce(new Error('réseau indisponible')) + render() + + 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) + }) +}) diff --git a/web/src/routes/ProgramsCalendarPage.tsx b/web/src/routes/ProgramsCalendarPage.tsx new file mode 100644 index 0000000..f27e3b0 --- /dev/null +++ b/web/src/routes/ProgramsCalendarPage.tsx @@ -0,0 +1,312 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + createPreparationFromProgram, + fetchAllPrograms, + fetchProgram, + type ProgramDetail, + type ProgramListItem, + type ProgramSession, +} from '../api/programs' +import { useDatePreferences } from '../presentation/DatePreferences' +import { formatCivilDate, parseCivilDate } from '../presentation/dateFormat' + +type ExecutionState = ProgramSession['execution_state'] + +const stateLabels: Record = { + todo: 'À préparer', + prepared: 'Préparée', + in_progress: 'En cours', + completed: 'Effectuée', + deleted: 'Retirée', +} + +const weekdayLabels = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'] +const dayMilliseconds = 24 * 60 * 60 * 1000 + +interface CalendarDay { + date: string + dayNumber: number + weekday: string + sessions: ProgramSession[] +} + +function dateValue(value: string | null): number | null { + if (value === null) return null + const parts = parseCivilDate(value) + if (parts === null) return null + + const date = new Date(0) + date.setUTCFullYear(parts.year, parts.month - 1, parts.day) + return date.valueOf() +} + +function isoDate(value: number): string { + return new Date(value).toISOString().slice(0, 10) +} + +function calendarDays(program: ProgramDetail): CalendarDay[] { + const datedSessions = program.sessions.filter((session) => dateValue(session.planned_for) !== null) + const values = [program.start_date, program.end_date, ...datedSessions.map((session) => session.planned_for)] + .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 firstWeekday = (new Date(first).getUTCDay() + 6) % 7 + const lastWeekday = (new Date(last).getUTCDay() + 6) % 7 + const start = first - firstWeekday * dayMilliseconds + const end = last + (6 - lastWeekday) * dayMilliseconds + const sessionsByDate = new Map() + for (const session of datedSessions) { + const sessions = sessionsByDate.get(session.planned_for as string) ?? [] + sessions.push(session) + sessionsByDate.set(session.planned_for as string, sessions) + } + + const days: CalendarDay[] = [] + for (let value = start; value <= end; value += dayMilliseconds) { + const date = isoDate(value) + const dateObject = new Date(value) + days.push({ + date, + dayNumber: dateObject.getUTCDate(), + weekday: weekdayLabels[(dateObject.getUTCDay() + 6) % 7], + sessions: sessionsByDate.get(date) ?? [], + }) + } + return days +} + +function errorMessage(reason: unknown, fallback: string): string { + return reason instanceof Error && reason.message ? reason.message : fallback +} + +function NavigationLink({ path, onNavigate, children, className }: { + path: string + onNavigate: (path: string) => void + children: React.ReactNode + className?: string +}) { + return { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return + event.preventDefault() + onNavigate(path) + }}>{children} +} + +function ProgramSessionCard({ programId, session, pending, preparationId, onPrepare, onNavigate }: { + programId: string + session: ProgramSession + pending: boolean + preparationId?: string + onPrepare: (programId: string, session: ProgramSession) => void + onNavigate: (path: string) => void +}) { + return
+

{session.title}

+ {stateLabels[session.execution_state]} +
+ {session.execution_state === 'todo' && } + {preparationId && Ouvrir la préparation} +
+
+} + +export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string) => void }) { + const { dateFormat } = useDatePreferences() + const [programs, setPrograms] = useState([]) + const [programsPending, setProgramsPending] = useState(true) + const [programsError, setProgramsError] = useState('') + const [selectedId, setSelectedId] = useState('') + const [detail, setDetail] = useState(null) + const [detailPending, setDetailPending] = useState(false) + const [detailError, setDetailError] = useState('') + const [pendingSessions, setPendingSessions] = useState>(new Set()) + const [preparationIds, setPreparationIds] = useState>({}) + const [announcement, setAnnouncement] = useState('') + const preparing = useRef(new Set()) + const mounted = useRef(true) + const selectedIdRef = useRef('') + const selectionGeneration = useRef(0) + + useEffect(() => { + mounted.current = true + return () => { + mounted.current = false + } + }, []) + + useEffect(() => { + const controller = new AbortController() + fetchAllPrograms('', 'active', controller.signal).then((items) => { + if (controller.signal.aborted || !mounted.current) return + setPrograms(items) + const next = selectedIdRef.current || items[0]?.program_id || '' + if (next !== selectedIdRef.current) selectionGeneration.current += 1 + selectedIdRef.current = next + setSelectedId(next) + }).catch((reason) => { + if (!controller.signal.aborted) { + setProgramsError(errorMessage(reason, 'Chargement des programmes impossible.')) + } + }).finally(() => { + if (!controller.signal.aborted) setProgramsPending(false) + }) + return () => controller.abort() + }, []) + + useEffect(() => { + if (!selectedId) { + setDetail(null) + return + } + const controller = new AbortController() + const generation = selectionGeneration.current + const isCurrent = () => mounted.current && !controller.signal.aborted && + selectedIdRef.current === selectedId && selectionGeneration.current === generation + setDetail(null) + setDetailPending(true) + setDetailError('') + setAnnouncement('') + setPreparationIds({}) + fetchProgram(selectedId, controller.signal).then((value) => { + if (isCurrent()) setDetail(value) + }).catch((reason) => { + if (isCurrent()) { + setDetailError(errorMessage(reason, 'Chargement du programme impossible.')) + } + }).finally(() => { + if (isCurrent()) setDetailPending(false) + }) + return () => controller.abort() + }, [selectedId]) + + const days = useMemo(() => detail === null ? [] : calendarDays(detail), [detail]) + const undatedSessions = detail?.sessions.filter((session) => dateValue(session.planned_for) === null) ?? [] + + const prepare = async (programId: string, session: ProgramSession) => { + const sessionId = session.program_session_id + if (session.execution_state !== 'todo' || preparing.current.has(sessionId)) return + const generation = selectionGeneration.current + const isCurrent = () => mounted.current && selectedIdRef.current === programId && + selectionGeneration.current === generation + preparing.current.add(sessionId) + setPendingSessions((current) => new Set(current).add(sessionId)) + setAnnouncement('') + try { + const result = await createPreparationFromProgram(programId, sessionId) + if (!isCurrent()) return + setPreparationIds((current) => ({ ...current, [sessionId]: result.preparation_id })) + try { + const reread = await fetchProgram(programId) + if (!isCurrent()) return + setDetail(reread) + setAnnouncement('Préparation créée.') + } catch (reason) { + if (!isCurrent()) return + const reasonMessage = errorMessage(reason, 'erreur inconnue') + setAnnouncement( + `Préparation créée, mais le programme n’a pas pu être actualisé : ${reasonMessage}`, + ) + } + } catch (reason) { + if (isCurrent()) { + setAnnouncement(`Préparation impossible : ${errorMessage(reason, 'erreur inconnue')}`) + } + } finally { + preparing.current.delete(sessionId) + if (mounted.current) { + setPendingSessions((current) => { + const next = new Set(current) + next.delete(sessionId) + return next + }) + } + } + } + + const renderSession = (session: ProgramSession) => + + return
+ {programsPending &&

Chargement des programmes…

} + {!programsPending && programsError &&

{programsError}

} + {!programsPending && !programsError && programs.length === 0 &&
+

PROGRAMMES

+

Programmes

+

Aucun programme actif.

+

Importez ou gérez un programme depuis l’onglet Programmes de la page Séances.

+ + Aller à Séances — Programmes + +
} + {!programsPending && !programsError && programs.length > 0 && <> + {programs.length > 1 && } + {detailPending &&

Chargement du programme…

} + {!detailPending && detailError &&

{detailError}

} + {!detailPending && detail && <> +
+
+

PROGRAMME ACTIF

+

{detail.title}

+

+ {detail.start_date && detail.end_date + ? `${formatCivilDate(detail.start_date, dateFormat)} – ${formatCivilDate(detail.end_date, dateFormat)}` + : 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'} +

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

{announcement}

+ {days.length > 0 &&
+
+ {days.map((day) =>
+
{day.weekday}{day.dayNumber} +
+ {day.sessions.length === 0 + ?

Repos

+ :
{day.sessions.map(renderSession)}
} +
)} +
+
} + {days.length === 0 &&

Aucune date planifiée.

} + {undatedSessions.length > 0 &&
+

Séances sans date

+
{undatedSessions.map(renderSession)}
+
} + } + } +
+} diff --git a/web/src/theme/app.css b/web/src/theme/app.css index ae379e7..25b8344 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -177,11 +177,52 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett .program-execution-state.state-prepared, .program-execution-state.state-in_progress { background: color-mix(in srgb, var(--yellow) 18%, transparent); color: var(--yellow); } .program-execution-state.state-completed { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green); } .program-execution-state.state-deleted { background: color-mix(in srgb, var(--red) 14%, transparent); color: var(--red); } +.programs-calendar-page { max-width: 100%; } +.program-calendar-loading { min-height: 12rem; display: grid; place-items: center; color: var(--subtext-0); } +.program-calendar-empty { max-width: 40rem; padding: clamp(1.25rem, 4vw, 2.5rem); border: 1px solid var(--surface-1); border-radius: var(--radius); background: var(--mantle); } +.program-calendar-empty > p:not(.eyebrow) { color: var(--subtext-0); line-height: 1.6; } +.program-calendar-empty .primary-action { display: inline-flex; align-items: center; text-decoration: none; } +.program-calendar-selector { width: min(100%, 28rem); margin: 0 0 1.5rem auto; display: grid; gap: .4rem; color: var(--subtext-0); font-size: var(--font-size-sm); } +.program-calendar-selector select { min-height: 2.7rem; padding: .6rem .75rem; color: var(--text); background: var(--mantle); border: 1px solid var(--surface-2); border-radius: .55rem; } +.program-calendar-header { margin-bottom: 1.25rem; display: flex; align-items: end; justify-content: space-between; gap: 2rem; } +.program-calendar-summary { margin: .7rem 0 0; color: var(--subtext-0); } +.program-calendar-legend { margin: 0; padding: 0; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .45rem; list-style: none; } +.program-calendar-legend li, .program-calendar-state-label { padding: .3rem .55rem; border: 1px solid currentColor; border-radius: 999px; font-size: var(--font-size-xs); font-weight: 700; } +.program-calendar-state-todo { color: var(--lavender); background: color-mix(in srgb, var(--lavender) 7%, transparent); } +.program-calendar-state-prepared { color: var(--yellow); background: color-mix(in srgb, var(--yellow) 8%, transparent); } +.program-calendar-state-in_progress { color: var(--sapphire); background: color-mix(in srgb, var(--sapphire) 9%, transparent); } +.program-calendar-state-completed { color: var(--green); background: color-mix(in srgb, var(--green) 8%, transparent); } +.program-calendar-state-deleted { color: var(--overlay-1); background: color-mix(in srgb, var(--surface-1) 35%, transparent); } +.program-calendar-announcement { min-height: 1.5rem; margin: 0 0 .5rem; color: var(--subtext-0); font-size: var(--font-size-sm); } +.program-calendar-scroll { max-width: 100%; overflow-x: auto; scrollbar-width: thin; outline-offset: .35rem; } +.program-calendar-scroll:focus-visible { outline: 2px solid var(--lavender); } +.program-calendar-grid { min-width: 72rem; display: grid; grid-template-columns: repeat(7, minmax(9rem, 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: .7rem; background: var(--mantle); border-right: 1px solid var(--surface-1); border-bottom: 1px solid var(--surface-1); } +.program-calendar-day > header { display: grid; grid-template-columns: 1fr auto; align-items: baseline; gap: .15rem .5rem; } +.program-calendar-day > header span { color: var(--subtext-0); font: 700 var(--font-size-xs) var(--font-mono); text-transform: uppercase; } +.program-calendar-day > header strong { color: var(--text); font-size: var(--font-size-lg); } +.program-calendar-day > header time { grid-column: 1 / -1; color: var(--overlay-1); font-size: var(--font-size-xs); } +.program-calendar-rest { margin: 2rem 0 0; color: var(--overlay-1); font-size: var(--font-size-sm); text-align: center; } +.program-calendar-day-sessions { margin-top: .75rem; display: grid; gap: .55rem; } +.program-calendar-session { padding: .65rem; background: var(--base); border: 1px solid var(--surface-1); border-left: .22rem solid currentColor; border-radius: .55rem; } +.program-calendar-session h3 { margin: 0 0 .5rem; color: var(--text); font-size: var(--font-size-sm); line-height: 1.35; } +.program-calendar-state-label { display: inline-flex; padding: .2rem .4rem; } +.program-calendar-action { margin-top: .55rem; display: grid; gap: .4rem; } +.program-calendar-action button, .program-calendar-action a { min-height: 2.35rem; padding: .48rem .6rem; border: 1px solid var(--lavender); border-radius: .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: .55; } +.program-calendar-undated { margin-top: 2rem; padding: clamp(1rem, 3vw, 1.5rem); background: var(--mantle); border: 1px solid var(--surface-1); border-radius: var(--radius); } +.program-calendar-undated > div { display: grid; grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); gap: .75rem; } .import-preview { margin: 1rem 0; padding: 1rem; border: 1px solid var(--blue); border-radius: .75rem; background: var(--mantle); } .visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } @media (max-width: 600px) { .program-session-list li { align-items: stretch; flex-direction: column; } .program-row .session-row-main { grid-template-columns: 1fr; } + .program-calendar-header { align-items: start; flex-direction: column; gap: 1rem; } + .program-calendar-legend { justify-content: flex-start; } + .program-calendar-scroll { overflow: visible; } + .program-calendar-grid { min-width: 0; display: grid; grid-template-columns: 1fr; gap: .6rem; border: 0; } + .program-calendar-day { min-height: 0; border: 1px solid var(--surface-1); border-radius: .65rem; } + .program-calendar-rest { margin: 1rem 0; text-align: left; } } .detail-summary, .detail-tile { margin-bottom: 1rem; padding: 1.1rem; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--base); } .detail-occurrences li { display: grid; gap: .35rem; }