diff --git a/web/src/api/programs.ts b/web/src/api/programs.ts new file mode 100644 index 0000000..6fd1d73 --- /dev/null +++ b/web/src/api/programs.ts @@ -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 { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +async function errorReason(response: Response): Promise { + 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 { + const items: ProgramListItem[] = [] + const identities = new Set() + 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 { + 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 { + 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 { + const response = await programMutation('/api/v1/sessions/programs/validate', body) + if (!response.ok) { + throw new Error(await errorReason(response)) + } + return response.json() as Promise +} + +export async function importProgram(body: string): Promise { + const response = await programMutation('/api/v1/sessions/programs/import', body) + if (!response.ok) { + throw new Error(await errorReason(response)) + } + return response.json() as Promise +} + +export async function archiveProgram(programId: string, revision: string): Promise { + 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 }> +} diff --git a/web/src/routes/ProgramsTab.tsx b/web/src/routes/ProgramsTab.tsx new file mode 100644 index 0000000..d8bd293 --- /dev/null +++ b/web/src/routes/ProgramsTab.tsx @@ -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(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

+ {error} +

+ } + if (!program) { + return

Chargement du programme…

+ } + 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
+
+ + {program.state === 'active' && } +
+ {message &&

{message}

} + {error &&

{error}

} +
+

+ PROGRAMME · {program.state === 'active' ? 'ACTIF' : 'ARCHIVÉ'} +

+

{program.title}

+ {program.note &&

{program.note}

} +

+ {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'} +

+

+ Import {program.source_format} v{program.source_version} · modifié le{' '} + {formatDateTime(program.updated_at, dateFormat)} +

+
+
    + {program.sessions.map((session) =>
  1. +
    + {session.position + 1}. {session.title} +

    + {session.session_type === 'max_test' + ? 'Test MAX planifié' : 'Entraînement planifié'} + {' · '}{session.occurrences.length} exercice + {session.occurrences.length > 1 ? 's' : ''} +

    +
    + +
  2. )} +
+
+} + +export function ProgramsTab({ detailProgramId, onOpen, onBack }: { + detailProgramId: string | null + onOpen: (programId: string) => void + onBack: () => void +}) { + const { dateFormat } = useDatePreferences() + const fileInput = useRef(null) + const [items, setItems] = useState([]) + 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(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 + } + 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
+
+ + + void choose(event.target.files?.[0])} + /> + +
+ {preview &&
+

Aperçu avant import

+

{fileName}

+

+ Identité : {preview.program_id} · {preview.session_count} séance + {preview.session_count > 1 ? 's' : ''} +

+

Aucune donnée n’a encore été enregistrée.

+
+ + +
+
} + {error &&

{error}

} + {pending &&

Chargement…

} + {!pending && !error && items.length === 0 && +

Aucun programme.

} +
+ {items.map((program) => )} +
+
+} diff --git a/web/src/routes/SessionsPage.tsx b/web/src/routes/SessionsPage.tsx index 6160a53..edb6dbb 100644 --- a/web/src/routes/SessionsPage.tsx +++ b/web/src/routes/SessionsPage.tsx @@ -21,12 +21,23 @@ import { validDateSortValue, validTimestampValue, } from '../presentation/dateFormat' +import { ProgramsTab } from './ProgramsTab' -type View = 'preparation' | 'resume' | 'history' -const collections: Record = { +type View = 'preparation' | 'resume' | 'history' | 'programs' +const collections: Record, SessionCollection[]> = { preparation: ['preparations', 'proposals'], resume: ['drafts'], history: ['history'], } +function viewLabel(view: View): string { + const labels: Record = { + 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
+ if (programMatch) { + return
+ +
+ } return

ORGANISER

Séances

Préparez sans confondre proposition, brouillon d’exécution et séance réalisée.

- {(['preparation', 'resume', 'history'] as const).map((candidate) => )}
+ {(['preparation', 'resume', 'history', 'programs'] as const).map((candidate) => + )}
{view === 'preparation' && } + {view === 'programs' && } + {view !== 'programs' && <>
@@ -467,6 +515,6 @@ export function SessionsPage() { ? item.collection === 'preparations' || item.collection === 'proposals' ? 'Non planifiée' : 'Date inconnue' : } {item.occurrence_count} exercice{item.occurrence_count > 1 ? 's' : ''} - {sessionStateLabel(item.collection, item.state)})}
+ {sessionStateLabel(item.collection, item.state)})}}
} diff --git a/web/src/theme/app.css b/web/src/theme/app.css index ac6b7bc..c8c0635 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -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); }