import { useEffect, useMemo, useRef, useState } from 'react' import { fetchCatalog, deleteSessionResource, fetchSessionDetail, fetchReadonlySessionDetail, prepareForAndroid, fetchAllSessionPages, savePreparation, withdrawPreparation, type CatalogChoice, type PreparationInput, type PreparationOccurrence, type SessionCollection, type SessionDetail, type SessionListItem, } from '../api/sessions' import { DeleteConfirmationDialog } from '../components/DeleteConfirmationDialog' import { useDatePreferences } from '../presentation/DatePreferences' import { formatCivilDate, formatDateTime, validDateSortValue, validTimestampValue, } from '../presentation/dateFormat' import { ProgramsTab } from './ProgramsTab' 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 deletionKind(collection: SessionCollection): 'preparation' | 'proposal' | 'draft' | 'history' { if (collection === 'preparations') return 'preparation' if (collection === 'proposals') return 'proposal' if (collection === 'drafts') return 'draft' return 'history' } function deletionLabel(kind: 'preparation' | 'proposal' | 'draft' | 'history'): string { const labels = { preparation: 'Supprimer la préparation', proposal: 'Supprimer la proposition', draft: 'Abandonner le brouillon', history: 'Supprimer la séance de l’historique', } return labels[kind] } function deletionType(kind: 'preparation' | 'proposal' | 'draft' | 'history'): string { const labels = { preparation: 'PRÉPARATION MANUELLE', proposal: 'PROPOSITION IA', draft: 'BROUILLON D’EXÉCUTION', history: 'SÉANCE RÉALISÉE', } return labels[kind] } function deletionConsequence(kind: 'preparation' | 'proposal' | 'draft' | 'history'): string { const consequences = { preparation: 'La préparation sera retirée. Ses livraisons Android non commencées seront annulées, sans effacer un entraînement réel.', proposal: 'La proposition sera retirée et son retrait sera propagé. Les préparations ou séances déjà dérivées seront conservées.', draft: 'Le brouillon sera abandonné seulement s’il n’est pas actif ou modifié concurremment sur Android.', history: 'La séance sera retirée de l’historique avec une protection empêchant sa réapparition depuis un ancien instantané.', } return consequences[kind] } function deletionSuccess(kind: 'preparation' | 'proposal' | 'draft' | 'history'): string { const messages = { preparation: 'Préparation supprimée.', proposal: 'Proposition supprimée.', draft: 'Brouillon abandonné.', history: 'Séance supprimée de l’historique.', } return messages[kind] } function compareIdentity(left: SessionListEntry, right: SessionListEntry): number { return `${left.collection}:${left.identity}`.localeCompare( `${right.collection}:${right.identity}`, 'en') } function compareTimestampDescending(left: string, right: string): number { const leftTimestamp = validTimestampValue(left) const rightTimestamp = validTimestampValue(right) if (leftTimestamp === null && rightTimestamp === null) return 0 if (leftTimestamp === null) return 1 if (rightTimestamp === null) return -1 return rightTimestamp - leftTimestamp } export function sortSessionItems(items: readonly SessionListEntry[], view: View): SessionListEntry[] { return [...items].sort((left, right) => { if (view === 'preparation') { const leftDate = validDateSortValue(left.date) const rightDate = validDateSortValue(right.date) if (leftDate !== null && rightDate !== null && leftDate !== rightDate) { return rightDate.localeCompare(leftDate, 'en') } if (leftDate === null && rightDate !== null) return 1 if (leftDate !== null && rightDate === null) return -1 } const timestampOrder = compareTimestampDescending(left.sort_timestamp, right.sort_timestamp) return timestampOrder !== 0 ? timestampOrder : compareIdentity(left, right) }) } export function sessionStateLabel(collection: SessionCollection, state: string): string { const labels: Record> = { preparations: { draft: 'En préparation', ready: 'Prête', local: 'Locale', pending: 'À synchroniser', acknowledged: 'Reçue', remote_unknown: 'État Android inconnu', withdrawn: 'Supprimée', }, proposals: { local: 'À valider', pending: 'À valider', published: 'Publiée' }, drafts: { active: 'En cours', pending: 'À reprendre', stale: 'À vérifier' }, history: { completed: 'Terminée' }, } return labels[collection][state] ?? `État non reconnu (${state})` } function parseDecimal(value: string): number | null { if (!value.trim()) return null const normalized = value.replace(',', '.') const number = Number(normalized) return Number.isFinite(number) ? number : null } function emptyInput(): PreparationInput { return { title: '', session_type: 'training', planned_for: null, notes: null, editing_state: 'draft', occurrences: [] } } function editableOccurrence( value: PreparationOccurrence, preserveIdentity: boolean, ): PreparationOccurrence { return { ...(preserveIdentity ? { entry_id: value.entry_id } : {}), exercise_id: value.exercise_id, exercise_name: value.exercise_name, equipment_id: value.equipment_id, recording_mode: value.recording_mode, tracking_mode: value.tracking_mode, load_mode: value.load_mode, rest_seconds: value.rest_seconds, target_sets: value.target_sets, target_reps: value.target_reps, target_duration_seconds: value.target_duration_seconds, target_weight_kg: value.target_weight_kg, notes: value.notes, } } export function commandOccurrence(value: PreparationOccurrence): PreparationOccurrence { return { ...(value.entry_id ? { entry_id: value.entry_id } : {}), exercise_id: value.exercise_id, equipment_id: value.equipment_id, load_mode: value.load_mode, rest_seconds: value.rest_seconds, target_sets: value.target_sets, target_reps: value.target_reps, target_duration_seconds: value.target_duration_seconds, target_weight_kg: value.target_weight_kg, notes: value.notes, } } function Editor({ initial, onSaved, onCancel }: { initial?: SessionDetail onSaved: (identity: string) => void onCancel: () => void }) { const [input, setInput] = useState(() => initial ? { title: initial.title, session_type: initial.session_type, planned_for: initial.planned_for, notes: initial.notes, editing_state: 'draft', occurrences: initial.occurrences.map((value) => editableOccurrence(value, initial.kind === 'preparation')), ...(initial.kind === 'proposal' ? { source_proposal_id: initial.identity, source_payload_sha256: initial.source_fingerprint ?? undefined, } : {}), } : emptyInput()) const [catalog, setCatalog] = useState([]) const [catalogSearch, setCatalogSearch] = useState('') const [error, setError] = useState('') const [saving, setSaving] = useState(false) useEffect(() => { const controller = new AbortController() fetchCatalog(catalogSearch, controller.signal).then(setCatalog).catch(() => setCatalog([])) return () => controller.abort() }, [catalogSearch]) const updateOccurrence = (index: number, next: PreparationOccurrence) => { setInput((current) => ({ ...current, occurrences: current.occurrences.map((value, position) => position === index ? next : value) })) } const add = (choice: CatalogChoice) => setInput((current) => ({ ...current, occurrences: [ ...current.occurrences, { exercise_id: choice.exercise_id, exercise_name: choice.name, equipment_id: null, recording_mode: choice.recording_mode, tracking_mode: choice.tracking_mode, load_mode: 'none', rest_seconds: 0, target_sets: choice.recording_mode === 'sets' ? 1 : null, target_reps: choice.tracking_mode === 'reps' ? 1 : null, target_duration_seconds: choice.tracking_mode === 'duration' ? 60 : null, target_weight_kg: null, notes: null }, ] })) const move = (index: number, delta: number) => setInput((current) => { const target = index + delta if (target < 0 || target >= current.occurrences.length) return current const occurrences = [...current.occurrences] ;[occurrences[index], occurrences[target]] = [occurrences[target], occurrences[index]] return { ...current, occurrences } }) const submit = async (ready: boolean) => { setSaving(true); setError('') try { const result = await savePreparation({ ...input, editing_state: ready ? 'ready' : 'draft', occurrences: input.occurrences.map(commandOccurrence) }, initial?.kind === 'preparation' ? initial.identity : undefined, initial?.kind === 'preparation' ? initial.revision_id : undefined) if (ready) await prepareForAndroid(result.preparation_id, result.revision_id) onSaved(result.preparation_id) } catch (reason) { setError(reason instanceof Error ? reason.message : 'Sauvegarde impossible') } finally { setSaving(false) } } return

PRÉPARATION

{initial ? 'Modifier la séance' : 'Nouvelle séance'}

{error &&

{error}

}