style(web): separate programs interaction flows

This commit is contained in:
fy59 2026-09-18 16:18:40 +02:00
parent ed9395366e
commit 0cff443893
4 changed files with 518 additions and 10 deletions

188
web/src/api/programs.ts Normal file
View file

@ -0,0 +1,188 @@
import { mutationCsrfToken } from './sync'
export interface ProgramListItem {
program_id: string
title: string
state: 'active' | 'archived'
start_date: string | null
end_date: string | null
session_count: number
provenance: string
updated_at: string
}
export interface ProgramOccurrence {
entry_id: string
position: number
exercise_id: string
equipment_id: string | null
recording_mode: 'sets' | 'continuous'
tracking_mode: 'reps' | 'duration'
data_fields: number
load_mode: 'none' | 'external' | 'assistance'
rest_seconds: number
target_sets: number | null
target_reps: number | null
target_duration_seconds: number | null
target_weight_kg: number | null
notes: string | null
}
export interface ProgramSession {
program_session_id: string
position: number
title: string
session_type: 'training' | 'max_test'
planned_for: string | null
note: string | null
occurrences: ProgramOccurrence[]
}
export interface ProgramDetail {
api_version: 1
program_id: string
title: string
note: string | null
state: 'active' | 'archived'
start_date: string | null
end_date: string | null
created_at: string
updated_at: string
revision_id: string
source_format: 'trainlog-program'
source_version: 1
source_payload_sha256: string
sessions: ProgramSession[]
}
export interface ProgramImportPreview {
api_version: 1
program_id: string
payload_sha256: string
session_count: number
imported: boolean
}
function object(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
async function errorReason(response: Response): Promise<string> {
const value: unknown = await response.json()
return object(value) && typeof value.error === 'string' ? value.error : `HTTP ${response.status}`
}
function requestId(): string {
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
return `web-${Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')}`
}
export async function fetchAllPrograms(
search = '',
state = '',
signal?: AbortSignal,
): Promise<ProgramListItem[]> {
const items: ProgramListItem[] = []
const identities = new Set<string>()
let offset = 0
for (;;) {
const query = new URLSearchParams({ offset: String(offset), limit: '24' })
if (search.trim()) query.set('search', search.trim())
if (state) query.set('state', state)
const response = await fetch(`/api/v1/sessions/programs?${query}`, {
headers: { Accept: 'application/json' }, signal,
})
if (!response.ok) {
throw new Error(await errorReason(response))
}
const value: unknown = await response.json()
if (!object(value) || value.api_version !== 1 || value.offset !== offset ||
!Array.isArray(value.items) || typeof value.more !== 'boolean' ||
!Number.isSafeInteger(value.next_offset)) {
throw new TypeError('liste de programmes invalide')
}
for (const candidate of value.items) {
if (!object(candidate) || typeof candidate.program_id !== 'string' ||
typeof candidate.title !== 'string' ||
(candidate.state !== 'active' && candidate.state !== 'archived') ||
identities.has(candidate.program_id)) {
throw new TypeError('pagination de programmes incohérente')
}
identities.add(candidate.program_id)
items.push(candidate as unknown as ProgramListItem)
}
if (!value.more) return items
if (Number(value.next_offset) <= offset || value.items.length === 0) {
throw new TypeError('curseur de programmes obsolète')
}
offset = Number(value.next_offset)
}
}
export async function fetchProgram(programId: string, signal?: AbortSignal): Promise<ProgramDetail> {
const response = await fetch(`/api/v1/sessions/program/${encodeURIComponent(programId)}`, {
headers: { Accept: 'application/json' }, signal,
})
if (!response.ok) {
throw new Error(await errorReason(response))
}
const value: unknown = await response.json()
if (!object(value) || value.api_version !== 1 || value.program_id !== programId ||
!Array.isArray(value.sessions) || typeof value.revision_id !== 'string') {
throw new TypeError('programme invalide')
}
return value as unknown as ProgramDetail
}
async function programMutation(path: string, body: string, extraHeaders = {}): Promise<Response> {
const csrf = await mutationCsrfToken()
return fetch(path, {
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'X-Trainlog-CSRF-Token': csrf, 'X-Trainlog-Request-ID': requestId(), ...extraHeaders,
},
body,
})
}
export async function validateProgramImport(body: string): Promise<ProgramImportPreview> {
const response = await programMutation('/api/v1/sessions/programs/validate', body)
if (!response.ok) {
throw new Error(await errorReason(response))
}
return response.json() as Promise<ProgramImportPreview>
}
export async function importProgram(body: string): Promise<ProgramImportPreview> {
const response = await programMutation('/api/v1/sessions/programs/import', body)
if (!response.ok) {
throw new Error(await errorReason(response))
}
return response.json() as Promise<ProgramImportPreview>
}
export async function archiveProgram(programId: string, revision: string): Promise<void> {
const response = await programMutation(
`/api/v1/sessions/program/${encodeURIComponent(programId)}/archive`, '{}',
{ 'If-Match': `"${revision}"` },
)
if (!response.ok) {
throw new Error(await errorReason(response))
}
}
export async function createPreparationFromProgram(
programId: string,
programSessionId: string,
): Promise<{ preparation_id: string }> {
const response = await programMutation(
`/api/v1/sessions/program/${encodeURIComponent(programId)}/sessions/${encodeURIComponent(programSessionId)}/prepare`,
'{}',
)
if (!response.ok) {
throw new Error(await errorReason(response))
}
return response.json() as Promise<{ preparation_id: string }>
}

