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) => - {formatDateTime(point.timestamp, dateFormat)}, {formatWeight(point.weight_kg)}{point.improved ? ', amélioration' : ''}
)}
>}
{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) => {record.exercise_name}{formatDate(record.timestamp, dateFormat)}{size === 'large' && record.equipment_id ? ` · ${record.equipment_id}` : ''}
{formatWeight(record.weight_kg)} )}
{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
}
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 }
export type DashboardTileComponent = ComponentType