import type { DashboardSnapshot, WorkedZone } from '../api/dashboard' import { lazy, Suspense, type ComponentType, type ReactNode } from 'react' import type { TileSize } from './dashboardLayout' import { count, formatDate, formatDateTime, formatDose, formatDuration, formatWeight } from './dashboardFormat' import { BodyZoneFigure } from './BodyZoneFigure' import type { PreparedItemsSnapshot } from '../api/preparedItems' import type { PreparedItem } from '../api/preparedItems' import { useDatePreferences } from '../presentation/DatePreferences' import { validDateSortValue, validTimestampValue } from '../presentation/dateFormat' const ProgressionChart = lazy(() => import('../charts/ProgressionChart').then((module) => ({ default: module.ProgressionChart }))) interface TileDataProps { snapshot: DashboardSnapshot; size: TileSize } export interface NextSessionTileProps { size: TileSize preparedItems?: PreparedItemsSnapshot | null pending?: boolean failed?: boolean } export function sortPreparedItems(items: readonly PreparedItem[]): PreparedItem[] { return [...items].sort((left, right) => { const leftDate = validDateSortValue(left.planned_for) const rightDate = validDateSortValue(right.planned_for) 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 leftTimestamp = validTimestampValue(left.sort_timestamp) const rightTimestamp = validTimestampValue(right.sort_timestamp) if (leftTimestamp !== null && rightTimestamp !== null && leftTimestamp !== rightTimestamp) { return rightTimestamp - leftTimestamp } if (leftTimestamp === null && rightTimestamp !== null) return 1 if (leftTimestamp !== null && rightTimestamp === null) return -1 return `${left.kind}:${left.identity}`.localeCompare(`${right.kind}:${right.identity}`, 'en') }) } export function NextSessionTile({ size, preparedItems = null, pending = false, failed = false, }: NextSessionTileProps) { const { dateFormat } = useDatePreferences() if (preparedItems === null) { return } const orderedItems = sortPreparedItems(preparedItems.items) const item = orderedItems[0] if (item === undefined) { return } const proposal = item.kind === 'ai_proposal' const manual = item.kind === 'manual_preparation' const heading = proposal ? 'Proposition préparée · à valider' : manual ? 'Préparation manuelle' : item.state === 'active' ? 'Brouillon d’exécution · à reprendre' : 'Brouillon d’exécution · en attente' const title = item.title || (proposal ? 'Proposition sans titre' : 'Séance préparée') return (

{heading}

{title} {size !== 'compact' && ( <>
{formatDate(item.planned_for, dateFormat)} : 'Non planifiée'} />
{orderedItems.length > 1 && (
{orderedItems.length} éléments préparés · date décroissante
)} )} {failed && Dernière lecture conservée · actualisation indisponible} {pending && !failed && Actualisation…}
) } export function ActivityTile({ snapshot, size }: TileDataProps) { const { dateFormat } = useDatePreferences() const days = snapshot.data.activity.days // CONTRACT: these are direct presentation totals of Core-projected counters, // never an activity or physiological-intensity score. const activeDays = days.filter((day) => day.active).length const sessions = days.reduce((total, day) => total + day.session_count, 0) const sets = days.reduce((total, day) => total + day.set_count, 0) const shown = size === 'compact' ? days.slice(-35) : days return
{size !== 'compact' && }{size === 'large' && }
{shown.map((day) => 1 ? ' is-multiple' : ''}`} title={`${formatDate(day.date, dateFormat)} — ${count(day.session_count, 'séance')}, ${count(day.set_count, 'série')}`} />)}
{size === 'large' &&

Jour avec au moins une séance · la teinte indique uniquement le nombre de séances

}
} export function ProgressionTile({ snapshot, size }: TileDataProps) { const { dateFormat } = useDatePreferences() const progression = snapshot.data.progression if (!progression.available) return const first = progression.points[0] const last = progression.points.at(-1) return

{progression.identity.exercise_name}

{size !== 'compact' &&

{progression.identity.equipment_label}

}
{last === undefined ?

Aucun point disponible

:
{formatWeight(last.weight_kg)}{formatDate(last.timestamp, dateFormat)}{last.improved && Amélioration enregistrée}
} {size !== 'compact' &&
} {size !== 'compact' && progression.points.length > 0 && <>Chargement de la courbe…
}>
    {progression.points.map((point, index) =>
  1. {formatDateTime(point.timestamp, dateFormat)}, {formatWeight(point.weight_kg)}{point.improved ? ', amélioration' : ''}
  2. )}
} {size === 'large' &&

Identité comparable : {progression.identity.tracking_mode} · {progression.identity.load_mode}

} } export function LastSessionTile({ snapshot, size }: TileDataProps) { const { dateFormat } = useDatePreferences() const session = snapshot.data.last_session if (!session.available) return return

{size !== 'compact' && <>}
{size === 'large' &&
{session.primary_zones.map((zone) => {zone.label})}
}
} export function MaxRecordsTile({ snapshot, size }: TileDataProps) { const { dateFormat } = useDatePreferences() const records = snapshot.data.max_records.records if (!snapshot.data.max_records.available || records.length === 0) return const limit = size === 'compact' ? 1 : size === 'medium' ? 3 : 8 return
    {records.slice(0, limit).map((record) =>
  1. {record.exercise_name}{formatDate(record.timestamp, dateFormat)}{size === 'large' && record.equipment_id ? ` · ${record.equipment_id}` : ''}
    {formatWeight(record.weight_kg)}
  2. )}
{snapshot.meta.partial && records.length >= 8 &&

8 MAX récents affichés

}
} export function MuscleDistributionTile({ snapshot, size }: TileDataProps) { const zones = snapshot.data.muscle_distribution.primary_zones if (!snapshot.data.muscle_distribution.available || zones.length === 0) return // INVARIANT: deterministic display order is separate from bar semantics; // each bar length represents session_count alone. const ordered = [...zones].sort((a, b) => b.session_count - a.session_count || b.occurrence_count - a.occurrence_count || a.label.localeCompare(b.label, 'fr')) const shown = size === 'compact' ? ordered.slice(0, 3) : ordered const maximum = Math.max(...ordered.map((zone) => zone.session_count), 1) return
{size !== 'compact' &&

Couleur : séances sur 30 jours · maximum relatif au snapshot affiché

}

Longueur : nombre de séances sur 30 jours

    {shown.map((zone) => )}
} function MuscleRow({ zone, maximum, detailed, full }: { zone: WorkedZone; maximum: number; detailed: boolean; full: boolean }) { return
  • {zone.label}{count(zone.session_count, 'séance')}
    {detailed &&

    {count(zone.occurrence_count, 'occurrence')}{full && ` · ${count(zone.set_count, 'série')}`}

    }
  • } export function CardioTile({ size }: TileDataProps) { return
    {size !== 'compact' &&

    Aucune donnée cardio disponible

    }{size === 'large' && Les mesures apparaîtront ici lorsqu’une source cardio sera enregistrée.}
    } function Metric({ value, label }: { value: string; label: string }) { return
    {value}{label}
    } function Fact({ label, value }: { label: string; value: ReactNode }) { return
    {label}
    {value}
    } function Unavailable({ text }: { text: string }) { return

    {text}

    } export type DashboardTileComponent = ComponentType