feat(web): add active program calendar
This commit is contained in:
parent
ca45683813
commit
3ad50be247
9 changed files with 741 additions and 25 deletions
16
README.md
16
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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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]} />
|
||||
|
|
|
|||
315
web/src/routes/ProgramsCalendarPage.test.tsx
Normal file
315
web/src/routes/ProgramsCalendarPage.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
312
web/src/routes/ProgramsCalendarPage.tsx
Normal file
312
web/src/routes/ProgramsCalendarPage.tsx
Normal 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>
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue