feat(web): add active program calendar

This commit is contained in:
fy59 2026-09-20 08:03:03 +02:00
parent ca45683813
commit 3ad50be247
9 changed files with 741 additions and 25 deletions

View file

@ -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;

View file

@ -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.

View file

@ -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

View file

@ -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',
)

View file

@ -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<typeof import('../api/programs')>(),
...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(<App />)
@ -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(<App />)
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(<App />)
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 () => {

View file

@ -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<Exclude<RouteId, 'dashboard'>, [string, string, string]> = {
const placeholderContent: Record<Exclude<RouteId, 'dashboard' | 'programs'>, [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' ? <SessionsPage /> :
route.id === 'programs' ? <ProgramsCalendarPage onNavigate={navigate} /> :
<PlaceholderPage title={placeholderContent[route.id][0]}
eyebrow={placeholderContent[route.id][1]}
description={placeholderContent[route.id][2]} />

View file

@ -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<typeof import('../api/programs')>(),
...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> = {}): 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<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((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(<ProgramsCalendarPage onNavigate={vi.fn()} />)
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(<ProgramsCalendarPage onNavigate={onNavigate} />)
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(<ProgramsCalendarPage onNavigate={vi.fn()} />)
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<ProgramDetail>()
api.fetchAllPrograms.mockResolvedValue([item(), item('pg_two', 'Cycle mobilité')])
api.fetchProgram
.mockReturnValueOnce(first.promise)
.mockResolvedValueOnce(detail({ program_id: 'pg_two', title: 'Cycle mobilité' }))
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
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(<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()
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(<ProgramsCalendarPage onNavigate={vi.fn()} />)
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(<ProgramsCalendarPage onNavigate={vi.fn()} />)
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(<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)
})
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(<ProgramsCalendarPage onNavigate={vi.fn()} />)
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<ProgramDetail>()
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(<ProgramsCalendarPage onNavigate={vi.fn()} />)
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(<ProgramsCalendarPage onNavigate={vi.fn()} />)
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(<ProgramsCalendarPage onNavigate={onNavigate} />)
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(<ProgramsCalendarPage onNavigate={vi.fn()} />)
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(<ProgramsCalendarPage onNavigate={vi.fn()} />)
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)
})
})

View file

@ -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<ExecutionState, string> = {
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<string, ProgramSession[]>()
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 <a className={className} href={path} onClick={(event) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
event.preventDefault()
onNavigate(path)
}}>{children}</a>
}
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 <article className={`program-calendar-session program-calendar-state-${session.execution_state}`}>
<h3>{session.title}</h3>
<span className="program-calendar-state-label">{stateLabels[session.execution_state]}</span>
<div className="program-calendar-action">
{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>
</article>
}
export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string) => void }) {
const { dateFormat } = useDatePreferences()
const [programs, setPrograms] = useState<ProgramListItem[]>([])
const [programsPending, setProgramsPending] = useState(true)
const [programsError, setProgramsError] = useState('')
const [selectedId, setSelectedId] = useState('')
const [detail, setDetail] = useState<ProgramDetail | null>(null)
const [detailPending, setDetailPending] = useState(false)
const [detailError, setDetailError] = useState('')
const [pendingSessions, setPendingSessions] = useState<Set<string>>(new Set())
const [preparationIds, setPreparationIds] = useState<Record<string, string>>({})
const [announcement, setAnnouncement] = useState('')
const preparing = useRef(new Set<string>())
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) => <ProgramSessionCard
key={session.program_session_id}
programId={detail?.program_id ?? ''}
session={session}
pending={pendingSessions.has(session.program_session_id)}
preparationId={preparationIds[session.program_session_id]}
onPrepare={prepare}
onNavigate={onNavigate}
/>
return <section className="page programs-calendar-page">
{programsPending && <p className="program-calendar-loading" role="status">Chargement des programmes…</p>}
{!programsPending && programsError && <p className="error-panel" role="alert">{programsError}</p>}
{!programsPending && !programsError && programs.length === 0 && <div className="program-calendar-empty">
<p className="eyebrow">PROGRAMMES</p>
<h1>Programmes</h1>
<p>Aucun programme actif.</p>
<p>Importez ou gérez un programme depuis l’onglet Programmes de la page Séances.</p>
<NavigationLink className="primary-action" path="/seances" onNavigate={onNavigate}>
Aller à Séances — Programmes
</NavigationLink>
</div>}
{!programsPending && !programsError && programs.length > 0 && <>
{programs.length > 1 && <label className="program-calendar-selector">
Programme actif
<select value={selectedId} onChange={(event) => {
const next = event.target.value
if (next !== selectedIdRef.current) selectionGeneration.current += 1
selectedIdRef.current = next
setSelectedId(next)
}}>
{programs.map((program) => <option key={program.program_id} value={program.program_id}>
{program.title}
</option>)}
</select>
</label>}
{detailPending && <p className="program-calendar-loading" role="status">Chargement du programme…</p>}
{!detailPending && detailError && <p className="error-panel" role="alert">{detailError}</p>}
{!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
? `${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'}
</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>
{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)}`}>
<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>
: <div className="program-calendar-day-sessions">{day.sessions.map(renderSession)}</div>}
</section>)}
</div>
</div>}
{days.length === 0 && <p className="empty-inline">Aucune date planifiée.</p>}
{undatedSessions.length > 0 && <section className="program-calendar-undated"
aria-labelledby="undated-program-sessions">
<h2 id="undated-program-sessions">Séances sans date</h2>
<div>{undatedSessions.map(renderSession)}</div>
</section>}
</>}
</>}
</section>
}

View file

@ -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; }