View file

@ -0,0 +1,265 @@
import { useEffect, useRef, useState } from 'react'
import {
archiveProgram,
createPreparationFromProgram,
fetchAllPrograms,
fetchProgram,
importProgram,
validateProgramImport,
type ProgramDetail,
type ProgramImportPreview,
type ProgramListItem,
} from '../api/programs'
import { useDatePreferences } from '../presentation/DatePreferences'
import { formatCivilDate, formatDateTime } from '../presentation/dateFormat'
function ProgramDetailView({ programId, onBack, onChanged }: {
programId: string
onBack: () => void
onChanged: () => void
}) {
const { dateFormat } = useDatePreferences()
const [program, setProgram] = useState<ProgramDetail | null>(null)
const [error, setError] = useState('')
const [message, setMessage] = useState('')
useEffect(() => {
const controller = new AbortController()
fetchProgram(programId, controller.signal).then(setProgram).catch((reason: unknown) => {
if (!controller.signal.aborted) {
setError(reason instanceof Error ? reason.message : 'Programme indisponible')
}
})
return () => controller.abort()
}, [programId])
if (error) {
return <p role="alert" className="error-panel">
{error} <button onClick={onBack}>Retour</button>
</p>
}
if (!program) {
return <p aria-live="polite">Chargement du programme…</p>
}
const archive = async () => {
setError('')
try {
await archiveProgram(program.program_id, program.revision_id)
setProgram(await fetchProgram(program.program_id))
setMessage('Programme archivé. Ses définitions et préparations existantes sont conservées.')
onChanged()
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Archivage impossible')
}
}
const prepare = async (sessionId: string) => {
setError('')
try {
const result = await createPreparationFromProgram(program.program_id, sessionId)
setMessage('Préparation créée sans livraison Android automatique.')
window.history.pushState(null, '', `/seances/preparation/${encodeURIComponent(result.preparation_id)}`)
window.dispatchEvent(new PopStateEvent('popstate'))
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Création impossible')
}
}
return <article className="session-detail program-detail">
<div className="sessions-toolbar">
<button type="button" className="quiet-action" onClick={onBack}>
← Retour aux programmes
</button>
{program.state === 'active' && <button
type="button"
className="quiet-action"
onClick={() => void archive()}
>Archiver</button>}
</div>
{message && <p role="status" className="success-panel">{message}</p>}
{error && <p role="alert" className="form-error">{error}</p>}
<header className="detail-summary">
<p className="eyebrow">
PROGRAMME · {program.state === 'active' ? 'ACTIF' : 'ARCHIVÉ'}
</p>
<h2>{program.title}</h2>
{program.note && <p>{program.note}</p>}
<p>
{program.start_date
? formatCivilDate(program.start_date, dateFormat) : 'Début non défini'}
{' — '}
{program.end_date ? formatCivilDate(program.end_date, dateFormat) : 'fin non définie'}
</p>
<p>
Import {program.source_format} v{program.source_version} · modifié le{' '}
{formatDateTime(program.updated_at, dateFormat)}
</p>
</header>
<ol className="program-session-list">
{program.sessions.map((session) => <li key={session.program_session_id}>
<div>
<strong>{session.position + 1}. {session.title}</strong>
<p>
{session.session_type === 'max_test'
? 'Test MAX planifié' : 'Entraînement planifié'}
{' · '}{session.occurrences.length} exercice
{session.occurrences.length > 1 ? 's' : ''}
</p>
</div>
<button
type="button"
className="primary-action"
onClick={() => void prepare(session.program_session_id)}
>Créer une préparation</button>
</li>)}
</ol>
</article>
}
export function ProgramsTab({ detailProgramId, onOpen, onBack }: {
detailProgramId: string | null
onOpen: (programId: string) => void
onBack: () => void
}) {
const { dateFormat } = useDatePreferences()
const fileInput = useRef<HTMLInputElement>(null)
const [items, setItems] = useState<ProgramListItem[]>([])
const [search, setSearch] = useState('')
const [state, setState] = useState('')
const [pending, setPending] = useState(true)
const [error, setError] = useState('')
const [rawImport, setRawImport] = useState('')
const [preview, setPreview] = useState<ProgramImportPreview | null>(null)
const [fileName, setFileName] = useState('')
const reload = () => {
setPending(true)
setError('')
const controller = new AbortController()
fetchAllPrograms(search, state, controller.signal).then(setItems).catch((reason: unknown) => {
if (!controller.signal.aborted) {
setError(reason instanceof Error ? reason.message : 'Liste indisponible')
}
}).finally(() => {
if (!controller.signal.aborted) {
setPending(false)
}
})
return controller
}
useEffect(() => {
const controller = reload()
return () => controller.abort()
}, [search, state])
if (detailProgramId) {
return <ProgramDetailView
programId={detailProgramId}
onBack={onBack}
onChanged={reload}
/>
}
const choose = async (file: File | undefined) => {
setPreview(null)
setRawImport('')
setError('')
setFileName(file?.name ?? '')
if (!file) return
if (file.size > 512 * 1024) {
setError('Le fichier dépasse la limite de 512 Kio.')
return
}
try {
const body = await file.text()
const result = await validateProgramImport(body)
setRawImport(body)
setPreview(result)
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Fichier invalide')
}
}
const confirmImport = async () => {
try {
const result = await importProgram(rawImport)
setPreview(null)
setRawImport('')
setFileName('')
onOpen(result.program_id)
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Import impossible')
}
}
const cancelImport = () => {
setPreview(null)
setRawImport('')
}
return <section className="programs-tab">
<div className="sessions-toolbar">
<label className="session-search">
<span>Rechercher par titre</span>
<input
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</label>
<label>État
<select value={state} onChange={(event) => setState(event.target.value)}>
<option value="">Tous</option>
<option value="active">Actifs</option>
<option value="archived">Archivés</option>
</select>
</label>
<input
ref={fileInput}
className="visually-hidden"
type="file"
accept="application/json,.json"
onChange={(event) => void choose(event.target.files?.[0])}
/>
<button
type="button"
className="primary-action"
onClick={() => fileInput.current?.click()}
>Importer un programme</button>
</div>
{preview && <section className="import-preview" aria-labelledby="import-preview-title">
<h2 id="import-preview-title">Aperçu avant import</h2>
<p><strong>{fileName}</strong></p>
<p>
Identité : {preview.program_id} · {preview.session_count} séance
{preview.session_count > 1 ? 's' : ''}
</p>
<p>Aucune donnée n’a encore été enregistrée.</p>
<div className="confirmation-actions">
<button type="button" onClick={cancelImport}>Annuler</button>
<button
type="button"
className="primary-action"
onClick={() => void confirmImport()}
>Importer</button>
</div>
</section>}
{error && <p role="alert" className="error-panel">{error}</p>}
{pending && <p aria-live="polite">Chargement…</p>}
{!pending && !error && items.length === 0 &&
<p className="empty-inline">Aucun programme.</p>}
<div className="program-list">
{items.map((program) => <button
type="button"
key={program.program_id}
className="session-row program-row"
onClick={() => onOpen(program.program_id)}
>
<span>
<small>{program.state === 'active' ? 'Programme actif' : 'Programme archivé'}</small>
<strong>{program.title}</strong>
</span>
<span>
{program.start_date
? formatCivilDate(program.start_date, dateFormat) : 'Dates libres'}
</span>
<span>
{program.session_count} séance{program.session_count > 1 ? 's' : ''}
</span>
<span>
{program.provenance} · {formatDateTime(program.updated_at, dateFormat)}
</span>
</button>)}
</div>
</section>
}

View file

@ -21,12 +21,23 @@ import {
validDateSortValue,
validTimestampValue,
} from '../presentation/dateFormat'
import { ProgramsTab } from './ProgramsTab'
type View = 'preparation' | 'resume' | 'history'
const collections: Record<View, SessionCollection[]> = {
type View = 'preparation' | 'resume' | 'history' | 'programs'
const collections: Record<Exclude<View, 'programs'>, SessionCollection[]> = {
preparation: ['preparations', 'proposals'], resume: ['drafts'], history: ['history'],
}
function viewLabel(view: View): string {
const labels: Record<View, string> = {
preparation: 'Préparation',
resume: 'À reprendre',
history: 'Historique',
programs: 'Programmes',
}
return labels[view]
}
export type SessionListEntry = SessionListItem & { collection: SessionCollection }
function compareIdentity(left: SessionListEntry, right: SessionListEntry): number {
@ -394,10 +405,20 @@ export function SessionsPage() {
const [detailPath, setDetailPath] = useState(() => window.location.pathname)
const loadRevision = useRef(0)
const detailMatch = useMemo(() => detailPath.match(/^\/seances\/(preparation|proposal|draft|history)\/([^/]+)$/), [detailPath])
const load = () => { const controller = new AbortController(); const revision = ++loadRevision.current
setPending(true); setFailed(false)
Promise.all(collections[view].map((collection) => fetchAllSessionPages(collection, search, controller.signal)
.then((pageItems) => pageItems.map((item) => ({ ...item, collection })))))
const load = () => {
const controller = new AbortController()
const revision = ++loadRevision.current
setPending(true)
setFailed(false)
if (view === 'programs') {
setItems([])
setPending(false)
return controller
}
const pageRequests = collections[view].map((collection) =>
fetchAllSessionPages(collection, search, controller.signal).then((pageItems) =>
pageItems.map((item) => ({ ...item, collection }))))
Promise.all(pageRequests)
.then((pages) => {
if (revision === loadRevision.current) setItems(sortSessionItems(pages.flat(), view))
}).catch((reason) => {
@ -418,6 +439,13 @@ export function SessionsPage() {
window.history.pushState(null, '', path); setDetailPath(path)
}
const back = () => { window.history.pushState(null, '', '/seances'); setDetailPath('/seances'); setEditor(null) }
const programMatch = useMemo(() => detailPath.match(/^\/seances\/program\/([^/]+)$/), [detailPath])
const openProgram = (identity: string) => {
const path = `/seances/program/${encodeURIComponent(identity)}`
window.history.pushState(null, '', path)
setDetailPath(path)
setView('programs')
}
const visibleItems = useMemo(() => sortSessionItems(items.filter((item) => {
if (stateFilter && item.state !== stateFilter) return false
if (dateFrom && (!item.date || item.date < dateFrom)) return false
@ -439,14 +467,34 @@ export function SessionsPage() {
if (detailMatch) return <section className="page"><ReadonlyDetail
kind={detailMatch[1] as 'draft' | 'history'} identity={decodeURIComponent(detailMatch[2])}
onBack={back} /></section>
if (programMatch) {
return <section className="page">
<ProgramsTab
detailProgramId={decodeURIComponent(programMatch[1])}
onOpen={openProgram}
onBack={back}
/>
</section>
}
return <section className="page sessions-page" aria-labelledby="page-title"><div className="page-heading">
<div><p className="eyebrow">ORGANISER</p><h1 id="page-title">Séances</h1></div>
<p className="page-intro">Préparez sans confondre proposition, brouillon d’exécution et séance réalisée.</p></div>
<div className="sessions-toolbar"><div className="session-tabs" role="tablist">
{(['preparation', 'resume', 'history'] as const).map((candidate) => <button type="button" role="tab"
aria-selected={view === candidate} key={candidate} onClick={() => setView(candidate)}>
{candidate === 'preparation' ? 'Préparation' : candidate === 'resume' ? 'À reprendre' : 'Historique'}</button>)}</div>
{(['preparation', 'resume', 'history', 'programs'] as const).map((candidate) =>
<button
type="button"
role="tab"
aria-selected={view === candidate}
key={candidate}
onClick={() => setView(candidate)}
>{viewLabel(candidate)}</button>)}</div>
{view === 'preparation' && <button type="button" className="primary-action" onClick={() => setEditor('new')}>Nouvelle séance</button>}</div>
{view === 'programs' && <ProgramsTab
detailProgramId={null}
onOpen={openProgram}
onBack={back}
/>}
{view !== 'programs' && <>
<label className="session-search"><span>Rechercher par titre ou exercice</span><input type="search" value={search}
onChange={(event) => setSearch(event.target.value)} /></label>
<div className="session-filters" aria-label="Filtres de séances">
@ -467,6 +515,6 @@ export function SessionsPage() {
? item.collection === 'preparations' || item.collection === 'proposals' ? 'Non planifiée' : 'Date inconnue'
: <time dateTime={item.date}>{formatCivilDate(item.date, dateFormat)}</time>}</span>
<span>{item.occurrence_count} exercice{item.occurrence_count > 1 ? 's' : ''}</span>
<span>{sessionStateLabel(item.collection, item.state)}</span></button>)}</div>
<span>{sessionStateLabel(item.collection, item.state)}</span></button>)}</div></>}
</section>
}

View file

@ -112,6 +112,13 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
.delete-confirmation { margin-bottom: 1rem; padding: 1rem; border: 1px solid var(--red); border-radius: .75rem; background: var(--base); }
.delete-confirmation h3 { margin-top: 0; }
.delete-confirmation p { color: var(--subtext-0); line-height: 1.55; }
.program-list, .program-session-list { display: grid; gap: .75rem; }
.program-session-list { padding: 0; list-style: none; }
.program-session-list li { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; border: 1px solid var(--surface-1); border-radius: .7rem; }
.program-session-list p { margin: .35rem 0 0; color: var(--subtext-0); }
.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; } }
.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; }
.detail-occurrences span, .detail-occurrences small { color: var(--subtext-0); }