diff --git a/CHANGELOG.md b/CHANGELOG.md
index 35e9ee7..ca989f8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,14 @@ Development opened after stable v0.1.3 release.
links, safe query fallbacks, null/empty states, French/English presentation,
keyboard focus, and responsive charts are covered without estimating 1RM,
readiness, fatigue, recovery, body fat, or performance.
+- Refined the Analyse V1 and Dashboard presentation without changing their
+ read models or API contracts. Period controls now share the section toolbar;
+ durations use the shared human-readable formatter; incompatible exercise
+ metrics remain hidden; activity, exercise, and measurement charts expose
+ chronological dates, units, and factual tooltips; BODY ZONE silhouettes and
+ exposure comparisons are more legible; and active-Program summaries use a
+ compact structured progression layout. Persisted Dashboard tile identities
+ and layout behavior remain unchanged.
## 0.1.3 — 2026-09-20
diff --git a/docs/current_state.md b/docs/current_state.md
index 7505443..9f5a5a9 100644
--- a/docs/current_state.md
+++ b/docs/current_state.md
@@ -141,7 +141,7 @@ parallel implementation of its rules.
| Private full-generation rollout | `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` (direct MTP, one authorized phone) |
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` |
-| Current operational cursor | `TRAINLOG_WEB_ANALYSIS_V1` (implemented, visual review pending) |
+| Current operational cursor | `TRAINLOG_WEB_ANALYSIS_V1` (implemented and visually refined, user review pending) |
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
diff --git a/web/src/dashboard/DashboardTiles.test.tsx b/web/src/dashboard/DashboardTiles.test.tsx
index 7f5918f..122ccee 100644
--- a/web/src/dashboard/DashboardTiles.test.tsx
+++ b/web/src/dashboard/DashboardTiles.test.tsx
@@ -1,138 +1,376 @@
-import { render, screen, within } from '@testing-library/react'
-import { describe, expect, it } from 'vitest'
-import { dashboardFixture } from '../test/dashboardFixtures'
-import { ActivityTile, CardioTile, LastSessionTile, MaxRecordsTile, MuscleDistributionTile, NextSessionTile, ProgressionTile } from './DashboardTiles'
+import { render, screen, within } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { dashboardFixture } from "../test/dashboardFixtures";
+import {
+ ActivityTile,
+ CardioTile,
+ LastSessionTile,
+ MaxRecordsTile,
+ MuscleDistributionTile,
+ NextSessionTile,
+ ProgressionTile,
+} from "./DashboardTiles";
+import { analysisFixture } from "../api/analysis.test";
-describe('tuiles Dashboard alimentées par le contrat', () => {
- it('présente les indisponibilités sans exposer les raisons techniques ni inventer de faits', () => {
- const snapshot = dashboardFixture()
- const { rerender } = render()
- expect(screen.getByText('Aucun élément disponible')).toBeInTheDocument()
- expect(document.body).not.toHaveTextContent('no_persisted_executable_plan')
- rerender()
- expect(screen.getByText('Aucune donnée cardio disponible')).toBeInTheDocument()
- expect(document.body).not.toHaveTextContent('no_cardio_data_source')
- expect(document.body).not.toHaveTextContent(/bpm|%/i)
- })
+describe("tuiles Dashboard alimentées par le contrat", () => {
+ it("présente les indisponibilités sans exposer les raisons techniques ni inventer de faits", () => {
+ const snapshot = dashboardFixture();
+ const { rerender } = render(
+ ,
+ );
+ expect(screen.getByText("Aucun élément disponible")).toBeInTheDocument();
+ expect(document.body).not.toHaveTextContent("no_persisted_executable_plan");
+ rerender();
+ expect(
+ screen.getByText("Aucune donnée cardio disponible"),
+ ).toBeInTheDocument();
+ expect(document.body).not.toHaveTextContent("no_cardio_data_source");
+ expect(document.body).not.toHaveTextContent(/bpm|%/i);
+ });
- it('sépare proposition à valider et brouillon actif sans fabriquer de réalisé', () => {
+ it("sépare proposition à valider et brouillon actif sans fabriquer de réalisé", () => {
const proposal = {
api_version: 1 as const,
- generated_at: '2026-09-17T12:00:00Z',
+ generated_at: "2026-09-17T12:00:00Z",
partial: false,
- items: [{ identity: 'aid_one', kind: 'ai_proposal' as const, title: 'Préparation', planned_for: '2026-09-18', state: 'published', sort_timestamp: '2026-09-17T12:00:00Z', occurrence_count: 6, provenance: 'ai_import' }],
- }
+ items: [
+ {
+ identity: "aid_one",
+ kind: "ai_proposal" as const,
+ title: "Préparation",
+ planned_for: "2026-09-18",
+ state: "published",
+ sort_timestamp: "2026-09-17T12:00:00Z",
+ occurrence_count: 6,
+ provenance: "ai_import",
+ },
+ ],
+ };
const { rerender } = render(
- ,
- )
- expect(screen.getByText('Proposition préparée · à valider')).toBeInTheDocument()
- expect(screen.getByText('Préparation')).toBeInTheDocument()
- expect(document.body).not.toHaveTextContent(/réalisée|terminée/i)
+ ,
+ );
+ expect(
+ screen.getByText("Proposition préparée · à valider"),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Préparation")).toBeInTheDocument();
+ expect(document.body).not.toHaveTextContent(/réalisée|terminée/i);
- rerender()
- expect(screen.getByText('Brouillon d’exécution · à reprendre')).toBeInTheDocument()
- })
+ rerender(
+ ,
+ );
+ expect(
+ screen.getByText("Brouillon d’exécution · à reprendre"),
+ ).toBeInTheDocument();
+ });
- it('conserve une lecture durable lorsque son actualisation échoue', () => {
- render()
- expect(screen.getByText('Conservée')).toBeInTheDocument()
- expect(screen.getByText(/Dernière lecture conservée/)).toBeInTheDocument()
- expect(screen.queryByText('Aucun élément disponible')).not.toBeInTheDocument()
- })
+ it("conserve une lecture durable lorsque son actualisation échoue", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Conservée")).toBeInTheDocument();
+ expect(screen.getByText(/Dernière lecture conservée/)).toBeInTheDocument();
+ expect(
+ screen.queryByText("Aucun élément disponible"),
+ ).not.toBeInTheDocument();
+ });
- it('rend chaque proposition consultable sans la transformer en brouillon', () => {
- render()
- const expected = document.querySelector('[data-prepared-identity="aid_expected"]')
- expect(expected).toHaveTextContent('Attendue')
- expect(expected).toHaveAttribute('data-prepared-identity', 'aid_expected')
- expect(document.body).not.toHaveTextContent('Brouillon d’exécution · à reprendre')
- })
+ it("rend chaque proposition consultable sans la transformer en brouillon", () => {
+ render(
+ ,
+ );
+ const expected = document.querySelector(
+ '[data-prepared-identity="aid_expected"]',
+ );
+ expect(expected).toHaveTextContent("Attendue");
+ expect(expected).toHaveAttribute("data-prepared-identity", "aid_expected");
+ expect(document.body).not.toHaveTextContent(
+ "Brouillon d’exécution · à reprendre",
+ );
+ });
- it.each(['compact', 'medium', 'large'] as const)('rend les 90 jours et les sommes factuelles en taille %s', (size) => {
- const { container } = render()
- const heatmap = screen.getByRole('img')
- expect(heatmap).toHaveAccessibleName(/2 jours actifs, 3 séances, 17 séries/)
- expect(container.querySelectorAll('.activity-day')).toHaveLength(size === 'compact' ? 35 : 90)
- })
+ it.each(["compact", "medium", "large"] as const)(
+ "rend les 90 jours et les sommes factuelles en taille %s",
+ (size) => {
+ const { container } = render(
+ ,
+ );
+ const heatmap = screen.getByRole("img");
+ expect(heatmap).toHaveAccessibleName(
+ /2 jours actifs, 3 séances, 17 séries/,
+ );
+ expect(container.querySelectorAll(".activity-day")).toHaveLength(
+ size === "compact" ? 35 : 90,
+ );
+ },
+ );
- it('présente l’identité comparable, le flag improved et le point legacy 0 kg', () => {
- render()
- expect(screen.getByText('Presse épaules')).toBeInTheDocument()
- expect(screen.getByText('Amélioration enregistrée')).toBeInTheDocument()
- expect(screen.getByText('0 kg')).toBeInTheDocument()
- expect(screen.queryByText(/%/)).not.toBeInTheDocument()
- })
+ it("présente l’identité comparable, le flag improved et le point legacy 0 kg", () => {
+ render();
+ expect(screen.getByText("Presse épaules")).toBeInTheDocument();
+ expect(screen.getByText("Amélioration enregistrée")).toBeInTheDocument();
+ expect(screen.getByText("0 kg")).toBeInTheDocument();
+ expect(screen.queryByText(/%/)).not.toBeInTheDocument();
+ });
- it('réserve la courbe aux tailles medium et large', async () => {
- const snapshot = dashboardFixture()
- const { rerender } = render()
- expect(screen.queryByTestId('progression-chart')).not.toBeInTheDocument()
- rerender()
- expect(await screen.findByTestId('progression-chart', undefined, { timeout: 5_000 })).toBeInTheDocument()
- expect(screen.getByRole('list', { name: 'Mesures de progression' })).toHaveTextContent('0 kg')
- expect(screen.getByRole('list', { name: 'Mesures de progression' })).toHaveTextContent('amélioration')
- rerender()
- expect(await screen.findByTestId('progression-chart', undefined, { timeout: 5_000 })).toBeInTheDocument()
- expect(screen.getByText('Premier')).toBeInTheDocument()
- expect(screen.getByText('Dernier')).toBeInTheDocument()
- expect(screen.getByText('Points')).toBeInTheDocument()
- })
+ it("masque les séries incompatibles et humanise la durée pour une activité continue", () => {
+ const analysis = {
+ ...analysisFixture,
+ exercise: {
+ ...analysisFixture.exercise!,
+ name: "Marche",
+ tracking_mode: "duration" as const,
+ recording_mode: "continuous" as const,
+ points: [
+ {
+ ...analysisFixture.exercise!.points[0],
+ sets: 0,
+ reps: null,
+ external_load_kg: null,
+ external_volume_kg: null,
+ explicit_max_kg: null,
+ continuous_duration_seconds: 900,
+ distance_km: 1.43,
+ speed_kmh: 6.4,
+ },
+ ],
+ },
+ };
+ render(
+ ,
+ );
+ expect(screen.getByText("15 min")).toBeInTheDocument();
+ expect(screen.getByText("1,43 km")).toBeInTheDocument();
+ expect(screen.getByText("6,4 km/h")).toBeInTheDocument();
+ expect(screen.queryByText("Séries")).not.toBeInTheDocument();
+ });
- it('omet toute durée fabriquée lorsque ended_at est absent et révèle les zones en grand', () => {
- const snapshot = dashboardFixture()
- const { rerender } = render()
- expect(screen.getByText('Indisponible')).toBeInTheDocument()
- if (!snapshot.data.last_session.available) throw new Error('fixture invalide')
- snapshot.data.last_session.duration_seconds = 3720
- rerender()
- expect(screen.getByText('1 h 02')).toBeInTheDocument()
- expect(screen.getByLabelText('Zones primaires travaillées')).toHaveTextContent('Pectoraux')
- expect(screen.getByLabelText('Zones primaires travaillées')).toHaveTextContent('Épaules')
- })
+ it("réserve la courbe aux tailles medium et large", async () => {
+ const snapshot = dashboardFixture();
+ const { rerender } = render(
+ ,
+ );
+ expect(screen.queryByTestId("progression-chart")).not.toBeInTheDocument();
+ rerender();
+ expect(
+ await screen.findByTestId("progression-chart", undefined, {
+ timeout: 5_000,
+ }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("list", { name: "Mesures de progression" }),
+ ).toHaveTextContent("0 kg");
+ expect(
+ screen.getByRole("list", { name: "Mesures de progression" }),
+ ).toHaveTextContent("amélioration");
+ rerender();
+ expect(
+ await screen.findByTestId("progression-chart", undefined, {
+ timeout: 5_000,
+ }),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Premier")).toBeInTheDocument();
+ expect(screen.getByText("Dernier")).toBeInTheDocument();
+ expect(screen.getByText("Points")).toBeInTheDocument();
+ });
- it.each([['compact', 1], ['medium', 3], ['large', 8]] as const)('borne les MAX en taille %s à %i', (size, expected) => {
- const { container } = render()
- expect(container.querySelectorAll('.record-list li')).toHaveLength(expected)
- if (size === 'large') expect(screen.getByText('8 MAX récents affichés')).toBeInTheDocument()
- })
+ it("omet toute durée fabriquée lorsque ended_at est absent et révèle les zones en grand", () => {
+ const snapshot = dashboardFixture();
+ const { rerender } = render(
+ ,
+ );
+ expect(screen.getByText("Indisponible")).toBeInTheDocument();
+ if (!snapshot.data.last_session.available)
+ throw new Error("fixture invalide");
+ snapshot.data.last_session.duration_seconds = 3720;
+ rerender();
+ expect(screen.getByText("1 h 02 min")).toBeInTheDocument();
+ expect(
+ screen.getByLabelText("Zones primaires travaillées"),
+ ).toHaveTextContent("Pectoraux");
+ expect(
+ screen.getByLabelText("Zones primaires travaillées"),
+ ).toHaveTextContent("Épaules");
+ });
- it('encode les barres musculaires avec les séances et conserve les valeurs textuelles', () => {
- const { container } = render()
- const chest = within(container.querySelector('.muscle-list li') as HTMLElement)
- expect(chest.getByText('4 séances')).toBeInTheDocument()
- expect(chest.getByText(/6 occurrences · 18 séries/)).toBeInTheDocument()
- expect((container.querySelector('.muscle-track span') as HTMLElement).style.width).toBe('100%')
- expect(screen.getByText(/Longueur : nombre de séances/)).toBeInTheDocument()
- })
+ it.each([
+ ["compact", 1],
+ ["medium", 3],
+ ["large", 8],
+ ] as const)("borne les MAX en taille %s à %i", (size, expected) => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelectorAll(".record-list li")).toHaveLength(
+ expected,
+ );
+ if (size === "large")
+ expect(screen.getByText("8 MAX récents affichés")).toBeInTheDocument();
+ });
- it('affiche la silhouette en medium/large, jamais en compact, et conserve une zone non mappée dans la liste', () => {
- const snapshot = dashboardFixture()
- snapshot.data.muscle_distribution.primary_zones.push({ zone_id: 'full_body', label: 'Corps entier', session_count: 1, occurrence_count: 1, set_count: 0 })
- const { rerender } = render()
- expect(screen.queryByLabelText('Silhouette, vue avant')).not.toBeInTheDocument()
- rerender()
- expect(screen.getByLabelText('Silhouette, vue avant')).toBeInTheDocument()
- expect(screen.getByLabelText('Silhouette, vue arrière')).toBeInTheDocument()
- expect(screen.getByText('Corps entier')).toBeInTheDocument()
- rerender()
- expect(screen.getByLabelText('Silhouette, vue arrière')).toBeInTheDocument()
- expect(screen.getByText(/Couleur : séances sur 30 jours/)).toBeInTheDocument()
- expect(screen.getByText('Corps entier')).toBeInTheDocument()
- })
+ it("encode les barres musculaires avec les séances et conserve les valeurs textuelles", () => {
+ const { container } = render(
+ ,
+ );
+ const chest = within(
+ container.querySelector(".muscle-list li") as HTMLElement,
+ );
+ expect(chest.getByText("4 séances")).toBeInTheDocument();
+ expect(chest.getByText(/6 occurrences · 18 séries/)).toBeInTheDocument();
+ expect(
+ (container.querySelector(".muscle-track span") as HTMLElement).style
+ .width,
+ ).toBe("100%");
+ expect(
+ screen.getByText(/Longueur : nombre de séances/),
+ ).toBeInTheDocument();
+ });
- it('gère les états indisponibles des domaines optionnels', () => {
- const snapshot = dashboardFixture()
- snapshot.data.progression = { available: false, reason: 'no_comparable_performance' }
- snapshot.data.last_session = { available: false, reason: 'no_observable_session' }
- snapshot.data.max_records = { available: false, records: [] }
- snapshot.data.muscle_distribution = { available: false, window_days: 30, primary_zones: [] }
- const { rerender } = render()
- expect(screen.getByText(/Aucune performance/)).toBeInTheDocument()
- rerender(); expect(screen.getByText(/Aucune séance observable/)).toBeInTheDocument()
- rerender(); expect(screen.getByText(/Aucun MAX explicite/)).toBeInTheDocument()
- rerender(); expect(screen.getByText(/Aucune zone travaillée/)).toBeInTheDocument()
- })
-})
+ it("affiche la silhouette en medium/large, jamais en compact, et conserve une zone non mappée dans la liste", () => {
+ const snapshot = dashboardFixture();
+ snapshot.data.muscle_distribution.primary_zones.push({
+ zone_id: "full_body",
+ label: "Corps entier",
+ session_count: 1,
+ occurrence_count: 1,
+ set_count: 0,
+ });
+ const { rerender } = render(
+ ,
+ );
+ expect(
+ screen.queryByLabelText("Silhouette, vue avant"),
+ ).not.toBeInTheDocument();
+ rerender();
+ expect(screen.getByLabelText("Silhouette, vue avant")).toBeInTheDocument();
+ expect(
+ screen.getByLabelText("Silhouette, vue arrière"),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Corps entier")).toBeInTheDocument();
+ rerender();
+ expect(
+ screen.getByLabelText("Silhouette, vue arrière"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(/Couleur : séances sur 30 jours/),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Corps entier")).toBeInTheDocument();
+ });
+
+ it("gère les états indisponibles des domaines optionnels", () => {
+ const snapshot = dashboardFixture();
+ snapshot.data.progression = {
+ available: false,
+ reason: "no_comparable_performance",
+ };
+ snapshot.data.last_session = {
+ available: false,
+ reason: "no_observable_session",
+ };
+ snapshot.data.max_records = { available: false, records: [] };
+ snapshot.data.muscle_distribution = {
+ available: false,
+ window_days: 30,
+ primary_zones: [],
+ };
+ const { rerender } = render(
+ ,
+ );
+ expect(screen.getByText(/Aucune performance/)).toBeInTheDocument();
+ rerender();
+ expect(screen.getByText(/Aucune séance observable/)).toBeInTheDocument();
+ rerender();
+ expect(screen.getByText(/Aucun MAX explicite/)).toBeInTheDocument();
+ rerender();
+ expect(screen.getByText(/Aucune zone travaillée/)).toBeInTheDocument();
+ });
+});
diff --git a/web/src/dashboard/DashboardTiles.tsx b/web/src/dashboard/DashboardTiles.tsx
index c11e6b4..c443cab 100644
--- a/web/src/dashboard/DashboardTiles.tsx
+++ b/web/src/dashboard/DashboardTiles.tsx
@@ -1,43 +1,70 @@
-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'
-import type { AnalysisSnapshot } from '../api/analysis'
+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";
+import type { AnalysisSnapshot } from "../api/analysis";
-const ProgressionChart = lazy(() => import('../charts/ProgressionChart').then((module) => ({ default: module.ProgressionChart })))
+const ProgressionChart = lazy(() =>
+ import("../charts/ProgressionChart").then((module) => ({
+ default: module.ProgressionChart,
+ })),
+);
-interface TileDataProps { snapshot: DashboardSnapshot; size: TileSize; analysis?: AnalysisSnapshot | null }
-
-export interface NextSessionTileProps {
- size: TileSize
- preparedItems?: PreparedItemsSnapshot | null
- pending?: boolean
- failed?: boolean
+interface TileDataProps {
+ snapshot: DashboardSnapshot;
+ size: TileSize;
+ analysis?: AnalysisSnapshot | null;
}
-export function sortPreparedItems(items: readonly PreparedItem[]): PreparedItem[] {
+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)
+ const leftDate = validDateSortValue(left.planned_for);
+ const rightDate = validDateSortValue(right.planned_for);
if (leftDate !== null && rightDate !== null && leftDate !== rightDate) {
- return rightDate.localeCompare(leftDate, 'en')
+ 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 (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')
- })
+ 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({
@@ -46,51 +73,94 @@ export function NextSessionTile({
pending = false,
failed = false,
}: NextSessionTileProps) {
- const { dateFormat } = useDatePreferences()
+ const { dateFormat } = useDatePreferences();
if (preparedItems === null) {
- return
+ return (
+
+ );
}
- const orderedItems = sortPreparedItems(preparedItems.items)
- const item = orderedItems[0]
+ const orderedItems = sortPreparedItems(preparedItems.items);
+ const item = orderedItems[0];
if (item === undefined) {
- return
+ return (
+
+ );
}
- const proposal = item.kind === 'ai_proposal'
- const manual = item.kind === 'manual_preparation'
+ 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')
+ ? "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' && (
+ {size !== "compact" && (
<>
- {formatDate(item.planned_for, dateFormat)}
- : 'Non planifiée'} />
+
+ {formatDate(item.planned_for, dateFormat)}
+
+ ) : (
+ "Non planifiée"
+ )
+ }
+ />
{orderedItems.length > 1 && (
- {orderedItems.length} éléments préparés · date décroissante
+
+ {orderedItems.length} éléments préparés · date décroissante
+
@@ -98,109 +168,481 @@ export function NextSessionTile({
)}
>
)}
- {failed && Dernière lecture conservée · actualisation indisponible}
+ {failed && (
+ Dernière lecture conservée · actualisation indisponible
+ )}
{pending && !failed && Actualisation…}
- Ouvrir la séance
+
+ Ouvrir la séance
+
- )
+ );
}
export function ActivityTile({ snapshot, size, analysis }: TileDataProps) {
- const { dateFormat } = useDatePreferences()
- const days = snapshot.data.activity.days
+ 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 = analysis?.overview.sessions ?? days.reduce((total, day) => total + day.session_count, 0)
- const sets = analysis?.overview.sets ?? days.reduce((total, day) => total + day.set_count, 0)
- const shown = size === 'compact' ? days.slice(-35) : days
- return
-
{size !== 'compact' && }{size === 'large' && }{size === 'large' && analysis?.overview.duration_seconds !== null && analysis?.overview.duration_seconds !== undefined && }
-
- {shown.map((day) =>
1 ? ' is-multiple' : ''}`} title={`${formatDate(day.date, dateFormat)} — ${count(day.session_count, 'séance')}, ${count(day.set_count, 'série')}`} />)}
+ const activeDays = days.filter((day) => day.active).length;
+ const sessions =
+ analysis?.overview.sessions ??
+ days.reduce((total, day) => total + day.session_count, 0);
+ const sets =
+ analysis?.overview.sets ??
+ days.reduce((total, day) => total + day.set_count, 0);
+ const shown = size === "compact" ? days.slice(-35) : days;
+ return (
+
+
+
+ {size !== "compact" && (
+
+ )}
+ {size === "large" && }
+ {size === "large" &&
+ analysis?.overview.duration_seconds !== null &&
+ analysis?.overview.duration_seconds !== undefined && (
+
+ )}
+
+
+ {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
+
+ )}
- {size === 'large' && Jour avec au moins une séance · la teinte indique uniquement le nombre de séances
}
-
+ );
}
export function ProgressionTile({ snapshot, size, analysis }: TileDataProps) {
- const { dateFormat } = useDatePreferences()
+ const { dateFormat } = useDatePreferences();
if (analysis?.exercise !== null && analysis?.exercise !== undefined) {
- const exercise = analysis.exercise
- const latest = exercise.points[0]
- return
{exercise.name}
{exercise.tracking_mode === 'reps' ? 'Répétitions' : 'Durée'}
{latest === undefined ?
:
{latest.reps !== null && }{latest.external_volume_kg !== null && }{latest.explicit_max_kg !== null && }
}
Voir dans Analyse
+ const exercise = analysis.exercise;
+ const latest = exercise.points[0];
+ return (
+
+
+
{exercise.name}
+
+ {exercise.tracking_mode === "reps" ? "Répétitions" : "Durée"}
+
+
+ {latest === undefined ? (
+
+ ) : (
+
+ {exercise.recording_mode === "sets" && (
+
+ )}
+ {latest.reps !== null && (
+
+ )}
+ {(latest.continuous_duration_seconds ??
+ latest.set_duration_seconds) !== null && (
+
+ )}
+ {latest.distance_km !== null && (
+
+ )}
+ {latest.speed_kmh !== null && (
+
+ )}
+ {latest.external_volume_kg !== null && (
+
+ )}
+ {latest.explicit_max_kg !== null && (
+
+ )}
+
+ )}
+
+ Voir dans Analyse
+
+
+ );
}
- 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}
}
-
+ 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})}
}
-
+ 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
}
+ 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
+ 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é
}
-
-
+ 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) => (
+
+ ))}
+
+
+ Voir dans Analyse
+
+
+
+ );
}
-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')}`}
}
+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.}
+ return (
+
+
—
+ {size !== "compact" &&
Aucune donnée cardio disponible
}
+ {size === "large" && (
+
+ Les mesures apparaîtront ici lorsqu’une source cardio sera
+ enregistrée.
+
+ )}
+
+ );
}
export function ProgramTile({ size, analysis }: TileDataProps) {
- const program = analysis?.active_program
- if (program === null || program === undefined) return
- return {program.title}{program.completed_sessions}/{program.total_sessions} séances effectuées
{size !== 'compact' &&
{program.next_session_title === null ? 'Programme terminé' : `Prochaine séance : ${program.next_session_title}`}
}
Ouvrir Programmes
+ const program = analysis?.active_program;
+ if (program === null || program === undefined)
+ return ;
+ return (
+
+
{program.title}
+
+
+ {program.completed_sessions} sur {program.total_sessions} séances
+ effectuées
+
+
+
+ {size !== "compact" && (
+
+ Prochaine séance
+ {program.next_session_title ?? "Programme terminé"}
+
+ )}
+
+ Ouvrir Programmes
+
+
+ );
}
export function MeasurementsTile({ size, analysis }: TileDataProps) {
- const summaries = analysis?.measurements.summaries ?? []
- const visible = summaries.filter((summary) => summary.metric === 'weight' || summary.metric === 'waist').filter((summary) => summary.last !== null)
- if (visible.length === 0) return
- return {visible.map((summary) => - {summary.metric === 'weight' ? 'Poids' : 'Tour de taille'}
- {formatWeight(summary.last ?? 0).replace('kg', summary.unit)}{summary.delta !== null && size !== 'compact' && {summary.delta > 0 ? '+' : ''}{summary.delta.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} {summary.unit}}
)}
Voir dans Analyse
+ const summaries = analysis?.measurements.summaries ?? [];
+ const visible = summaries
+ .filter(
+ (summary) => summary.metric === "weight" || summary.metric === "waist",
+ )
+ .filter((summary) => summary.last !== null);
+ if (visible.length === 0)
+ return ;
+ return (
+
+
+ {visible.map((summary) => (
+
+
- {summary.metric === "weight" ? "Poids" : "Tour de taille"}
+ -
+ {formatWeight(summary.last ?? 0).replace("kg", summary.unit)}
+ {summary.delta !== null && size !== "compact" && (
+
+ {summary.delta > 0 ? "+" : ""}
+ {summary.delta.toLocaleString("fr-FR", {
+ maximumFractionDigits: 2,
+ })}{" "}
+ {summary.unit}
+
+ )}
+
+
+ ))}
+
+
+ Voir dans Analyse
+
+
+ );
}
-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 }
+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
+export type DashboardTileComponent = ComponentType;
diff --git a/web/src/dashboard/dashboardFormat.test.ts b/web/src/dashboard/dashboardFormat.test.ts
new file mode 100644
index 0000000..1e8f2cf
--- /dev/null
+++ b/web/src/dashboard/dashboardFormat.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from "vitest";
+import { formatDuration } from "./dashboardFormat";
+
+describe("formatDuration", () => {
+ it.each([
+ [900, "15 min"],
+ [600, "10 min"],
+ [480, "8 min"],
+ [90, "1 min 30 s"],
+ [45, "45 s"],
+ [3720, "1 h 02 min"],
+ ])("humanizes %i seconds as %s", (seconds, expected) => {
+ expect(formatDuration(seconds)).toBe(expected);
+ });
+});
diff --git a/web/src/dashboard/dashboardFormat.ts b/web/src/dashboard/dashboardFormat.ts
index ba60f72..0f1c8f7 100644
--- a/web/src/dashboard/dashboardFormat.ts
+++ b/web/src/dashboard/dashboardFormat.ts
@@ -1,23 +1,31 @@
-import type { WebDateFormat } from '../api/webPreferences'
+import type { WebDateFormat } from "../api/webPreferences";
import {
formatDate as formatPresentationDate,
formatDateTime as formatPresentationDateTime,
-} from '../presentation/dateFormat'
+} from "../presentation/dateFormat";
-const numberFormat = new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 2 })
+const numberFormat = new Intl.NumberFormat("fr-FR", {
+ maximumFractionDigits: 2,
+});
-export const formatDate = (value: string, format: WebDateFormat = 'fr') =>
- formatPresentationDate(value, format)
-export const formatDateTime = (value: string, format: WebDateFormat = 'fr') =>
- formatPresentationDateTime(value, format)
-export const formatWeight = (value: number) => `${numberFormat.format(value)} kg`
-export const formatDose = (value: number) => numberFormat.format(value)
+export const formatDate = (value: string, format: WebDateFormat = "fr") =>
+ formatPresentationDate(value, format);
+export const formatDateTime = (value: string, format: WebDateFormat = "fr") =>
+ formatPresentationDateTime(value, format);
+export const formatWeight = (value: number) =>
+ `${numberFormat.format(value)} kg`;
+export const formatDose = (value: number) => numberFormat.format(value);
export function formatDuration(seconds: number): string {
- const hours = Math.floor(seconds / 3600)
- const minutes = Math.floor((seconds % 3600) / 60)
- const remaining = seconds % 60
- if (hours > 0) return `${hours} h ${minutes.toString().padStart(2, '0')}`
- if (minutes > 0) return `${minutes} min${remaining ? ` ${remaining} s` : ''}`
- return `${remaining} s`
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const remaining = seconds % 60;
+ if (hours > 0)
+ return `${hours} h ${minutes.toString().padStart(2, "0")} min${remaining ? ` ${remaining} s` : ""}`;
+ if (minutes > 0) return `${minutes} min${remaining ? ` ${remaining} s` : ""}`;
+ return `${remaining} s`;
}
-export const count = (value: number, singular: string, plural = `${singular}s`) => `${value} ${value === 1 ? singular : plural}`
+export const count = (
+ value: number,
+ singular: string,
+ plural = `${singular}s`,
+) => `${value} ${value === 1 ? singular : plural}`;
diff --git a/web/src/routes/AnalysisPage.test.tsx b/web/src/routes/AnalysisPage.test.tsx
index 5af08ac..c20be14 100644
--- a/web/src/routes/AnalysisPage.test.tsx
+++ b/web/src/routes/AnalysisPage.test.tsx
@@ -1,35 +1,196 @@
-import { fireEvent, render, screen, waitFor } from '@testing-library/react'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { AnalysisPage } from './AnalysisPage'
-import { analysisFixture } from '../api/analysis.test'
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { AnalysisPage } from "./AnalysisPage";
+import { analysisFixture } from "../api/analysis.test";
+import type { AnalysisSnapshot } from "../api/analysis";
-describe('AnalysisPage', () => {
+describe("AnalysisPage", () => {
beforeEach(() => {
- window.history.replaceState(null, '', '/analyse?section=invalid&period=broken&metric=unknown&exercise_id=nope')
- vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(analysisFixture), { status: 200 }))
- })
- afterEach(() => vi.restoreAllMocks())
+ document.documentElement.lang = "fr";
+ window.history.replaceState(
+ null,
+ "",
+ "/analyse?section=invalid&period=broken&metric=unknown&exercise_id=nope",
+ );
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(JSON.stringify(analysisFixture), { status: 200 }),
+ );
+ });
+ afterEach(() => vi.restoreAllMocks());
- it('falls back safely and changes sections, periods and metrics', async () => {
- render()
- expect(await screen.findByRole('heading', { name: 'Analyse' })).toBeInTheDocument()
- expect(screen.getByRole('tab', { name: 'Vue d’ensemble' })).toHaveAttribute('aria-selected', 'true')
- expect(screen.getByLabelText('Période')).toHaveValue('30d')
- fireEvent.change(screen.getByLabelText('Période'), { target: { value: '7d' } })
- await waitFor(() => expect(globalThis.fetch).toHaveBeenLastCalledWith(expect.stringContaining('period=7d'), expect.anything()))
- fireEvent.click(screen.getByRole('tab', { name: 'Mensurations' }))
- fireEvent.change(screen.getByLabelText('Mesure'), { target: { value: 'waist' } })
- expect(window.location.search).toContain('section=measurements')
- })
+ it("falls back safely and changes sections, periods and metrics", async () => {
+ render();
+ expect(
+ await screen.findByRole("heading", { name: "Analyse" }),
+ ).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: "Vue d’ensemble" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ expect(screen.getByLabelText("Période")).toHaveValue("30d");
+ fireEvent.change(screen.getByLabelText("Période"), {
+ target: { value: "7d" },
+ });
+ await waitFor(() =>
+ expect(globalThis.fetch).toHaveBeenLastCalledWith(
+ expect.stringContaining("period=7d"),
+ expect.anything(),
+ ),
+ );
+ fireEvent.click(screen.getByRole("tab", { name: "Mensurations" }));
+ fireEvent.change(screen.getByLabelText("Mesure"), {
+ target: { value: "waist" },
+ });
+ expect(window.location.search).toContain("section=measurements");
+ });
- it('supports exercise and BODY ZONE deep-link sections and English UI', async () => {
- window.history.replaceState(null, '', `/analyse?section=exercise&exercise_id=${analysisFixture.exercises[0].exercise_id}&period=30d`)
- render()
- expect(await screen.findByRole('heading', { name: 'Presse' })).toBeInTheDocument()
- fireEvent.click(screen.getByRole('tab', { name: 'Répartition' }))
- expect(screen.getByText(/pourcentages physiologiques/)).toBeInTheDocument()
- fireEvent.change(screen.getByLabelText('Langue'), { target: { value: 'en' } })
- expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
- expect(screen.getByText(/not physiological percentages/)).toBeInTheDocument()
- })
-})
+ it("supports exercise and BODY ZONE deep-link sections without duplicating the global language control", async () => {
+ window.history.replaceState(
+ null,
+ "",
+ `/analyse?section=exercise&exercise_id=${analysisFixture.exercises[0].exercise_id}&period=30d`,
+ );
+ render();
+ expect(
+ await screen.findByRole("heading", { name: "Presse" }),
+ ).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("tab", { name: "Répartition" }));
+ expect(screen.getByText(/pourcentages physiologiques/)).toBeInTheDocument();
+ expect(screen.queryByLabelText("Langue")).not.toBeInTheDocument();
+ expect(document.querySelector(".analysis-zone-track span")).toHaveStyle({
+ width: "100%",
+ });
+ expect(document.body).not.toHaveTextContent("%");
+ });
+
+ it("humanizes durations and hides set counts for a continuous exercise", async () => {
+ const continuous: AnalysisSnapshot = {
+ ...analysisFixture,
+ exercises: [
+ {
+ ...analysisFixture.exercises[0],
+ name: "Marche",
+ tracking_mode: "duration",
+ recording_mode: "continuous",
+ },
+ ],
+ exercise: {
+ ...analysisFixture.exercise!,
+ name: "Marche",
+ tracking_mode: "duration",
+ recording_mode: "continuous",
+ points: [
+ {
+ ...analysisFixture.exercise!.points[0],
+ sets: 0,
+ reps: null,
+ external_load_kg: null,
+ external_volume_kg: null,
+ explicit_max_kg: null,
+ continuous_duration_seconds: 900,
+ distance_km: 1.43,
+ speed_kmh: 6.4,
+ },
+ ],
+ },
+ };
+ vi.mocked(globalThis.fetch).mockResolvedValue(
+ new Response(JSON.stringify(continuous), { status: 200 }),
+ );
+ window.history.replaceState(
+ null,
+ "",
+ `/analyse?section=exercise&exercise_id=${continuous.exercises[0].exercise_id}&period=30d`,
+ );
+ render();
+ expect(
+ await screen.findByRole("heading", { name: "Marche" }),
+ ).toBeInTheDocument();
+ expect(screen.getByText("15 min")).toBeInTheDocument();
+ expect(screen.getByText("1,43 km")).toBeInTheDocument();
+ expect(screen.getByText("6,4 km/h")).toBeInTheDocument();
+ expect(screen.queryByText("Séries")).not.toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: "Durée" })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: "Distance" })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: "Vitesse" })).toBeInTheDocument();
+ expect(
+ screen.queryByRole("tab", { name: "Charge" }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByText(/Durée · min · 1 points réels/),
+ ).toBeInTheDocument();
+ });
+
+ it("offers only available loaded-repetition metrics and labels chart axes", async () => {
+ window.history.replaceState(
+ null,
+ "",
+ `/analyse?section=exercise&exercise_id=${analysisFixture.exercises[0].exercise_id}&period=30d`,
+ );
+ render();
+ expect(await screen.findByRole("tab", { name: "Charge" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ expect(
+ screen.getByRole("tab", { name: "Répétitions" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("tab", { name: "Volume externe" }),
+ ).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: "MAX" })).toBeInTheDocument();
+ expect(
+ screen.queryByRole("tab", { name: "Durée" }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByText(/Charge · kg · 1 points réels/),
+ ).toBeInTheDocument();
+ expect(document.querySelectorAll(".chart-x-label")).toHaveLength(1);
+ expect(document.querySelectorAll(".chart-y-label")).toHaveLength(2);
+ });
+
+ it("renders a neutral factual measurement delta with first and last dates", async () => {
+ const summaries = analysisFixture.measurements.summaries.map((summary) =>
+ summary.metric === "weight"
+ ? { ...summary, count: 2, first: 83.7, last: 85.1, delta: 1.4 }
+ : summary,
+ );
+ const measurements: AnalysisSnapshot = {
+ ...analysisFixture,
+ measurements: {
+ selected_metric: "weight",
+ unit: "kg",
+ summaries,
+ points: [
+ { timestamp: "2026-08-20T08:00:00Z", value: 83.7 },
+ { timestamp: "2026-09-20T08:00:00Z", value: 85.1 },
+ ],
+ },
+ };
+ vi.mocked(globalThis.fetch).mockResolvedValue(
+ new Response(JSON.stringify(measurements), { status: 200 }),
+ );
+ window.history.replaceState(
+ null,
+ "",
+ "/analyse?section=measurements&metric=weight&period=30d",
+ );
+ render();
+ const delta = await screen.findByText("+1,4 kg");
+ expect(delta.parentElement).toHaveClass("analysis-delta");
+ expect(screen.getByText(/Poids · kg · 2 points réels/)).toBeInTheDocument();
+ expect(document.querySelectorAll(".chart-x-label")).toHaveLength(2);
+ });
+
+ it("uses a factual sessions-by-date histogram in the overview", async () => {
+ render();
+ expect(await screen.findByText("Séances par date")).toBeInTheDocument();
+ expect(
+ screen.getByRole("img", { name: "Histogramme des séances par date" }),
+ ).toBeInTheDocument();
+ expect(document.querySelector(".activity-bar")).toHaveAttribute(
+ "title",
+ expect.stringContaining("2 séance(s)"),
+ );
+ });
+});
diff --git a/web/src/routes/AnalysisPage.tsx b/web/src/routes/AnalysisPage.tsx
index f5302db..4a93045 100644
--- a/web/src/routes/AnalysisPage.tsx
+++ b/web/src/routes/AnalysisPage.tsx
@@ -1,133 +1,877 @@
-import { useEffect, useMemo, useState } from 'react'
-import { ANALYSIS_PERIODS, MEASUREMENT_METRICS, fetchAnalysis, type AnalysisExercisePoint, type AnalysisPeriod, type AnalysisSnapshot, type MeasurementMetric } from '../api/analysis'
-import { BodyZoneFigure } from '../dashboard/BodyZoneFigure'
-import { analysisMessages, type AnalysisLanguage } from '../analysis/messages'
+import { useEffect, useMemo, useState } from "react";
+import {
+ ANALYSIS_PERIODS,
+ MEASUREMENT_METRICS,
+ fetchAnalysis,
+ type AnalysisExercise,
+ type AnalysisExercisePoint,
+ type AnalysisPeriod,
+ type AnalysisSnapshot,
+ type MeasurementMetric,
+} from "../api/analysis";
+import { analysisMessages, type AnalysisLanguage } from "../analysis/messages";
+import { BodyZoneFigure } from "../dashboard/BodyZoneFigure";
+import { formatDuration } from "../dashboard/dashboardFormat";
-type AnalysisSection = 'overview' | 'exercise' | 'body-zones' | 'measurements'
-const sections: readonly AnalysisSection[] = ['overview', 'exercise', 'body-zones', 'measurements']
-const exerciseIdPattern = /^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
-
-const metricLabels = {
- fr: { weight: 'Poids', neck: 'Cou', shoulders: 'Épaules', chest: 'Poitrine', waist: 'Taille', hips: 'Hanches', left_arm: 'Bras gauche', right_arm: 'Bras droit', left_forearm: 'Avant-bras gauche', right_forearm: 'Avant-bras droit', left_thigh: 'Cuisse gauche', right_thigh: 'Cuisse droite', left_calf: 'Mollet gauche', right_calf: 'Mollet droit' },
- en: { weight: 'Weight', neck: 'Neck', shoulders: 'Shoulders', chest: 'Chest', waist: 'Waist', hips: 'Hips', left_arm: 'Left arm', right_arm: 'Right arm', left_forearm: 'Left forearm', right_forearm: 'Right forearm', left_thigh: 'Left thigh', right_thigh: 'Right thigh', left_calf: 'Left calf', right_calf: 'Right calf' },
-} satisfies Record>
-
-const zoneLabelsEn: Readonly> = {
- full_body: 'Full body', upper_body: 'Upper body', chest: 'Chest', back: 'Back',
- shoulders: 'Shoulders', arms: 'Arms', core: 'Core', lower_body: 'Lower body',
- glutes: 'Glutes', thighs: 'Thighs', calves: 'Calves',
+type AnalysisSection = "overview" | "exercise" | "body-zones" | "measurements";
+type ExerciseMetric =
+ "duration" | "distance" | "speed" | "load" | "reps" | "volume" | "max";
+interface ChartPoint {
+ timestamp: string;
+ value: number;
}
+interface ExerciseMetricDefinition {
+ id: ExerciseMetric;
+ label: string;
+ unit: string;
+ value: (point: AnalysisExercisePoint) => number | null;
+ display: (value: number) => string;
+}
+
+const sections: readonly AnalysisSection[] = [
+ "overview",
+ "exercise",
+ "body-zones",
+ "measurements",
+];
+const exerciseIdPattern =
+ /^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+const metricLabels = {
+ fr: {
+ weight: "Poids",
+ neck: "Cou",
+ shoulders: "Épaules",
+ chest: "Poitrine",
+ waist: "Taille",
+ hips: "Hanches",
+ left_arm: "Bras gauche",
+ right_arm: "Bras droit",
+ left_forearm: "Avant-bras gauche",
+ right_forearm: "Avant-bras droit",
+ left_thigh: "Cuisse gauche",
+ right_thigh: "Cuisse droite",
+ left_calf: "Mollet gauche",
+ right_calf: "Mollet droit",
+ },
+ en: {
+ weight: "Weight",
+ neck: "Neck",
+ shoulders: "Shoulders",
+ chest: "Chest",
+ waist: "Waist",
+ hips: "Hips",
+ left_arm: "Left arm",
+ right_arm: "Right arm",
+ left_forearm: "Left forearm",
+ right_forearm: "Right forearm",
+ left_thigh: "Left thigh",
+ right_thigh: "Right thigh",
+ left_calf: "Left calf",
+ right_calf: "Right calf",
+ },
+} satisfies Record>;
+const zoneLabelsEn: Readonly> = {
+ full_body: "Full body",
+ upper_body: "Upper body",
+ chest: "Chest",
+ back: "Back",
+ shoulders: "Shoulders",
+ arms: "Arms",
+ core: "Core",
+ lower_body: "Lower body",
+ glutes: "Glutes",
+ thighs: "Thighs",
+ calves: "Calves",
+};
function safeInitialQuery() {
- const query = new URLSearchParams(window.location.search)
- const sectionValue = query.get('section')
- const periodValue = query.get('period')
- const metricValue = query.get('metric')
- const exerciseValue = query.get('exercise_id')
+ const query = new URLSearchParams(window.location.search);
+ const sectionValue = query.get("section");
+ const periodValue = query.get("period");
+ const metricValue = query.get("metric");
+ const exerciseValue = query.get("exercise_id");
return {
- section: sections.includes(sectionValue as AnalysisSection) ? sectionValue as AnalysisSection : 'overview',
- period: ANALYSIS_PERIODS.includes(periodValue as AnalysisPeriod) ? periodValue as AnalysisPeriod : '30d',
- metric: MEASUREMENT_METRICS.includes(metricValue as MeasurementMetric) ? metricValue as MeasurementMetric : 'weight',
- exerciseId: exerciseValue !== null && exerciseIdPattern.test(exerciseValue) ? exerciseValue : undefined,
- }
+ section: sections.includes(sectionValue as AnalysisSection)
+ ? (sectionValue as AnalysisSection)
+ : "overview",
+ period: ANALYSIS_PERIODS.includes(periodValue as AnalysisPeriod)
+ ? (periodValue as AnalysisPeriod)
+ : "30d",
+ metric: MEASUREMENT_METRICS.includes(metricValue as MeasurementMetric)
+ ? (metricValue as MeasurementMetric)
+ : "weight",
+ exerciseId:
+ exerciseValue !== null && exerciseIdPattern.test(exerciseValue)
+ ? exerciseValue
+ : undefined,
+ };
}
-function formatNumber(value: number, language: AnalysisLanguage, maximumFractionDigits = 2) {
- return new Intl.NumberFormat(language === 'fr' ? 'fr-FR' : 'en-US', { maximumFractionDigits }).format(value)
+const analysisLanguage = (): AnalysisLanguage =>
+ document.documentElement.lang.toLowerCase().startsWith("en") ? "en" : "fr";
+const locale = (language: AnalysisLanguage) =>
+ language === "fr" ? "fr-FR" : "en-US";
+const formatNumber = (
+ value: number,
+ language: AnalysisLanguage,
+ maximumFractionDigits = 2,
+) =>
+ new Intl.NumberFormat(locale(language), { maximumFractionDigits }).format(
+ value,
+ );
+const shortDate = (value: string, language: AnalysisLanguage) =>
+ new Intl.DateTimeFormat(locale(language), {
+ day: "2-digit",
+ month: "short",
+ }).format(new Date(value));
+
+function exerciseMetrics(
+ exercise: AnalysisExercise,
+ language: AnalysisLanguage,
+): ExerciseMetricDefinition[] {
+ const labels =
+ language === "fr"
+ ? {
+ duration: "Durée",
+ distance: "Distance",
+ speed: "Vitesse",
+ load: "Charge",
+ reps: "Répétitions",
+ volume: "Volume externe",
+ max: "MAX",
+ }
+ : {
+ duration: "Duration",
+ distance: "Distance",
+ speed: "Speed",
+ load: "Load",
+ reps: "Repetitions",
+ volume: "External volume",
+ max: "MAX",
+ };
+ const candidates: ExerciseMetricDefinition[] =
+ exercise.tracking_mode === "duration"
+ ? [
+ {
+ id: "duration",
+ label: labels.duration,
+ unit: "min",
+ value: (point) =>
+ point.continuous_duration_seconds ?? point.set_duration_seconds,
+ display: formatDuration,
+ },
+ {
+ id: "distance",
+ label: labels.distance,
+ unit: "km",
+ value: (point) => point.distance_km,
+ display: (value) => `${formatNumber(value, language)} km`,
+ },
+ {
+ id: "speed",
+ label: labels.speed,
+ unit: "km/h",
+ value: (point) => point.speed_kmh,
+ display: (value) => `${formatNumber(value, language)} km/h`,
+ },
+ ]
+ : [
+ {
+ id: "load",
+ label: labels.load,
+ unit: "kg",
+ value: (point) => point.external_load_kg,
+ display: (value) => `${formatNumber(value, language)} kg`,
+ },
+ {
+ id: "reps",
+ label: labels.reps,
+ unit: language === "fr" ? "répétitions" : "repetitions",
+ value: (point) => point.reps,
+ display: (value) => formatNumber(value, language),
+ },
+ {
+ id: "volume",
+ label: labels.volume,
+ unit: "kg",
+ value: (point) => point.external_volume_kg,
+ display: (value) => `${formatNumber(value, language)} kg`,
+ },
+ {
+ id: "max",
+ label: labels.max,
+ unit: "kg",
+ value: (point) => point.explicit_max_kg,
+ display: (value) => `${formatNumber(value, language)} kg`,
+ },
+ ];
+ return candidates.filter((metric) =>
+ exercise.points.some((point) => metric.value(point) !== null),
+ );
}
-function duration(value: number, language: AnalysisLanguage) {
- const hours = Math.floor(value / 3600)
- const minutes = Math.floor((value % 3600) / 60)
- return hours > 0 ? `${hours} h ${minutes.toString().padStart(2, '0')} min` : `${minutes} min${language === 'en' ? '' : ''}`
+function chartPoints(
+ exercise: AnalysisExercise,
+ metric: ExerciseMetricDefinition,
+): ChartPoint[] {
+ return exercise.points.flatMap((point) => {
+ const value = metric.value(point);
+ return value === null
+ ? []
+ : [
+ {
+ timestamp: point.timestamp,
+ value: metric.id === "duration" ? value / 60 : value,
+ },
+ ];
+ });
}
-function MiniLineChart({ points, unit, language, label }: { points: Array<{ timestamp: string; value: number }>; unit: string; language: AnalysisLanguage; label: string }) {
- if (points.length === 0) return null
- const ordered = [...points].reverse()
- const values = ordered.map((point) => point.value)
- const minimum = Math.min(...values)
- const maximum = Math.max(...values)
- const span = maximum - minimum || 1
- const polyline = ordered.map((point, index) => `${ordered.length === 1 ? 50 : (index / (ordered.length - 1)) * 100},${36 - ((point.value - minimum) / span) * 30}`).join(' ')
- return {formatNumber(minimum, language)}–{formatNumber(maximum, language)} {unit} · {points.length} points · no interpolation
+export function AnalysisLineChart({
+ points,
+ unit,
+ language,
+ label,
+ displayValue,
+}: {
+ points: ChartPoint[];
+ unit: string;
+ language: AnalysisLanguage;
+ label: string;
+ displayValue?: (value: number) => string;
+}) {
+ if (points.length === 0) return null;
+ const ordered = [...points].sort(
+ (left, right) => Date.parse(left.timestamp) - Date.parse(right.timestamp),
+ );
+ const values = ordered.map((point) => point.value);
+ const minimum = Math.min(...values);
+ const maximum = Math.max(...values);
+ const span = maximum - minimum || Math.max(Math.abs(maximum) * 0.1, 1);
+ const low = minimum - span * 0.12;
+ const high = maximum + span * 0.12;
+ const scaleX = (index: number) =>
+ ordered.length === 1 ? 55 : 10 + (index / (ordered.length - 1)) * 96;
+ const scaleY = (value: number) => 62 - ((value - low) / (high - low)) * 50;
+ const polyline = ordered
+ .map((point, index) => `${scaleX(index)},${scaleY(point.value)}`)
+ .join(" ");
+ const formatValue =
+ displayValue ??
+ ((value: number) => `${formatNumber(value, language)} ${unit}`);
+ return (
+
+
+ {label}
+ {unit}
+
+
+
+ {label} · {unit} · {points.length}{" "}
+ {language === "fr"
+ ? "points réels, sans interpolation"
+ : "real points, no interpolation"}
+
+
+ );
}
-function ExerciseFacts({ points, language }: { points: AnalysisExercisePoint[]; language: AnalysisLanguage }) {
- const t = analysisMessages[language]
- if (points.length === 0) return {t.noExerciseData}
- const latest = points[0]
- return <>
-
-
- {latest.reps !== null && }
- {latest.external_load_kg !== null && }
- {latest.external_volume_kg !== null && }
- {(latest.set_duration_seconds ?? latest.continuous_duration_seconds) !== null && }
- {latest.distance_km !== null && }
- {latest.speed_kmh !== null && }
- {latest.explicit_max_kg !== null && }
- {latest.load_mode === 'none' && latest.reps !== null && }
-
- ({ timestamp: point.timestamp, value: point.explicit_max_kg ?? point.external_volume_kg ?? point.reps ?? point.continuous_duration_seconds ?? point.set_duration_seconds ?? 0 })).filter((point) => point.value > 0)} unit={latest.explicit_max_kg !== null || latest.external_volume_kg !== null ? 'kg' : latest.reps !== null ? 'reps' : 's'} language={language} label={t.exercise} />
- >
+function ActivityHistogram({
+ points,
+ language,
+}: {
+ points: AnalysisSnapshot["activity"];
+ language: AnalysisLanguage;
+}) {
+ if (points.length === 0) return null;
+ const ordered = [...points].sort((left, right) =>
+ left.date.localeCompare(right.date),
+ );
+ const maximum = Math.max(...ordered.map((point) => point.sessions), 1);
+ return (
+
+
+
+ {language === "fr" ? "Séances par date" : "Sessions by date"}
+
+ {language === "fr" ? "séances" : "sessions"}
+
+
+ {ordered.map((point, index) => (
+
+ {point.sessions}
+
+ {(ordered.length <= 10 ||
+ index === 0 ||
+ index === ordered.length - 1) && (
+
+ )}
+
+ ))}
+
+
+ );
}
-function Fact({ label, value, unit, language }: { label: string; value: number; unit?: string; language: AnalysisLanguage }) {
- return {label}{formatNumber(value, language)}{unit ? ` ${unit}` : ''}
+function ExerciseFacts({
+ exercise,
+ language,
+ selectedMetric,
+ onMetricChange,
+ detailed = false,
+}: {
+ exercise: AnalysisExercise;
+ language: AnalysisLanguage;
+ selectedMetric: ExerciseMetric | null;
+ onMetricChange: (metric: ExerciseMetric) => void;
+ detailed?: boolean;
+}) {
+ const t = analysisMessages[language];
+ if (exercise.points.length === 0)
+ return {t.noExerciseData}
;
+ const latest = exercise.points[0];
+ const metrics = exerciseMetrics(exercise, language);
+ const activeMetric =
+ metrics.find((metric) => metric.id === selectedMetric) ?? metrics[0];
+ return (
+ <>
+
+ {exercise.recording_mode === "sets" && (
+
+ )}
+ {latest.reps !== null && (
+
+ )}
+ {latest.external_load_kg !== null && (
+
+ )}
+ {latest.external_volume_kg !== null && (
+
+ )}
+ {(latest.set_duration_seconds ?? latest.continuous_duration_seconds) !==
+ null && (
+
+ )}
+ {latest.distance_km !== null && (
+
+ )}
+ {latest.speed_kmh !== null && (
+
+ )}
+ {latest.explicit_max_kg !== null && (
+
+ )}
+ {latest.load_mode === "none" && latest.reps !== null && (
+
+ )}
+
+ {activeMetric && (
+ <>
+ {detailed && metrics.length > 1 && (
+
+ {metrics.map((item) => (
+
+ ))}
+
+ )}
+
+ >
+ )}
+ >
+ );
+}
+
+function Fact({ label, value }: { label: string; value: string }) {
+ return (
+
+
{label}
+ {value}
+
+ );
}
export function AnalysisPage() {
- const initial = useMemo(safeInitialQuery, [])
- const [section, setSection] = useState(initial.section)
- const [period, setPeriod] = useState(initial.period)
- const [metric, setMetric] = useState(initial.metric)
- const [exerciseId, setExerciseId] = useState(initial.exerciseId)
- const [language, setLanguage] = useState('fr')
- const [snapshot, setSnapshot] = useState(null)
- const [pending, setPending] = useState(true)
- const [failed, setFailed] = useState(false)
- const t = analysisMessages[language]
+ const initial = useMemo(safeInitialQuery, []);
+ const language = analysisLanguage();
+ const [section, setSection] = useState(initial.section);
+ const [period, setPeriod] = useState(initial.period);
+ const [metric, setMetric] = useState(initial.metric);
+ const [exerciseId, setExerciseId] = useState(
+ initial.exerciseId,
+ );
+ const [exerciseMetric, setExerciseMetric] = useState(
+ null,
+ );
+ const [snapshot, setSnapshot] = useState(null);
+ const [pending, setPending] = useState(true);
+ const [failed, setFailed] = useState(false);
+ const t = analysisMessages[language];
useEffect(() => {
- const controller = new AbortController()
- setPending(true)
- fetchAnalysis({ period, exerciseId, metric }, controller.signal).then((value) => {
- setSnapshot(value); setFailed(false)
- if (exerciseId === undefined && value.exercise !== null) setExerciseId(value.exercise.exercise_id)
- }).catch(() => { if (!controller.signal.aborted) setFailed(true) }).finally(() => { if (!controller.signal.aborted) setPending(false) })
- return () => controller.abort()
- }, [period, exerciseId, metric])
+ const controller = new AbortController();
+ setPending(true);
+ fetchAnalysis({ period, exerciseId, metric }, controller.signal)
+ .then((value) => {
+ setSnapshot(value);
+ setFailed(false);
+ if (exerciseId === undefined && value.exercise !== null)
+ setExerciseId(value.exercise.exercise_id);
+ })
+ .catch(() => {
+ if (!controller.signal.aborted) setFailed(true);
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) setPending(false);
+ });
+ return () => controller.abort();
+ }, [period, exerciseId, metric]);
- function updateUrl(nextSection = section, nextPeriod = period, nextExercise = exerciseId, nextMetric = metric) {
- const query = new URLSearchParams({ section: nextSection, period: nextPeriod })
- if (nextExercise) query.set('exercise_id', nextExercise)
- if (nextMetric !== 'weight' || nextSection === 'measurements') query.set('metric', nextMetric)
- window.history.replaceState(null, '', `/analyse?${query}`)
+ function updateUrl(
+ nextSection = section,
+ nextPeriod = period,
+ nextExercise = exerciseId,
+ nextMetric = metric,
+ ) {
+ const query = new URLSearchParams({
+ section: nextSection,
+ period: nextPeriod,
+ });
+ if (nextExercise) query.set("exercise_id", nextExercise);
+ if (nextMetric !== "weight" || nextSection === "measurements")
+ query.set("metric", nextMetric);
+ window.history.replaceState(null, "", `/analyse?${query}`);
+ }
+ function chooseSection(value: AnalysisSection) {
+ setSection(value);
+ updateUrl(value);
+ }
+ function choosePeriod(value: AnalysisPeriod) {
+ setPeriod(value);
+ updateUrl(section, value);
+ }
+ function chooseExercise(value: string) {
+ setExerciseId(value);
+ setExerciseMetric(null);
+ updateUrl("exercise", period, value);
+ setSection("exercise");
+ }
+ function chooseMetric(value: MeasurementMetric) {
+ setMetric(value);
+ updateUrl("measurements", period, exerciseId, value);
+ setSection("measurements");
}
- function chooseSection(value: AnalysisSection) { setSection(value); updateUrl(value) }
- function choosePeriod(value: AnalysisPeriod) { setPeriod(value); updateUrl(section, value) }
- function chooseExercise(value: string) { setExerciseId(value); updateUrl('exercise', period, value); setSection('exercise') }
- function chooseMetric(value: MeasurementMetric) { setMetric(value); updateUrl('measurements', period, exerciseId, value); setSection('measurements') }
- const selectedSummary = snapshot?.measurements.summaries.find((item) => item.metric === metric)
- const zoneFigure = snapshot?.body_zones.map((zone) => ({ zone_id: zone.zone_id, label: language === 'en' ? zoneLabelsEn[zone.zone_id] ?? zone.zone_id : zone.label, session_count: zone.exposures, occurrence_count: zone.exposures, set_count: zone.associated_sets })) ?? []
- return
-
- {sections.map((id) => )}
- {pending && snapshot === null && {t.loading}
}
- {failed && snapshot === null && {t.failed}
}
- {snapshot !== null && <>
- {snapshot.meta.partial && {t.partial}
}
- {section === 'overview' &&
-
{t.overview}
{snapshot.overview.duration_seconds !== null && - {t.duration}
- {duration(snapshot.overview.duration_seconds, language)}
}
{snapshot.overview.sessions === 0 && {t.noData}
} ({ timestamp: `${day.date}T00:00:00Z`, value: day.sessions }))} unit={t.sessions.toLowerCase()} language={language} label={t.sessions} />
-
{t.activeProgram}
{snapshot.active_program === null ? {t.noProgram}
: <>{snapshot.active_program.title}
{snapshot.active_program.completed_sessions}/{snapshot.active_program.total_sessions}
{snapshot.active_program.next_session_title && {t.nextSession}: {snapshot.active_program.next_session_title}
}Programmes>}
-
{t.distribution}
{zoneFigure.length === 0 ? {t.noData}
: }
-
{t.exercises}
{snapshot.exercise === null ? {t.noExercise}
: <>{snapshot.exercise.name}
>}
-
}
- {section === 'exercise' && {snapshot.exercise === null ? {t.noExercise}
: <>{snapshot.exercise.name}
>}}
- {section === 'body-zones' && {t.exposures}
{t.zonesHelp}
{zoneFigure.length === 0 ? {t.noData}
: {snapshot.body_zones.map((zone) => - {language === 'en' ? zoneLabelsEn[zone.zone_id] ?? zone.zone_id : zone.label}{zone.exposures} {t.exposures.toLowerCase()} · {zone.associated_sets} {t.sets.toLowerCase()}
)}
}}
- {section === 'measurements' && {metricLabels[language][metric]}
{selectedSummary === undefined || selectedSummary.count === 0 ? {t.noData}
: <>- {t.first}
- {selectedSummary.first === null ? '—' : `${formatNumber(selectedSummary.first, language)} ${selectedSummary.unit}`}
- {t.last}
- {selectedSummary.last === null ? '—' : `${formatNumber(selectedSummary.last, language)} ${selectedSummary.unit}`}
- {t.delta}
- {selectedSummary.delta === null ? '—' : `${selectedSummary.delta > 0 ? '+' : ''}${formatNumber(selectedSummary.delta, language)} ${selectedSummary.unit}`}
{selectedSummary.count === 1 && {t.onePoint}
}>}}
- >}
-
+ const selectedSummary = snapshot?.measurements.summaries.find(
+ (item) => item.metric === metric,
+ );
+ const zoneFigure =
+ snapshot?.body_zones.map((zone) => ({
+ zone_id: zone.zone_id,
+ label:
+ language === "en"
+ ? (zoneLabelsEn[zone.zone_id] ?? zone.zone_id)
+ : zone.label,
+ session_count: zone.exposures,
+ occurrence_count: zone.exposures,
+ set_count: zone.associated_sets,
+ })) ?? [];
+ const maximumExposure = Math.max(
+ 1,
+ ...(snapshot?.body_zones.map((zone) => zone.exposures) ?? []),
+ );
+ return (
+
+
+
+
{t.eyebrow}
+
{t.title}
+
+
+
+
+ {sections.map((id) => (
+
+ ))}
+
+
+
+ {pending && snapshot === null && {t.loading}
}
+ {failed && snapshot === null && (
+
+ {t.failed}
+
+ )}
+ {snapshot !== null && (
+ <>
+ {snapshot.meta.partial && (
+ {t.partial}
+ )}
+ {section === "overview" && (
+
+
+ {t.overview}
+
+
+
+ {snapshot.overview.duration_seconds !== null && (
+
+ )}
+
+ {snapshot.overview.sessions === 0 && (
+ {t.noData}
+ )}
+
+
+
+ {t.activeProgram}
+ {snapshot.active_program === null ? (
+ {t.noProgram}
+ ) : (
+
+
{snapshot.active_program.title}
+
+
+ {snapshot.active_program.completed_sessions}
+ {" "}
+ {language === "fr"
+ ? `sur ${snapshot.active_program.total_sessions} séances`
+ : `of ${snapshot.active_program.total_sessions} sessions`}
+
+
+ {snapshot.active_program.next_session_title && (
+
+ {t.nextSession}
+
+ {snapshot.active_program.next_session_title}
+
+
+ )}
+
+ Programmes
+
+
+ )}
+
+
+ {t.distribution}
+ {zoneFigure.length === 0 ? (
+ {t.noData}
+ ) : (
+
+ )}
+
+
+
+ {t.exercises}
+ {snapshot.exercise === null ? (
+ {t.noExercise}
+ ) : (
+ <>
+ {snapshot.exercise.name}
+
+ >
+ )}
+
+
+ )}
+ {section === "exercise" && (
+
+
+ {snapshot.exercise === null ? (
+ {t.noExercise}
+ ) : (
+ <>
+ {snapshot.exercise.name}
+
+ >
+ )}
+
+ )}
+ {section === "body-zones" && (
+
+ {t.exposures}
+ {t.zonesHelp}
+ {zoneFigure.length === 0 ? (
+ {t.noData}
+ ) : (
+
+ )}
+
+ )}
+ {section === "measurements" && (
+
+
+ {metricLabels[language][metric]}
+ {selectedSummary === undefined || selectedSummary.count === 0 ? (
+ {t.noData}
+ ) : (
+ <>
+
+
+
+
+
- {t.delta}
+ -
+ {selectedSummary.delta === null
+ ? "—"
+ : `${selectedSummary.delta > 0 ? "+" : ""}${formatNumber(selectedSummary.delta, language)} ${selectedSummary.unit}`}
+
+
+
+ {selectedSummary.count === 1 && (
+ {t.onePoint}
+ )}
+
+ >
+ )}
+
+ )}
+ >
+ )}
+
+ );
}
diff --git a/web/src/theme/app.css b/web/src/theme/app.css
index 96ff4db..943385c 100644
--- a/web/src/theme/app.css
+++ b/web/src/theme/app.css
@@ -8,12 +8,30 @@
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto 1fr;
background:
- radial-gradient(circle at 92% 12%, rgb(180 190 254 / 7%), transparent 25rem),
+ radial-gradient(
+ circle at 92% 12%,
+ rgb(180 190 254 / 7%),
+ transparent 25rem
+ ),
var(--crust);
}
-.skip-link { position: fixed; z-index: 20; top: .75rem; left: .75rem; padding: .65rem .85rem; color: var(--crust); background: var(--lavender); border-radius: .45rem; font-weight: 700; text-decoration: none; transform: translateY(-200%); }
-.skip-link:focus { transform: translateY(0); }
+.skip-link {
+ position: fixed;
+ z-index: 20;
+ top: 0.75rem;
+ left: 0.75rem;
+ padding: 0.65rem 0.85rem;
+ color: var(--crust);
+ background: var(--lavender);
+ border-radius: 0.45rem;
+ font-weight: 700;
+ text-decoration: none;
+ transform: translateY(-200%);
+}
+.skip-link:focus {
+ transform: translateY(0);
+}
.app-header {
min-width: 0;
@@ -21,7 +39,7 @@
top: 0;
z-index: 10;
min-height: 4.5rem;
- padding: .75rem var(--shell-gutter);
+ padding: 0.75rem var(--shell-gutter);
display: flex;
align-items: center;
gap: clamp(1.5rem, 4vw, 5rem);
@@ -30,109 +48,737 @@
backdrop-filter: blur(16px);
}
-.brand { display: flex; align-items: center; gap: .7rem; color: var(--text); text-decoration: none; font-size: 1.02rem; font-weight: 850; letter-spacing: .15em; text-shadow: 0 0 1.4rem rgb(180 190 254 / 24%); }
-.brand-mark { width: 2.1rem; height: 2.1rem; display: grid; place-items: center; color: var(--crust); background: var(--lavender); border: 1px solid rgb(255 255 255 / 28%); border-radius: .58rem; box-shadow: 0 0 .9rem rgb(180 190 254 / 18%); font: 750 var(--font-size-xs) var(--font-mono); letter-spacing: -.04em; }
-.main-nav { min-width: 0; display: flex; align-items: center; gap: .25rem; overflow-x: auto; scrollbar-width: none; }
-.main-nav::-webkit-scrollbar { display: none; }
-.nav-link { position: relative; padding: .7rem .85rem; color: var(--subtext-0); text-decoration: none; font-size: .91rem; font-weight: 570; white-space: nowrap; transition: color 140ms ease, background 140ms ease; border-radius: .55rem; }
-.nav-link:hover { color: var(--text); background: rgb(49 50 68 / 48%); }
-.nav-link.is-active { color: var(--lavender); }
-.nav-link.is-active::after { content: ""; position: absolute; left: .85rem; right: .85rem; bottom: .22rem; height: 2px; background: var(--lavender); border-radius: 2px; }
-.sync-control { margin-left: auto; display: flex; align-items: center; gap: .6rem; position: relative; }
-.sync-control button { border: 1px solid var(--lavender); border-radius: .55rem; padding: .55rem .8rem; background: transparent; color: var(--text); cursor: pointer; }
-.sync-control button:focus-visible { outline: 3px solid var(--lavender); outline-offset: 2px; }
-.sync-control button:disabled { cursor: not-allowed; opacity: .55; }
-.sync-details { max-width: 28rem; color: var(--subtext-0); }
-.sync-details summary { cursor: pointer; }
-.sync-details[open] { position: absolute; z-index: 20; right: 0; top: 2.8rem; min-width: 20rem; padding: .8rem; border: 1px solid var(--surface-2); border-radius: .7rem; background: var(--base); box-shadow: 0 .5rem 2rem #0008; }
-.sync-details h2 { font-size: 1rem; }
-.sync-details ul { padding-left: 1.2rem; }
-.display-preferences { position: relative; margin-left: auto; }
-.preferences-trigger { min-height: 2.5rem; padding: .55rem .75rem; color: var(--subtext-0); background: transparent; border: 1px solid var(--surface-2); border-radius: .55rem; cursor: pointer; white-space: nowrap; }
-.preferences-trigger:focus-visible { outline: 3px solid var(--lavender); outline-offset: 2px; }
-.preferences-dialog { position: absolute; z-index: 30; top: 3rem; right: 0; width: min(24rem, calc(100vw - 2rem)); padding: 1rem; color: var(--text); background: var(--base); border: 1px solid var(--surface-2); border-radius: .75rem; box-shadow: 0 .75rem 2.5rem #0009; }
-.preferences-heading { display: flex; align-items: start; justify-content: space-between; gap: 1rem; }
-.preferences-heading h2 { margin-bottom: .75rem; font-size: 1rem; }
-.preferences-dialog fieldset { display: grid; gap: .65rem; border: 0; padding: 0; }
-.preferences-dialog legend { margin-bottom: .55rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
-.preferences-dialog label { display: flex; align-items: center; gap: .55rem; }
-.preference-example { margin: .85rem 0 0; color: var(--overlay-1); font-size: var(--font-size-xs); }
-
-.app-main { width: 100%; min-width: 0; padding: clamp(2.25rem, 5vw, 5rem) var(--shell-gutter) clamp(3rem, 7vw, 7rem); }
-.page { width: min(100%, 92rem); min-width: 0; margin: 0 auto; }
-.page-heading { margin-bottom: clamp(2rem, 4vw, 3.5rem); display: flex; justify-content: space-between; align-items: end; gap: 2rem; }
-.eyebrow { margin: 0 0 .5rem; color: var(--lavender); font: 700 var(--font-size-xs)/1.25 var(--font-mono); letter-spacing: .12em; text-transform: uppercase; }
-h1, h2, p { margin-top: 0; }
-h1 { margin-bottom: 0; font-size: var(--font-size-display); line-height: .96; letter-spacing: -.055em; font-weight: 720; }
-h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; letter-spacing: -.015em; font-weight: 680; }
-.page-intro { max-width: 34rem; margin-bottom: .15rem; color: var(--subtext-0); font-size: clamp(.95rem, 1.4vw, 1.08rem); line-height: 1.65; }
-.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
-
-.analysis-toolbar { margin-bottom: 1.5rem; display: flex; align-items: end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
-.analysis-tabs { display: flex; flex-wrap: wrap; gap: .35rem; }
-.analysis-tabs button { min-height: 2.6rem; padding: .55rem .8rem; color: var(--subtext-0); background: transparent; border: 1px solid var(--surface-2); border-radius: .45rem; cursor: pointer; }
-.analysis-tabs button[aria-selected="true"] { color: var(--crust); background: var(--lavender); border-color: var(--lavender); font-weight: 700; }
-.analysis-toolbar label, .analysis-language, .analysis-picker { display: grid; gap: .35rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
-.analysis-toolbar select, .analysis-language select, .analysis-picker select { min-height: 2.6rem; padding: .55rem .7rem; color: var(--text); background: var(--mantle); border: 1px solid var(--surface-2); border-radius: .45rem; }
-.analysis-section-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
-.analysis-panel { min-width: 0; padding: clamp(1rem, 3vw, 1.75rem); border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); overflow: hidden; }
-.analysis-panel h3 { margin-bottom: .6rem; }
-.analysis-wide { width: 100%; }
-.analysis-facts { margin: 1rem 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); gap: .75rem; }
-.analysis-facts div { min-width: 0; padding: .75rem; background: var(--base); border: 1px solid var(--surface-1); border-radius: .55rem; }
-.analysis-facts dt { color: var(--subtext-0); font-size: var(--font-size-xs); }
-.analysis-facts dd { margin: .3rem 0 0; overflow-wrap: anywhere; font-size: 1.1rem; font-weight: 700; }
-.analysis-empty, .analysis-note { color: var(--subtext-0); line-height: 1.55; }
-.analysis-chart { min-width: 0; margin: 1rem 0 0; }
-.analysis-chart svg { width: 100%; height: clamp(10rem, 25vw, 16rem); overflow: visible; }
-.analysis-chart polyline { stroke: var(--lavender); stroke-width: 2; }
-.analysis-chart circle { fill: var(--green); stroke: var(--base); stroke-width: .5; }
-.analysis-chart figcaption { color: var(--subtext-0); font-size: var(--font-size-xs); }
-.analysis-zones { display: grid; grid-template-columns: minmax(14rem, .8fr) minmax(16rem, 1.2fr); align-items: start; gap: 1.5rem; }
-.analysis-zones ul { margin: 0; padding: 0; display: grid; gap: .5rem; list-style: none; }
-.analysis-zones li { padding: .75rem; display: flex; justify-content: space-between; gap: 1rem; border-bottom: 1px solid var(--surface-1); }
-.analysis-zones li span { color: var(--subtext-0); text-align: right; }
-.analysis-link, .tile-context-link { width: max-content; max-width: 100%; color: var(--lavender); background: transparent; border: 0; padding: .35rem 0; text-decoration: underline; text-underline-offset: .2rem; cursor: pointer; }
-.measurement-summary dd { display: grid; gap: .2rem; }
-.measurement-summary small { color: var(--subtext-0); font-weight: 500; }
-.program-progress p { margin: .25rem 0; color: var(--subtext-0); }
-
-@media (max-width: 48rem) {
- .analysis-section-grid, .analysis-zones { grid-template-columns: 1fr; }
- .analysis-page .page-heading { align-items: start; flex-direction: column; }
- .analysis-toolbar { align-items: stretch; flex-direction: column; }
- .analysis-toolbar label { width: 100%; }
- .analysis-tabs button { flex: 1 1 8rem; }
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 0.7rem;
+ color: var(--text);
+ text-decoration: none;
+ font-size: 1.02rem;
+ font-weight: 850;
+ letter-spacing: 0.15em;
+ text-shadow: 0 0 1.4rem rgb(180 190 254 / 24%);
+}
+.brand-mark {
+ width: 2.1rem;
+ height: 2.1rem;
+ display: grid;
+ place-items: center;
+ color: var(--crust);
+ background: var(--lavender);
+ border: 1px solid rgb(255 255 255 / 28%);
+ border-radius: 0.58rem;
+ box-shadow: 0 0 0.9rem rgb(180 190 254 / 18%);
+ font: 750 var(--font-size-xs) var(--font-mono);
+ letter-spacing: -0.04em;
+}
+.main-nav {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ overflow-x: auto;
+ scrollbar-width: none;
+}
+.main-nav::-webkit-scrollbar {
+ display: none;
+}
+.nav-link {
+ position: relative;
+ padding: 0.7rem 0.85rem;
+ color: var(--subtext-0);
+ text-decoration: none;
+ font-size: 0.91rem;
+ font-weight: 570;
+ white-space: nowrap;
+ transition:
+ color 140ms ease,
+ background 140ms ease;
+ border-radius: 0.55rem;
+}
+.nav-link:hover {
+ color: var(--text);
+ background: rgb(49 50 68 / 48%);
+}
+.nav-link.is-active {
+ color: var(--lavender);
+}
+.nav-link.is-active::after {
+ content: "";
+ position: absolute;
+ left: 0.85rem;
+ right: 0.85rem;
+ bottom: 0.22rem;
+ height: 2px;
+ background: var(--lavender);
+ border-radius: 2px;
+}
+.sync-control {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ position: relative;
+}
+.sync-control button {
+ border: 1px solid var(--lavender);
+ border-radius: 0.55rem;
+ padding: 0.55rem 0.8rem;
+ background: transparent;
+ color: var(--text);
+ cursor: pointer;
+}
+.sync-control button:focus-visible {
+ outline: 3px solid var(--lavender);
+ outline-offset: 2px;
+}
+.sync-control button:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+.sync-details {
+ max-width: 28rem;
+ color: var(--subtext-0);
+}
+.sync-details summary {
+ cursor: pointer;
+}
+.sync-details[open] {
+ position: absolute;
+ z-index: 20;
+ right: 0;
+ top: 2.8rem;
+ min-width: 20rem;
+ padding: 0.8rem;
+ border: 1px solid var(--surface-2);
+ border-radius: 0.7rem;
+ background: var(--base);
+ box-shadow: 0 0.5rem 2rem #0008;
+}
+.sync-details h2 {
+ font-size: 1rem;
+}
+.sync-details ul {
+ padding-left: 1.2rem;
+}
+.display-preferences {
+ position: relative;
+ margin-left: auto;
+}
+.preferences-trigger {
+ min-height: 2.5rem;
+ padding: 0.55rem 0.75rem;
+ color: var(--subtext-0);
+ background: transparent;
+ border: 1px solid var(--surface-2);
+ border-radius: 0.55rem;
+ cursor: pointer;
+ white-space: nowrap;
+}
+.preferences-trigger:focus-visible {
+ outline: 3px solid var(--lavender);
+ outline-offset: 2px;
+}
+.preferences-dialog {
+ position: absolute;
+ z-index: 30;
+ top: 3rem;
+ right: 0;
+ width: min(24rem, calc(100vw - 2rem));
+ padding: 1rem;
+ color: var(--text);
+ background: var(--base);
+ border: 1px solid var(--surface-2);
+ border-radius: 0.75rem;
+ box-shadow: 0 0.75rem 2.5rem #0009;
+}
+.preferences-heading {
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: 1rem;
+}
+.preferences-heading h2 {
+ margin-bottom: 0.75rem;
+ font-size: 1rem;
+}
+.preferences-dialog fieldset {
+ display: grid;
+ gap: 0.65rem;
+ border: 0;
+ padding: 0;
+}
+.preferences-dialog legend {
+ margin-bottom: 0.55rem;
+ color: var(--subtext-0);
+ font-size: var(--font-size-sm);
+}
+.preferences-dialog label {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+}
+.preference-example {
+ margin: 0.85rem 0 0;
+ color: var(--overlay-1);
+ font-size: var(--font-size-xs);
}
-.sessions-toolbar { margin-bottom: 1.25rem; display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
-.detail-actions, .confirmation-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .65rem; }
-.session-tabs { display: flex; gap: .4rem; padding: .3rem; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); }
-.session-tabs button, .quiet-action, .primary-action, .editor-actions button { min-height: 2.6rem; padding: .6rem .9rem; border: 1px solid transparent; border-radius: .55rem; color: var(--text); background: transparent; cursor: pointer; }
-.session-tabs button[aria-selected="true"] { color: var(--crust); background: var(--lavender); font-weight: 700; }
-.primary-action, .editor-actions .primary-action { color: var(--crust); background: var(--lavender); font-weight: 700; }
-.danger-action { min-height: 2.6rem; padding: .6rem .9rem; color: var(--red); background: transparent; border: 1px solid var(--red); border-radius: .55rem; cursor: pointer; font-weight: 700; }
-.danger-action:hover { color: var(--crust); background: var(--red); }
-.danger-action:disabled { cursor: not-allowed; opacity: .55; }
-.quiet-action { border-color: var(--surface-2); }
-.session-search { display: grid; gap: .45rem; max-width: 34rem; margin-bottom: 1.5rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
-.session-filters { display: flex; flex-wrap: wrap; gap: .8rem; margin: -.35rem 0 1.25rem; }
-.session-filters label { display: grid; min-width: 10rem; gap: .35rem; color: var(--subtext-0); font-size: var(--font-size-xs); }
-.session-filters select, .session-filters input { min-height: 2.5rem; padding: .55rem .7rem; color: var(--text); background: var(--mantle); border: 1px solid var(--surface-2); border-radius: .5rem; }
-.sort-indicator { width: max-content; margin: -.5rem 0 1rem; padding: .35rem .55rem; color: var(--lavender); background: rgb(180 190 254 / 8%); border-radius: .4rem; font: 650 var(--font-size-xs) var(--font-mono); }
-.session-search input, .form-grid input, .form-grid select, .form-grid textarea, .catalog-picker input, .occurrence-fields input, .occurrence-fields select { width: 100%; min-height: 2.7rem; padding: .65rem .75rem; border: 1px solid var(--surface-2); border-radius: .5rem; color: var(--text); background: var(--base); font-size: var(--font-size-md); }
-.session-list { display: grid; gap: .6rem; }
+.app-main {
+ width: 100%;
+ min-width: 0;
+ padding: clamp(2.25rem, 5vw, 5rem) var(--shell-gutter) clamp(3rem, 7vw, 7rem);
+}
+.page {
+ width: min(100%, 92rem);
+ min-width: 0;
+ margin: 0 auto;
+}
+.page-heading {
+ margin-bottom: clamp(2rem, 4vw, 3.5rem);
+ display: flex;
+ justify-content: space-between;
+ align-items: end;
+ gap: 2rem;
+}
+.eyebrow {
+ margin: 0 0 0.5rem;
+ color: var(--lavender);
+ font: 700 var(--font-size-xs)/1.25 var(--font-mono);
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+h1,
+h2,
+p {
+ margin-top: 0;
+}
+h1 {
+ margin-bottom: 0;
+ font-size: var(--font-size-display);
+ line-height: 0.96;
+ letter-spacing: -0.055em;
+ font-weight: 720;
+}
+h2 {
+ color: var(--text);
+ font-size: var(--font-size-lg);
+ line-height: 1.25;
+ letter-spacing: -0.015em;
+ font-weight: 680;
+}
+.page-intro {
+ max-width: 34rem;
+ margin-bottom: 0.15rem;
+ color: var(--subtext-0);
+ font-size: clamp(0.95rem, 1.4vw, 1.08rem);
+ line-height: 1.65;
+}
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.analysis-toolbar {
+ margin-bottom: 1.25rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem 2rem;
+ flex-wrap: wrap;
+}
+.analysis-tabs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+}
+.analysis-tabs button {
+ min-height: 2.6rem;
+ padding: 0.55rem 0.8rem;
+ color: var(--subtext-0);
+ background: transparent;
+ border: 1px solid var(--surface-2);
+ border-radius: 0.45rem;
+ cursor: pointer;
+}
+.analysis-tabs button[aria-selected="true"] {
+ color: var(--crust);
+ background: var(--lavender);
+ border-color: var(--lavender);
+ font-weight: 700;
+}
+.analysis-toolbar label {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+ color: var(--subtext-0);
+ font-size: var(--font-size-sm);
+ white-space: nowrap;
+}
+.analysis-picker {
+ width: min(100%, 30rem);
+ display: grid;
+ gap: 0.35rem;
+ color: var(--subtext-0);
+ font-size: var(--font-size-sm);
+}
+.analysis-toolbar select,
+.analysis-picker select {
+ min-height: 2.6rem;
+ padding: 0.55rem 0.7rem;
+ color: var(--text);
+ background: var(--mantle);
+ border: 1px solid var(--surface-2);
+ border-radius: 0.45rem;
+}
+.analysis-section-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 1rem;
+}
+.analysis-panel {
+ min-width: 0;
+ padding: clamp(1rem, 2vw, 1.45rem);
+ align-self: start;
+ border: 1px solid var(--surface-1);
+ border-radius: 0.75rem;
+ background: var(--mantle);
+ overflow: hidden;
+}
+.analysis-panel h3 {
+ margin-bottom: 0.6rem;
+}
+.analysis-wide {
+ width: 100%;
+}
+.analysis-facts {
+ margin: 0.85rem 0;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr));
+ gap: 0.65rem;
+}
+.analysis-facts div {
+ min-width: 0;
+ min-height: 4.3rem;
+ padding: 0.7rem 0.75rem;
+ background: var(--base);
+ border: 1px solid var(--surface-1);
+ border-radius: 0.55rem;
+}
+.analysis-facts dt {
+ color: var(--subtext-0);
+ font-size: var(--font-size-xs);
+}
+.analysis-facts dd {
+ margin: 0.3rem 0 0;
+ overflow-wrap: anywhere;
+ font-size: 1.1rem;
+ font-weight: 700;
+}
+.analysis-empty,
+.analysis-note {
+ color: var(--subtext-0);
+ line-height: 1.55;
+}
+.analysis-chart,
+.activity-histogram {
+ min-width: 0;
+ margin: 1rem 0 0;
+}
+.analysis-chart-heading {
+ margin-bottom: 0.45rem;
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 1rem;
+}
+.analysis-chart-heading strong {
+ font-size: var(--font-size-sm);
+}
+.analysis-chart-heading span {
+ color: var(--overlay-1);
+ font: 650 var(--font-size-xs) var(--font-mono);
+}
+.analysis-chart svg {
+ width: 100%;
+ height: clamp(13rem, 28vw, 21rem);
+ overflow: visible;
+}
+.analysis-chart polyline {
+ stroke: var(--lavender);
+ stroke-width: 1.35;
+}
+.analysis-chart circle {
+ fill: var(--lavender);
+ stroke: var(--base);
+ stroke-width: 0.45;
+}
+.analysis-chart .chart-axis {
+ stroke: var(--surface-2);
+ stroke-width: 0.5;
+ vector-effect: non-scaling-stroke;
+}
+.analysis-chart text {
+ fill: var(--overlay-1);
+ font-size: 3.4px;
+}
+.analysis-chart figcaption {
+ color: var(--subtext-0);
+ font-size: var(--font-size-xs);
+}
+.analysis-metric-tabs {
+ margin-top: 1rem;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+}
+.analysis-metric-tabs button {
+ min-height: 2.35rem;
+ padding: 0.45rem 0.7rem;
+ color: var(--subtext-0);
+ background: transparent;
+ border: 1px solid var(--surface-2);
+ border-radius: 0.4rem;
+ cursor: pointer;
+}
+.analysis-metric-tabs button[aria-selected="true"] {
+ color: var(--crust);
+ background: var(--lavender);
+ border-color: var(--lavender);
+ font-weight: 700;
+}
+.activity-bars {
+ height: 9rem;
+ padding: 0.75rem 0.25rem 1.65rem;
+ display: flex;
+ align-items: end;
+ gap: clamp(0.2rem, 0.8vw, 0.55rem);
+ border-bottom: 1px solid var(--surface-2);
+}
+.activity-bar-column {
+ position: relative;
+ height: 100%;
+ min-width: 0.35rem;
+ flex: 1 1 0;
+ display: flex;
+ align-items: end;
+ justify-content: center;
+}
+.activity-bar {
+ width: min(100%, 1.8rem);
+ min-height: 0.4rem;
+ background: var(--lavender);
+ border-radius: 0.25rem 0.25rem 0 0;
+}
+.activity-bar-value {
+ position: absolute;
+ bottom: calc(100% + 0.15rem);
+ color: var(--subtext-0);
+ font-size: var(--font-size-xs);
+ opacity: 0;
+}
+.activity-bar-column:hover .activity-bar-value,
+.activity-bar-column:focus-within .activity-bar-value {
+ opacity: 1;
+}
+.activity-bar-column time {
+ position: absolute;
+ top: calc(100% + 0.3rem);
+ color: var(--overlay-1);
+ font-size: 0.68rem;
+ white-space: nowrap;
+}
+.analysis-program {
+ max-width: 38rem;
+ display: grid;
+ gap: 0.85rem;
+}
+.analysis-program h3 {
+ margin: 0;
+}
+.analysis-program-count {
+ color: var(--subtext-0);
+}
+.analysis-program-count strong {
+ color: var(--text);
+ font-size: 1.35rem;
+}
+.analysis-program progress {
+ width: 100%;
+ height: 0.7rem;
+ accent-color: var(--lavender);
+}
+.analysis-next-session {
+ padding: 0.75rem;
+ display: grid;
+ gap: 0.25rem;
+ background: var(--base);
+ border: 1px solid var(--surface-1);
+ border-radius: 0.55rem;
+}
+.analysis-next-session span {
+ color: var(--subtext-0);
+ font-size: var(--font-size-xs);
+}
+.analysis-action {
+ width: max-content;
+ padding: 0.55rem 0.8rem;
+ color: var(--crust);
+ background: var(--lavender);
+ border-radius: 0.45rem;
+ font-weight: 700;
+ text-decoration: none;
+}
+.analysis-zones {
+ display: grid;
+ grid-template-columns: minmax(23rem, 1.1fr) minmax(18rem, 0.9fr);
+ align-items: start;
+ gap: 2rem;
+}
+.analysis-zones .body-views svg {
+ height: clamp(22rem, 42vw, 31rem);
+}
+.analysis-zones .muscle-legend {
+ margin-top: 0.8rem;
+ gap: 0.45rem 0.6rem;
+ font-size: var(--font-size-sm);
+}
+.analysis-zones .muscle-legend span {
+ width: 0.85rem;
+ height: 0.85rem;
+}
+.analysis-zones ul {
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: 0.5rem;
+ list-style: none;
+}
+.analysis-zones li {
+ padding: 0.65rem 0;
+ display: grid;
+ gap: 0.5rem;
+ border-bottom: 1px solid var(--surface-1);
+}
+.analysis-zone-heading {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+}
+.analysis-zone-heading span {
+ color: var(--subtext-0);
+ text-align: right;
+}
+.analysis-zone-track {
+ height: 0.42rem;
+ overflow: hidden;
+ background: var(--surface-0);
+ border-radius: 99rem;
+}
+.analysis-zone-track span {
+ display: block;
+ height: 100%;
+ background: var(--mauve);
+ border-radius: inherit;
+}
+.analysis-link,
+.tile-context-link {
+ width: max-content;
+ max-width: 100%;
+ color: var(--lavender);
+ background: transparent;
+ border: 0;
+ padding: 0.35rem 0;
+ text-decoration: underline;
+ text-underline-offset: 0.2rem;
+ cursor: pointer;
+}
+.analysis-delta dd {
+ color: var(--lavender);
+}
+.measurement-summary dd {
+ display: grid;
+ gap: 0.2rem;
+}
+.measurement-summary small {
+ color: var(--lavender);
+ font-weight: 500;
+}
+.program-progress p {
+ margin: 0.25rem 0;
+ color: var(--subtext-0);
+}
+
+@media (max-width: 48rem) {
+ .analysis-section-grid,
+ .analysis-zones {
+ grid-template-columns: 1fr;
+ }
+ .analysis-toolbar {
+ align-items: stretch;
+ flex-direction: column;
+ }
+ .analysis-toolbar label {
+ width: 100%;
+ justify-content: space-between;
+ }
+ .analysis-tabs button {
+ flex: 1 1 8rem;
+ }
+ .analysis-zones .body-views svg {
+ height: clamp(16rem, 58vw, 23rem);
+ }
+ .analysis-chart svg {
+ height: 14rem;
+ }
+}
+
+.sessions-toolbar {
+ margin-bottom: 1.25rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ flex-wrap: wrap;
+}
+.detail-actions,
+.confirmation-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 0.65rem;
+}
+.session-tabs {
+ display: flex;
+ gap: 0.4rem;
+ padding: 0.3rem;
+ border: 1px solid var(--surface-1);
+ border-radius: 0.75rem;
+ background: var(--mantle);
+}
+.session-tabs button,
+.quiet-action,
+.primary-action,
+.editor-actions button {
+ min-height: 2.6rem;
+ padding: 0.6rem 0.9rem;
+ border: 1px solid transparent;
+ border-radius: 0.55rem;
+ color: var(--text);
+ background: transparent;
+ cursor: pointer;
+}
+.session-tabs button[aria-selected="true"] {
+ color: var(--crust);
+ background: var(--lavender);
+ font-weight: 700;
+}
+.primary-action,
+.editor-actions .primary-action {
+ color: var(--crust);
+ background: var(--lavender);
+ font-weight: 700;
+}
+.danger-action {
+ min-height: 2.6rem;
+ padding: 0.6rem 0.9rem;
+ color: var(--red);
+ background: transparent;
+ border: 1px solid var(--red);
+ border-radius: 0.55rem;
+ cursor: pointer;
+ font-weight: 700;
+}
+.danger-action:hover {
+ color: var(--crust);
+ background: var(--red);
+}
+.danger-action:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+.quiet-action {
+ border-color: var(--surface-2);
+}
+.session-search {
+ display: grid;
+ gap: 0.45rem;
+ max-width: 34rem;
+ margin-bottom: 1.5rem;
+ color: var(--subtext-0);
+ font-size: var(--font-size-sm);
+}
+.session-filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.8rem;
+ margin: -0.35rem 0 1.25rem;
+}
+.session-filters label {
+ display: grid;
+ min-width: 10rem;
+ gap: 0.35rem;
+ color: var(--subtext-0);
+ font-size: var(--font-size-xs);
+}
+.session-filters select,
+.session-filters input {
+ min-height: 2.5rem;
+ padding: 0.55rem 0.7rem;
+ color: var(--text);
+ background: var(--mantle);
+ border: 1px solid var(--surface-2);
+ border-radius: 0.5rem;
+}
+.sort-indicator {
+ width: max-content;
+ margin: -0.5rem 0 1rem;
+ padding: 0.35rem 0.55rem;
+ color: var(--lavender);
+ background: rgb(180 190 254 / 8%);
+ border-radius: 0.4rem;
+ font: 650 var(--font-size-xs) var(--font-mono);
+}
+.session-search input,
+.form-grid input,
+.form-grid select,
+.form-grid textarea,
+.catalog-picker input,
+.occurrence-fields input,
+.occurrence-fields select {
+ width: 100%;
+ min-height: 2.7rem;
+ padding: 0.65rem 0.75rem;
+ border: 1px solid var(--surface-2);
+ border-radius: 0.5rem;
+ color: var(--text);
+ background: var(--base);
+ font-size: var(--font-size-md);
+}
+.session-list {
+ display: grid;
+ gap: 0.6rem;
+}
.session-row {
align-items: stretch;
background: var(--mantle);
border: 1px solid var(--surface-1);
- border-radius: .75rem;
+ border-radius: 0.75rem;
display: grid;
grid-template-columns: minmax(0, 1fr) auto 3rem;
overflow: hidden;
width: 100%;
}
-.session-row:hover { border-color: var(--lavender); }
+.session-row:hover {
+ border-color: var(--lavender);
+}
.session-row-main {
align-items: center;
background: transparent;
@@ -146,32 +792,143 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
text-align: left;
width: 100%;
}
-.session-row-main:hover { background: var(--base); }
-.session-row span:first-child { display: grid; gap: .2rem; }
-.session-row small { color: var(--lavender); font: 700 var(--font-size-xs) var(--font-mono); text-transform: uppercase; }
-.sessions-editor, .session-detail { padding: clamp(1rem, 3vw, 2.25rem); border: 1px solid var(--surface-1); border-radius: var(--radius); background: var(--mantle); }
-.form-grid { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 1rem; }
-.form-grid label, .catalog-picker label, .occurrence-fields label { display: grid; gap: .4rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
-.wide-field { grid-column: 1 / -1; }
-.form-grid textarea { min-height: 7rem; resize: vertical; }
-.catalog-picker { margin: 1.5rem 0; }
-.catalog-results { margin-top: .6rem; display: flex; gap: .45rem; flex-wrap: wrap; }
-.catalog-results button, .occurrence-actions button { padding: .45rem .65rem; border: 1px solid var(--surface-2); border-radius: .45rem; color: var(--text); background: var(--base); cursor: pointer; }
-.occurrence-editor-list, .detail-occurrences { padding: 0; display: grid; gap: .75rem; list-style: none; }
-.occurrence-editor-list > li, .detail-occurrences > li { padding: 1rem; border: 1px solid var(--surface-1); border-radius: .7rem; background: var(--base); }
-.occurrence-heading { display: flex; align-items: center; gap: .65rem; }
-.drag-handle { color: var(--overlay-1); }
-.occurrence-actions { margin-left: auto; display: flex; gap: .35rem; }
-.occurrence-fields { margin-top: 1rem; display: grid; grid-template-columns: repeat(5, minmax(7rem, 1fr)); gap: .75rem; }
-.editor-actions { margin-top: 1.5rem; display: flex; justify-content: flex-end; gap: .75rem; }
-.editor-actions button { border-color: var(--surface-2); }
-.editor-actions button:disabled { cursor: not-allowed; opacity: .55; }
-.empty-inline, .error-panel, .form-error { padding: 1rem; border-radius: .65rem; background: var(--base); color: var(--subtext-0); }
-.error-panel, .form-error { border: 1px solid var(--red); color: var(--red); }
-.success-panel { padding: 1rem; border: 1px solid var(--green); border-radius: .65rem; color: var(--green); background: rgb(166 227 161 / 7%); }
-.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; }
+.session-row-main:hover {
+ background: var(--base);
+}
+.session-row span:first-child {
+ display: grid;
+ gap: 0.2rem;
+}
+.session-row small {
+ color: var(--lavender);
+ font: 700 var(--font-size-xs) var(--font-mono);
+ text-transform: uppercase;
+}
+.sessions-editor,
+.session-detail {
+ padding: clamp(1rem, 3vw, 2.25rem);
+ border: 1px solid var(--surface-1);
+ border-radius: var(--radius);
+ background: var(--mantle);
+}
+.form-grid {
+ display: grid;
+ grid-template-columns: 2fr 1fr 1fr;
+ gap: 1rem;
+}
+.form-grid label,
+.catalog-picker label,
+.occurrence-fields label {
+ display: grid;
+ gap: 0.4rem;
+ color: var(--subtext-0);
+ font-size: var(--font-size-sm);
+}
+.wide-field {
+ grid-column: 1 / -1;
+}
+.form-grid textarea {
+ min-height: 7rem;
+ resize: vertical;
+}
+.catalog-picker {
+ margin: 1.5rem 0;
+}
+.catalog-results {
+ margin-top: 0.6rem;
+ display: flex;
+ gap: 0.45rem;
+ flex-wrap: wrap;
+}
+.catalog-results button,
+.occurrence-actions button {
+ padding: 0.45rem 0.65rem;
+ border: 1px solid var(--surface-2);
+ border-radius: 0.45rem;
+ color: var(--text);
+ background: var(--base);
+ cursor: pointer;
+}
+.occurrence-editor-list,
+.detail-occurrences {
+ padding: 0;
+ display: grid;
+ gap: 0.75rem;
+ list-style: none;
+}
+.occurrence-editor-list > li,
+.detail-occurrences > li {
+ padding: 1rem;
+ border: 1px solid var(--surface-1);
+ border-radius: 0.7rem;
+ background: var(--base);
+}
+.occurrence-heading {
+ display: flex;
+ align-items: center;
+ gap: 0.65rem;
+}
+.drag-handle {
+ color: var(--overlay-1);
+}
+.occurrence-actions {
+ margin-left: auto;
+ display: flex;
+ gap: 0.35rem;
+}
+.occurrence-fields {
+ margin-top: 1rem;
+ display: grid;
+ grid-template-columns: repeat(5, minmax(7rem, 1fr));
+ gap: 0.75rem;
+}
+.editor-actions {
+ margin-top: 1.5rem;
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.75rem;
+}
+.editor-actions button {
+ border-color: var(--surface-2);
+}
+.editor-actions button:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+.empty-inline,
+.error-panel,
+.form-error {
+ padding: 1rem;
+ border-radius: 0.65rem;
+ background: var(--base);
+ color: var(--subtext-0);
+}
+.error-panel,
+.form-error {
+ border: 1px solid var(--red);
+ color: var(--red);
+}
+.success-panel {
+ padding: 1rem;
+ border: 1px solid var(--green);
+ border-radius: 0.65rem;
+ color: var(--green);
+ background: rgb(166 227 161 / 7%);
+}
+.delete-confirmation {
+ margin-bottom: 1rem;
+ padding: 1rem;
+ border: 1px solid var(--red);
+ border-radius: 0.75rem;
+ background: var(--base);
+}
+.delete-confirmation h3 {
+ margin-top: 0;
+}
+.delete-confirmation p {
+ color: var(--subtext-0);
+ line-height: 1.55;
+}
.delete-dialog-backdrop {
align-items: center;
background: rgb(17 17 27 / 78%);
@@ -202,7 +959,9 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
min-width: 44px;
}
.trash-action:hover,
-.trash-action:focus-visible { background: rgb(243 139 168 / 12%); }
+.trash-action:focus-visible {
+ background: rgb(243 139 168 / 12%);
+}
.android-send-action {
align-items: center;
align-self: stretch;
@@ -217,8 +976,14 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
min-height: 44px;
min-width: 44px;
}
-.android-send-action:hover, .android-send-action:focus-visible { background: rgb(137 180 250 / 12%); }
-.android-send-action:disabled { cursor: wait; opacity: .55; }
+.android-send-action:hover,
+.android-send-action:focus-visible {
+ background: rgb(137 180 250 / 12%);
+}
+.android-send-action:disabled {
+ cursor: wait;
+ opacity: 0.55;
+}
.android-delivery-state {
align-items: center;
border-left: 1px solid var(--surface-1);
@@ -227,274 +992,1487 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
font-size: var(--font-size-xs);
font-weight: 700;
max-width: 9rem;
- padding: .55rem .75rem;
+ padding: 0.55rem 0.75rem;
text-align: center;
}
-.android-delivery-state.state-remote_unknown { color: var(--yellow); }
-.android-delivery-state.state-acknowledged { color: var(--green); }
-.program-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 28rem), 1fr)); gap: 1rem; }
-.program-session-list { display: grid; gap: .75rem; }
-.program-row { box-shadow: 0 .45rem 1.4rem rgb(0 0 0 / 10%); grid-template-columns: minmax(0, 1fr) 3rem; }
-.program-row .session-row-main { grid-template-columns: minmax(13rem, 2fr) repeat(3, minmax(8rem, 1fr)); }
-.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; background: var(--base); }
-.program-session-list li:has(.state-completed) { border-color: color-mix(in srgb, var(--green) 55%, var(--surface-1)); background: color-mix(in srgb, var(--green) 7%, var(--base)); }
-.program-session-list p { margin: .35rem 0 0; color: var(--subtext-0); }
-.program-execution-state { display: inline-flex; margin-left: .65rem; padding: .2rem .55rem; border-radius: 999px; font-size: .82rem; font-weight: 700; }
-.program-execution-state.state-todo { background: var(--surface-1); color: var(--subtext-1); }
-.program-execution-state.state-prepared, .program-execution-state.state-in_progress { background: color-mix(in srgb, var(--yellow) 18%, transparent); color: var(--yellow); }
-.program-execution-state.state-completed { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green); }
-.program-execution-state.state-deleted { background: color-mix(in srgb, var(--red) 14%, transparent); color: var(--red); }
-.program-progress { max-width: 28rem; display: grid; gap: .4rem; }
-.program-progress div { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
-.program-progress span { color: var(--subtext-0); font-size: var(--font-size-sm); }
-.program-progress progress { width: 100%; height: .55rem; accent-color: var(--green); }
-.programs-calendar-page { max-width: 100%; }
-.program-calendar-loading { min-height: 12rem; display: grid; place-items: center; color: var(--subtext-0); }
-.program-calendar-empty { max-width: 40rem; padding: clamp(1.25rem, 4vw, 2.5rem); border: 1px solid var(--surface-1); border-radius: var(--radius); background: var(--mantle); }
-.program-calendar-empty > p:not(.eyebrow) { color: var(--subtext-0); line-height: 1.6; }
-.program-calendar-empty .primary-action { display: inline-flex; align-items: center; text-decoration: none; }
-.program-calendar-selector { width: min(100%, 28rem); margin: 0 0 1.5rem auto; display: grid; gap: .4rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
-.program-calendar-selector select { min-height: 2.7rem; padding: .6rem .75rem; color: var(--text); background: var(--mantle); border: 1px solid var(--surface-2); border-radius: .55rem; }
-.program-calendar-header { margin-bottom: 1.25rem; display: flex; align-items: end; justify-content: space-between; gap: 2rem; }
-.program-calendar-summary { margin: .7rem 0 0; color: var(--subtext-0); }
-.program-calendar-legend { margin: 0; padding: 0; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .45rem; list-style: none; }
-.program-calendar-legend li, .program-calendar-state-label { padding: .3rem .55rem; border: 1px solid currentColor; border-radius: 999px; font-size: var(--font-size-xs); font-weight: 700; }
-.program-calendar-state-todo { color: var(--lavender); background: color-mix(in srgb, var(--lavender) 7%, transparent); }
+.android-delivery-state.state-remote_unknown {
+ color: var(--yellow);
+}
+.android-delivery-state.state-acknowledged {
+ color: var(--green);
+}
+.program-list {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 28rem), 1fr));
+ gap: 1rem;
+}
+.program-session-list {
+ display: grid;
+ gap: 0.75rem;
+}
+.program-row {
+ box-shadow: 0 0.45rem 1.4rem rgb(0 0 0 / 10%);
+ grid-template-columns: minmax(0, 1fr) 3rem;
+}
+.program-row .session-row-main {
+ grid-template-columns: minmax(13rem, 2fr) repeat(3, minmax(8rem, 1fr));
+}
+.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: 0.7rem;
+ background: var(--base);
+}
+.program-session-list li:has(.state-completed) {
+ border-color: color-mix(in srgb, var(--green) 55%, var(--surface-1));
+ background: color-mix(in srgb, var(--green) 7%, var(--base));
+}
+.program-session-list p {
+ margin: 0.35rem 0 0;
+ color: var(--subtext-0);
+}
+.program-execution-state {
+ display: inline-flex;
+ margin-left: 0.65rem;
+ padding: 0.2rem 0.55rem;
+ border-radius: 999px;
+ font-size: 0.82rem;
+ font-weight: 700;
+}
+.program-execution-state.state-todo {
+ background: var(--surface-1);
+ color: var(--subtext-1);
+}
+.program-execution-state.state-prepared,
+.program-execution-state.state-in_progress {
+ background: color-mix(in srgb, var(--yellow) 18%, transparent);
+ color: var(--yellow);
+}
+.program-execution-state.state-completed {
+ background: color-mix(in srgb, var(--green) 18%, transparent);
+ color: var(--green);
+}
+.program-execution-state.state-deleted {
+ background: color-mix(in srgb, var(--red) 14%, transparent);
+ color: var(--red);
+}
+.program-progress {
+ max-width: 28rem;
+ display: grid;
+ gap: 0.4rem;
+}
+.program-progress div {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 1rem;
+}
+.program-progress span {
+ color: var(--subtext-0);
+ font-size: var(--font-size-sm);
+}
+.program-progress progress {
+ width: 100%;
+ height: 0.65rem;
+ accent-color: var(--lavender);
+}
+.program-progress .program-next {
+ margin-top: 0.35rem;
+ padding: 0.6rem 0.7rem;
+ display: grid;
+ justify-content: stretch;
+ gap: 0.2rem;
+ background: var(--base);
+ border: 1px solid var(--surface-1);
+ border-radius: 0.55rem;
+}
+.programs-calendar-page {
+ max-width: 100%;
+}
+.program-calendar-loading {
+ min-height: 12rem;
+ display: grid;
+ place-items: center;
+ color: var(--subtext-0);
+}
+.program-calendar-empty {
+ max-width: 40rem;
+ padding: clamp(1.25rem, 4vw, 2.5rem);
+ border: 1px solid var(--surface-1);
+ border-radius: var(--radius);
+ background: var(--mantle);
+}
+.program-calendar-empty > p:not(.eyebrow) {
+ color: var(--subtext-0);
+ line-height: 1.6;
+}
+.program-calendar-empty .primary-action {
+ display: inline-flex;
+ align-items: center;
+ text-decoration: none;
+}
+.program-calendar-selector {
+ width: min(100%, 28rem);
+ margin: 0 0 1.5rem auto;
+ display: grid;
+ gap: 0.4rem;
+ color: var(--subtext-0);
+ font-size: var(--font-size-sm);
+}
+.program-calendar-selector select {
+ min-height: 2.7rem;
+ padding: 0.6rem 0.75rem;
+ color: var(--text);
+ background: var(--mantle);
+ border: 1px solid var(--surface-2);
+ border-radius: 0.55rem;
+}
+.program-calendar-header {
+ margin-bottom: 1.25rem;
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ gap: 2rem;
+}
+.program-calendar-summary {
+ margin: 0.7rem 0 0;
+ color: var(--subtext-0);
+}
+.program-calendar-legend {
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 0.45rem;
+ list-style: none;
+}
+.program-calendar-legend li,
+.program-calendar-state-label {
+ padding: 0.3rem 0.55rem;
+ border: 1px solid currentColor;
+ border-radius: 999px;
+ font-size: var(--font-size-xs);
+ font-weight: 700;
+}
+.program-calendar-state-todo {
+ color: var(--lavender);
+ background: color-mix(in srgb, var(--lavender) 7%, transparent);
+}
-.exercise-toolbar { display: grid; grid-template-columns: minmax(14rem, 2fr) repeat(2, minmax(11rem, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
-.exercise-toolbar label, .exercise-form > label { display: grid; gap: .4rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
-.exercise-toolbar input, .exercise-toolbar select, .exercise-form input[type="text"], .exercise-form > label > input, .exercise-form select { width: 100%; min-height: 2.7rem; padding: .65rem .75rem; color: var(--text); background: var(--base); border: 1px solid var(--surface-2); border-radius: .5rem; }
-.exercise-list { display: grid; gap: .65rem; }
-.exercise-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: stretch; overflow: hidden; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); }
-.exercise-card:hover { border-color: var(--lavender); }
-.exercise-card-main { display: grid; grid-template-columns: minmax(12rem, 2fr) repeat(3, minmax(8rem, 1fr)); gap: 1rem; align-items: center; padding: 1rem 1.1rem; color: var(--text); background: transparent; border: 0; text-align: left; cursor: pointer; }
-.exercise-card-main span { color: var(--subtext-0); font-size: var(--font-size-sm); }
-.exercise-card > .quiet-action { margin: .65rem; }
-.exercise-pagination { display: flex; justify-content: flex-end; gap: .65rem; margin-top: 1rem; }
-.exercise-detail { width: min(100%, 70rem); margin: 0 auto; }
-.exercise-detail-heading { display: flex; align-items: end; justify-content: space-between; gap: 1rem; margin: 2rem 0; }
-.exercise-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem; }
-.exercise-facts div { padding: 1rem; border: 1px solid var(--surface-1); border-radius: .7rem; background: var(--mantle); }
-.exercise-facts dt { margin-bottom: .4rem; color: var(--subtext-0); font-size: var(--font-size-xs); text-transform: uppercase; }
-.exercise-facts dd { margin: 0; overflow-wrap: anywhere; }
-.protected-identity { align-self: center; color: var(--yellow); font-size: var(--font-size-sm); }
-.exercise-form { width: min(100%, 54rem); display: grid; gap: 1rem; margin-top: 2rem; padding: clamp(1rem, 3vw, 2rem); border: 1px solid var(--surface-1); border-radius: var(--radius); background: var(--mantle); }
-.exercise-form fieldset { display: flex; flex-wrap: wrap; gap: .75rem 1.25rem; padding: 1rem; border: 1px solid var(--surface-1); border-radius: .65rem; }
-.exercise-form legend { padding: 0 .35rem; color: var(--subtext-0); }
-.exercise-form fieldset label { display: flex; align-items: center; gap: .45rem; }
-.exercise-zone-options { display: grid; grid-template-columns: repeat(2, minmax(12rem, 1fr)); gap: .65rem; width: 100%; }
-.profile-confirmation { padding: 1rem; border: 1px solid var(--yellow); border-radius: .65rem; color: var(--yellow); background: rgb(249 226 175 / 7%); }
+.exercise-toolbar {
+ display: grid;
+ grid-template-columns: minmax(14rem, 2fr) repeat(2, minmax(11rem, 1fr));
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+}
+.exercise-toolbar label,
+.exercise-form > label {
+ display: grid;
+ gap: 0.4rem;
+ color: var(--subtext-0);
+ font-size: var(--font-size-sm);
+}
+.exercise-toolbar input,
+.exercise-toolbar select,
+.exercise-form input[type="text"],
+.exercise-form > label > input,
+.exercise-form select {
+ width: 100%;
+ min-height: 2.7rem;
+ padding: 0.65rem 0.75rem;
+ color: var(--text);
+ background: var(--base);
+ border: 1px solid var(--surface-2);
+ border-radius: 0.5rem;
+}
+.exercise-list {
+ display: grid;
+ gap: 0.65rem;
+}
+.exercise-card {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: stretch;
+ overflow: hidden;
+ border: 1px solid var(--surface-1);
+ border-radius: 0.75rem;
+ background: var(--mantle);
+}
+.exercise-card:hover {
+ border-color: var(--lavender);
+}
+.exercise-card-main {
+ display: grid;
+ grid-template-columns: minmax(12rem, 2fr) repeat(3, minmax(8rem, 1fr));
+ gap: 1rem;
+ align-items: center;
+ padding: 1rem 1.1rem;
+ color: var(--text);
+ background: transparent;
+ border: 0;
+ text-align: left;
+ cursor: pointer;
+}
+.exercise-card-main span {
+ color: var(--subtext-0);
+ font-size: var(--font-size-sm);
+}
+.exercise-card > .quiet-action {
+ margin: 0.65rem;
+}
+.exercise-pagination {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.65rem;
+ margin-top: 1rem;
+}
+.exercise-detail {
+ width: min(100%, 70rem);
+ margin: 0 auto;
+}
+.exercise-detail-heading {
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ gap: 1rem;
+ margin: 2rem 0;
+}
+.exercise-facts {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.75rem;
+}
+.exercise-facts div {
+ padding: 1rem;
+ border: 1px solid var(--surface-1);
+ border-radius: 0.7rem;
+ background: var(--mantle);
+}
+.exercise-facts dt {
+ margin-bottom: 0.4rem;
+ color: var(--subtext-0);
+ font-size: var(--font-size-xs);
+ text-transform: uppercase;
+}
+.exercise-facts dd {
+ margin: 0;
+ overflow-wrap: anywhere;
+}
+.protected-identity {
+ align-self: center;
+ color: var(--yellow);
+ font-size: var(--font-size-sm);
+}
+.exercise-form {
+ width: min(100%, 54rem);
+ display: grid;
+ gap: 1rem;
+ margin-top: 2rem;
+ padding: clamp(1rem, 3vw, 2rem);
+ border: 1px solid var(--surface-1);
+ border-radius: var(--radius);
+ background: var(--mantle);
+}
+.exercise-form fieldset {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem 1.25rem;
+ padding: 1rem;
+ border: 1px solid var(--surface-1);
+ border-radius: 0.65rem;
+}
+.exercise-form legend {
+ padding: 0 0.35rem;
+ color: var(--subtext-0);
+}
+.exercise-form fieldset label {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+}
+.exercise-zone-options {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(12rem, 1fr));
+ gap: 0.65rem;
+ width: 100%;
+}
+.profile-confirmation {
+ padding: 1rem;
+ border: 1px solid var(--yellow);
+ border-radius: 0.65rem;
+ color: var(--yellow);
+ background: rgb(249 226 175 / 7%);
+}
@media (max-width: 50rem) {
- .exercise-toolbar, .exercise-facts { grid-template-columns: 1fr; }
- .exercise-card { grid-template-columns: 1fr; }
- .exercise-card-main { grid-template-columns: 1fr; gap: .35rem; }
- .exercise-card > .quiet-action { justify-self: start; }
- .exercise-detail-heading { align-items: start; flex-direction: column; }
- .exercise-zone-options { grid-template-columns: 1fr; }
+ .exercise-toolbar,
+ .exercise-facts {
+ grid-template-columns: 1fr;
+ }
+ .exercise-card {
+ grid-template-columns: 1fr;
+ }
+ .exercise-card-main {
+ grid-template-columns: 1fr;
+ gap: 0.35rem;
+ }
+ .exercise-card > .quiet-action {
+ justify-self: start;
+ }
+ .exercise-detail-heading {
+ align-items: start;
+ flex-direction: column;
+ }
+ .exercise-zone-options {
+ grid-template-columns: 1fr;
+ }
+}
+.program-calendar-state-prepared {
+ color: var(--yellow);
+ background: color-mix(in srgb, var(--yellow) 8%, transparent);
+}
+.program-calendar-state-in_progress {
+ color: var(--sapphire);
+ background: color-mix(in srgb, var(--sapphire) 9%, transparent);
+}
+.program-calendar-state-completed {
+ color: var(--green);
+ background: color-mix(in srgb, var(--green) 8%, transparent);
+}
+.program-calendar-state-deleted {
+ color: var(--overlay-1);
+ background: color-mix(in srgb, var(--surface-1) 35%, transparent);
+}
+.program-calendar-announcement {
+ min-height: 1.5rem;
+ margin: 0 0 0.5rem;
+ color: var(--subtext-0);
+ font-size: var(--font-size-sm);
+}
+.program-calendar-scroll {
+ max-width: 100%;
+ overflow-x: auto;
+ scrollbar-width: thin;
+ outline-offset: 0.35rem;
+}
+.program-calendar-scroll:focus-visible {
+ outline: 2px solid var(--lavender);
+}
+.program-calendar-grid {
+ min-width: 72rem;
+ display: grid;
+ grid-template-columns: repeat(7, minmax(9rem, 1fr));
+ border-top: 1px solid var(--surface-1);
+ border-left: 1px solid var(--surface-1);
+}
+.program-calendar-day {
+ min-width: 0;
+ min-height: 12rem;
+ padding: 0.7rem;
+ background: var(--mantle);
+ border-right: 1px solid var(--surface-1);
+ border-bottom: 1px solid var(--surface-1);
+}
+.program-calendar-day > header {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: baseline;
+ gap: 0.15rem 0.5rem;
+}
+.program-calendar-day > header span {
+ color: var(--subtext-0);
+ font: 700 var(--font-size-xs) var(--font-mono);
+ text-transform: uppercase;
+}
+.program-calendar-day > header strong {
+ color: var(--text);
+ font-size: var(--font-size-lg);
+}
+.program-calendar-day > header time {
+ grid-column: 1 / -1;
+ color: var(--overlay-1);
+ font-size: var(--font-size-xs);
+}
+.program-calendar-rest {
+ margin: 2rem 0 0;
+ color: var(--overlay-1);
+ font-size: var(--font-size-sm);
+ text-align: center;
+}
+.program-calendar-day-sessions {
+ margin-top: 0.75rem;
+ display: grid;
+ gap: 0.55rem;
+}
+.program-calendar-session {
+ padding: 0.65rem;
+ background: var(--base);
+ border: 1px solid var(--surface-1);
+ border-left: 0.22rem solid currentColor;
+ border-radius: 0.55rem;
+}
+.program-calendar-session h3 {
+ margin: 0 0 0.5rem;
+ color: var(--text);
+ font-size: var(--font-size-sm);
+ line-height: 1.35;
+}
+.program-calendar-state-label {
+ display: inline-flex;
+ padding: 0.2rem 0.4rem;
+}
+.program-calendar-action {
+ margin-top: 0.55rem;
+ display: grid;
+ gap: 0.4rem;
+}
+.program-calendar-action button,
+.program-calendar-action a {
+ min-height: 2.35rem;
+ padding: 0.48rem 0.6rem;
+ border: 1px solid var(--lavender);
+ border-radius: 0.45rem;
+ color: var(--text);
+ background: transparent;
+ cursor: pointer;
+ font-size: var(--font-size-xs);
+ text-align: center;
+ text-decoration: none;
+}
+.program-calendar-action button:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+.program-calendar-undated {
+ margin-top: 2rem;
+ padding: clamp(1rem, 3vw, 1.5rem);
+ background: var(--mantle);
+ border: 1px solid var(--surface-1);
+ border-radius: var(--radius);
+}
+.program-calendar-undated > div {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
+ gap: 0.75rem;
+}
+.import-preview {
+ margin: 1rem 0;
+ padding: 1rem;
+ border: 1px solid var(--blue);
+ border-radius: 0.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;
}
-.program-calendar-state-prepared { color: var(--yellow); background: color-mix(in srgb, var(--yellow) 8%, transparent); }
-.program-calendar-state-in_progress { color: var(--sapphire); background: color-mix(in srgb, var(--sapphire) 9%, transparent); }
-.program-calendar-state-completed { color: var(--green); background: color-mix(in srgb, var(--green) 8%, transparent); }
-.program-calendar-state-deleted { color: var(--overlay-1); background: color-mix(in srgb, var(--surface-1) 35%, transparent); }
-.program-calendar-announcement { min-height: 1.5rem; margin: 0 0 .5rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
-.program-calendar-scroll { max-width: 100%; overflow-x: auto; scrollbar-width: thin; outline-offset: .35rem; }
-.program-calendar-scroll:focus-visible { outline: 2px solid var(--lavender); }
-.program-calendar-grid { min-width: 72rem; display: grid; grid-template-columns: repeat(7, minmax(9rem, 1fr)); border-top: 1px solid var(--surface-1); border-left: 1px solid var(--surface-1); }
-.program-calendar-day { min-width: 0; min-height: 12rem; padding: .7rem; background: var(--mantle); border-right: 1px solid var(--surface-1); border-bottom: 1px solid var(--surface-1); }
-.program-calendar-day > header { display: grid; grid-template-columns: 1fr auto; align-items: baseline; gap: .15rem .5rem; }
-.program-calendar-day > header span { color: var(--subtext-0); font: 700 var(--font-size-xs) var(--font-mono); text-transform: uppercase; }
-.program-calendar-day > header strong { color: var(--text); font-size: var(--font-size-lg); }
-.program-calendar-day > header time { grid-column: 1 / -1; color: var(--overlay-1); font-size: var(--font-size-xs); }
-.program-calendar-rest { margin: 2rem 0 0; color: var(--overlay-1); font-size: var(--font-size-sm); text-align: center; }
-.program-calendar-day-sessions { margin-top: .75rem; display: grid; gap: .55rem; }
-.program-calendar-session { padding: .65rem; background: var(--base); border: 1px solid var(--surface-1); border-left: .22rem solid currentColor; border-radius: .55rem; }
-.program-calendar-session h3 { margin: 0 0 .5rem; color: var(--text); font-size: var(--font-size-sm); line-height: 1.35; }
-.program-calendar-state-label { display: inline-flex; padding: .2rem .4rem; }
-.program-calendar-action { margin-top: .55rem; display: grid; gap: .4rem; }
-.program-calendar-action button, .program-calendar-action a { min-height: 2.35rem; padding: .48rem .6rem; border: 1px solid var(--lavender); border-radius: .45rem; color: var(--text); background: transparent; cursor: pointer; font-size: var(--font-size-xs); text-align: center; text-decoration: none; }
-.program-calendar-action button:disabled { cursor: not-allowed; opacity: .55; }
-.program-calendar-undated { margin-top: 2rem; padding: clamp(1rem, 3vw, 1.5rem); background: var(--mantle); border: 1px solid var(--surface-1); border-radius: var(--radius); }
-.program-calendar-undated > div { display: grid; grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); gap: .75rem; }
-.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; }
- .program-row .session-row-main { grid-template-columns: 1fr; }
- .program-calendar-header { align-items: start; flex-direction: column; gap: 1rem; }
- .program-calendar-legend { justify-content: flex-start; }
- .program-calendar-scroll { overflow: visible; }
- .program-calendar-grid { min-width: 0; display: grid; grid-template-columns: 1fr; gap: .6rem; border: 0; }
- .program-calendar-day { min-height: 0; border: 1px solid var(--surface-1); border-radius: .65rem; }
- .program-calendar-rest { margin: 1rem 0; text-align: left; }
+ .program-session-list li {
+ align-items: stretch;
+ flex-direction: column;
+ }
+ .program-row .session-row-main {
+ grid-template-columns: 1fr;
+ }
+ .program-calendar-header {
+ align-items: start;
+ flex-direction: column;
+ gap: 1rem;
+ }
+ .program-calendar-legend {
+ justify-content: flex-start;
+ }
+ .program-calendar-scroll {
+ overflow: visible;
+ }
+ .program-calendar-grid {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 0.6rem;
+ border: 0;
+ }
+ .program-calendar-day {
+ min-height: 0;
+ border: 1px solid var(--surface-1);
+ border-radius: 0.65rem;
+ }
+ .program-calendar-rest {
+ margin: 1rem 0;
+ text-align: left;
+ }
+}
+.detail-summary,
+.detail-tile {
+ margin-bottom: 1rem;
+ padding: 1.1rem;
+ border: 1px solid var(--surface-1);
+ border-radius: 0.75rem;
+ background: var(--base);
+}
+.detail-occurrences li {
+ display: grid;
+ gap: 0.35rem;
+}
+.detail-occurrences span,
+.detail-occurrences small {
+ color: var(--subtext-0);
+}
+.detail-tile code {
+ overflow-wrap: anywhere;
+ color: var(--lavender);
+}
+.draft-payload {
+ max-height: 32rem;
+ overflow: auto;
+ color: var(--subtext-0);
+ font: var(--font-size-sm)/1.6 var(--font-mono);
+ white-space: pre-wrap;
}
-.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); }
-.detail-tile code { overflow-wrap: anywhere; color: var(--lavender); }
-.draft-payload { max-height: 32rem; overflow: auto; color: var(--subtext-0); font: var(--font-size-sm)/1.6 var(--font-mono); white-space: pre-wrap; }
@media (max-width: 52rem) {
- .session-row-main { grid-template-columns: 1fr 1fr; }
- .form-grid { grid-template-columns: 1fr 1fr; }
- .occurrence-fields { grid-template-columns: 1fr 1fr; }
+ .session-row-main {
+ grid-template-columns: 1fr 1fr;
+ }
+ .form-grid {
+ grid-template-columns: 1fr 1fr;
+ }
+ .occurrence-fields {
+ grid-template-columns: 1fr 1fr;
+ }
}
@media (max-width: 30rem) {
.session-row-main,
.form-grid,
- .occurrence-fields { grid-template-columns: 1fr; }
- .session-tabs { width: 100%; overflow-x: auto; }
- .session-tabs button { white-space: nowrap; }
- .editor-actions { align-items: stretch; flex-direction: column; }
+ .occurrence-fields {
+ grid-template-columns: 1fr;
+ }
+ .session-tabs {
+ width: 100%;
+ overflow-x: auto;
+ }
+ .session-tabs button {
+ white-space: nowrap;
+ }
+ .editor-actions {
+ align-items: stretch;
+ flex-direction: column;
+ }
}
-.dashboard-layout { min-width: 0; }
-.layout-toolbar { min-height: 2.7rem; margin: -1rem 0 1rem; display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: .65rem; }
-.layout-action { min-height: 2.45rem; padding: .58rem .82rem; color: var(--subtext-0); background: var(--surface-0); border: 1px solid rgb(108 112 134 / 48%); border-radius: .55rem; font: 650 var(--font-size-xs) var(--font-mono); cursor: pointer; transition: color 140ms ease, border-color 140ms ease, background 140ms ease; }
-.layout-action:hover { color: var(--text); border-color: var(--surface-2); background: var(--surface-1); }
-.layout-action:focus-visible, .tile:focus-visible { outline: 2px solid var(--lavender); outline-offset: 3px; }
-.layout-action-primary { color: var(--crust); background: var(--lavender); border-color: transparent; }
-.layout-action-primary:hover { color: var(--crust); background: var(--blue); border-color: transparent; }
-.layout-help, .layout-breakpoint-note { margin: 0 auto 0 0; color: var(--subtext-0); font: 550 var(--font-size-xs)/1.4 var(--font-mono); }
-.layout-breakpoint-note { flex-basis: 100%; color: var(--yellow); }
-.layout-loading { min-height: 12rem; display: grid; place-items: center; color: var(--subtext-0); }
-.layout-message { margin: 0 0 1rem; padding: .7rem .85rem; display: flex; align-items: center; gap: .75rem; color: var(--subtext-0); background: var(--surface-0); border-radius: .55rem; font-size: var(--font-size-sm); }
-.layout-message.is-error { color: var(--peach); }
-.layout-action:disabled { opacity: .55; cursor: not-allowed; }
-.dashboard-grid-frame { min-width: 0; }
-.dashboard-grid { min-width: 0; }
-.dashboard-grid > .react-grid-item { min-width: 0; }
-.dashboard-grid .tile { width: 100%; height: 100%; min-height: 0; }
-.dashboard-layout.is-editing .dashboard-grid-frame { margin: -.5rem; padding: .5rem; border-radius: calc(var(--radius) + .3rem); background-image: radial-gradient(circle, rgb(180 190 254 / 16%) 1px, transparent 1px); background-size: 1.5rem 1.5rem; }
-.dashboard-layout.is-editing .tile { border-color: rgb(180 190 254 / 38%); box-shadow: 0 1.2rem 3rem rgb(0 0 0 / 16%), inset 0 0 0 1px rgb(180 190 254 / 5%); }
-.dashboard-layout.is-editing .tile-drag-handle { cursor: grab; }
-.dashboard-layout.is-editing .react-draggable-dragging .tile-drag-handle { cursor: grabbing; }
-.dashboard-grid .react-grid-placeholder { background: var(--lavender); border-radius: var(--radius); opacity: .16; }
-.dashboard-grid .react-grid-item > .react-resizable-handle { display: none; width: 1.2rem; height: 1.2rem; opacity: .42; transition: opacity 120ms ease; }
-.dashboard-layout.is-editing .dashboard-grid .react-grid-item > .react-resizable-handle { display: block; }
-.dashboard-layout.is-editing .dashboard-grid .react-grid-item > .react-resizable-handle:hover,
-.dashboard-layout.is-editing .dashboard-grid .react-grid-item:focus-within > .react-resizable-handle { opacity: .85; }
-.dashboard-grid .react-resizable-handle::after { width: .38rem; height: .38rem; border-color: var(--lavender); }
-.dashboard-grid .react-resizable-handle-e { top: 50%; right: -.15rem; transform: translateY(-50%) rotate(135deg); }
-.dashboard-grid .react-resizable-handle-s { bottom: -.15rem; left: 50%; transform: translateX(-50%) rotate(45deg); }
-.tile-compact .empty-glyph { display: none; }
-.tile-compact .empty-state p { margin-top: 0; font-size: .82rem; }
-.tile-large .tile-content { padding: 1rem; }
+.dashboard-layout {
+ min-width: 0;
+}
+.layout-toolbar {
+ min-height: 2.7rem;
+ margin: -1rem 0 1rem;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ align-items: center;
+ gap: 0.65rem;
+}
+.layout-action {
+ min-height: 2.45rem;
+ padding: 0.58rem 0.82rem;
+ color: var(--subtext-0);
+ background: var(--surface-0);
+ border: 1px solid rgb(108 112 134 / 48%);
+ border-radius: 0.55rem;
+ font: 650 var(--font-size-xs) var(--font-mono);
+ cursor: pointer;
+ transition:
+ color 140ms ease,
+ border-color 140ms ease,
+ background 140ms ease;
+}
+.layout-action:hover {
+ color: var(--text);
+ border-color: var(--surface-2);
+ background: var(--surface-1);
+}
+.layout-action:focus-visible,
+.tile:focus-visible {
+ outline: 2px solid var(--lavender);
+ outline-offset: 3px;
+}
+.layout-action-primary {
+ color: var(--crust);
+ background: var(--lavender);
+ border-color: transparent;
+}
+.layout-action-primary:hover {
+ color: var(--crust);
+ background: var(--blue);
+ border-color: transparent;
+}
+.layout-help,
+.layout-breakpoint-note {
+ margin: 0 auto 0 0;
+ color: var(--subtext-0);
+ font: 550 var(--font-size-xs)/1.4 var(--font-mono);
+}
+.layout-breakpoint-note {
+ flex-basis: 100%;
+ color: var(--yellow);
+}
+.layout-loading {
+ min-height: 12rem;
+ display: grid;
+ place-items: center;
+ color: var(--subtext-0);
+}
+.layout-message {
+ margin: 0 0 1rem;
+ padding: 0.7rem 0.85rem;
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ color: var(--subtext-0);
+ background: var(--surface-0);
+ border-radius: 0.55rem;
+ font-size: var(--font-size-sm);
+}
+.layout-message.is-error {
+ color: var(--peach);
+}
+.layout-action:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+.dashboard-grid-frame {
+ min-width: 0;
+}
+.dashboard-grid {
+ min-width: 0;
+}
+.dashboard-grid > .react-grid-item {
+ min-width: 0;
+}
+.dashboard-grid .tile {
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+}
+.dashboard-layout.is-editing .dashboard-grid-frame {
+ margin: -0.5rem;
+ padding: 0.5rem;
+ border-radius: calc(var(--radius) + 0.3rem);
+ background-image: radial-gradient(
+ circle,
+ rgb(180 190 254 / 16%) 1px,
+ transparent 1px
+ );
+ background-size: 1.5rem 1.5rem;
+}
+.dashboard-layout.is-editing .tile {
+ border-color: rgb(180 190 254 / 38%);
+ box-shadow:
+ 0 1.2rem 3rem rgb(0 0 0 / 16%),
+ inset 0 0 0 1px rgb(180 190 254 / 5%);
+}
+.dashboard-layout.is-editing .tile-drag-handle {
+ cursor: grab;
+}
+.dashboard-layout.is-editing .react-draggable-dragging .tile-drag-handle {
+ cursor: grabbing;
+}
+.dashboard-grid .react-grid-placeholder {
+ background: var(--lavender);
+ border-radius: var(--radius);
+ opacity: 0.16;
+}
+.dashboard-grid .react-grid-item > .react-resizable-handle {
+ display: none;
+ width: 1.2rem;
+ height: 1.2rem;
+ opacity: 0.42;
+ transition: opacity 120ms ease;
+}
+.dashboard-layout.is-editing
+ .dashboard-grid
+ .react-grid-item
+ > .react-resizable-handle {
+ display: block;
+}
+.dashboard-layout.is-editing
+ .dashboard-grid
+ .react-grid-item
+ > .react-resizable-handle:hover,
+.dashboard-layout.is-editing
+ .dashboard-grid
+ .react-grid-item:focus-within
+ > .react-resizable-handle {
+ opacity: 0.85;
+}
+.dashboard-grid .react-resizable-handle::after {
+ width: 0.38rem;
+ height: 0.38rem;
+ border-color: var(--lavender);
+}
+.dashboard-grid .react-resizable-handle-e {
+ top: 50%;
+ right: -0.15rem;
+ transform: translateY(-50%) rotate(135deg);
+}
+.dashboard-grid .react-resizable-handle-s {
+ bottom: -0.15rem;
+ left: 50%;
+ transform: translateX(-50%) rotate(45deg);
+}
+.tile-compact .empty-glyph {
+ display: none;
+}
+.tile-compact .empty-state p {
+ margin-top: 0;
+ font-size: 0.82rem;
+}
+.tile-large .tile-content {
+ padding: 1rem;
+}
-.tile-grid { min-width: 0; display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 1rem; }
-.tile { min-width: 0; grid-column: span 4; min-height: 17rem; padding: clamp(1.15rem, 2vw, 1.6rem); display: flex; flex-direction: column; background: var(--base); border: 1px solid rgb(69 71 90 / 38%); border-radius: var(--radius); box-shadow: 0 1.2rem 3rem rgb(0 0 0 / 12%); }
-.tile-wide { grid-column: span 6; }
-.tile-feature { grid-column: span 8; min-height: 25rem; }
-.tile-header { min-width: 0; display: flex; flex-wrap: wrap; align-items: start; justify-content: space-between; gap: 1rem; }
-.tile-header h2 { margin-bottom: 0; }
-.tile-state { flex: none; color: var(--subtext-0); font: 650 var(--font-size-xs)/1 var(--font-mono); letter-spacing: .06em; padding: .48rem .62rem; background: var(--surface-0); border-radius: 99rem; }
-.tile-state.is-available { color: var(--green); background: rgb(166 227 161 / 8%); }
-.tile-state.is-partial { color: var(--yellow); background: rgb(249 226 175 / 8%); }
-.tile-content { flex: 1; min-height: 0; margin-top: 1rem; display: block; overflow: auto; scrollbar-width: thin; }
-.dashboard-data-message { margin: 0 0 1rem; padding: .65rem .8rem; color: var(--subtext-0); background: rgb(49 50 68 / 45%); border-radius: .5rem; font-size: var(--font-size-sm); }
-.dashboard-data-message.is-error { color: var(--peach); }
-.dashboard-data-message.is-warning { color: var(--yellow); }
-.tile-data-placeholder { height: 100%; display: grid; align-content: center; gap: .65rem; opacity: .5; }
-.tile-data-placeholder span { height: .55rem; background: var(--surface-0); border-radius: 99rem; }
-.tile-data-placeholder span:nth-child(2) { width: 72%; }.tile-data-placeholder span:nth-child(3) { width: 45%; }
-.unavailable-content { height: 100%; display: grid; align-content: center; justify-items: center; text-align: center; color: var(--subtext-0); }
-.unavailable-content > strong { color: var(--lavender); font: 500 1.7rem/1 var(--font-mono); }
-.unavailable-content p { margin: .65rem 0 0; color: var(--text); font-weight: 600; }
-.unavailable-content small { max-width: 24rem; margin-top: .55rem; color: var(--overlay-1); line-height: 1.45; }
-.future-action { margin-top: 1.1rem; padding: .45rem .65rem; color: var(--overlay-1); border: 1px dashed var(--surface-1); border-radius: .45rem; font: 600 var(--font-size-xs) var(--font-mono); }
-.metric-row { display: flex; flex-wrap: wrap; gap: .8rem 1.5rem; margin-bottom: 1rem; }
-.metric { display: grid; }.metric strong { color: var(--text); font-size: var(--font-size-xl); line-height: 1; }.metric span { margin-top: .3rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
-.activity-heatmap { display: grid; grid-template-rows: repeat(7, .72rem); grid-auto-flow: column; grid-auto-columns: minmax(.35rem, 1fr); gap: .22rem; min-width: 0; }
-.activity-day { background: var(--surface-0); border-radius: .16rem; }.activity-day.is-active { background: var(--lavender); }.activity-day.is-multiple { background: var(--blue); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 25%); }
-.chart-legend { margin: .75rem 0 0; color: var(--overlay-1); font-size: var(--font-size-xs); line-height: 1.45; }.legend-swatch { display: inline-block; width: .55rem; height: .55rem; margin-right: .3rem; background: var(--lavender); border-radius: .12rem; }
-.primary-label { margin: 0; color: var(--text); font-size: var(--font-size-lg); line-height: 1.3; font-weight: 650; }.secondary-label { margin: .3rem 0 0; color: var(--subtext-0); font-size: var(--font-size-sm); line-height: 1.4; }
-.progression-latest { margin: .9rem 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: .4rem .7rem; }.progression-latest strong { color: var(--lavender); font-size: clamp(1.6rem, 3vw, 2.5rem); }.progression-latest span { color: var(--subtext-0); font-size: var(--font-size-sm); }.progression-latest em { color: var(--green); font-size: var(--font-size-xs); font-style: normal; }
-.progression-chart { width: 100%; height: 6.5rem; min-height: 6rem; margin-top: .7rem; }
-.tile-large .progression-content { height: 100%; display: grid; grid-template-columns: minmax(14rem, .85fr) minmax(18rem, 1.15fr); grid-template-rows: auto auto 1fr auto; column-gap: 1.25rem; align-items: start; }
-.tile-large .progression-chart { grid-column: 2; grid-row: 1 / span 4; height: 100%; min-height: 10rem; margin-top: 0; }
-.chart-loading { min-height: 8rem; display: grid; place-items: center; color: var(--overlay-1); font-size: var(--font-size-xs); }
-.fact-list { margin: .8rem 0 0; }.fact-list.horizontal { display: flex; flex-wrap: wrap; gap: .75rem 1.2rem; }.fact-list div { min-width: 4rem; }.fact-list dt { color: var(--overlay-1); font: 650 var(--font-size-xs)/1.25 var(--font-mono); letter-spacing: .035em; text-transform: uppercase; }.fact-list dd { margin: .25rem 0 0; color: var(--text); font-size: var(--font-size-md); line-height: 1.3; }
-.zone-chips { margin-top: 1rem; display: flex; flex-wrap: wrap; gap: .4rem; }.zone-chips span { padding: .38rem .58rem; color: var(--mauve); background: rgb(203 166 247 / 8%); border-radius: 99rem; font-size: var(--font-size-xs); }
-.record-list, .muscle-list { margin: 0; padding: 0; list-style: none; }.record-list { display: grid; gap: .72rem; }.record-list li { min-width: 0; display: flex; justify-content: space-between; align-items: center; gap: .8rem; }.record-list div { min-width: 0; display: grid; gap: .12rem; }.record-list strong { overflow: hidden; color: var(--text); font-size: var(--font-size-md); text-overflow: ellipsis; white-space: nowrap; }.record-list span { overflow: hidden; color: var(--overlay-1); font-size: var(--font-size-xs); line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; }.record-list b { flex: none; color: var(--peach); font-size: 1.125rem; }
-.partial-note { margin: .8rem 0 0; color: var(--overlay-1); font-size: var(--font-size-xs); }
-.muscle-list { display: grid; gap: .58rem; }.muscle-label { min-width: 0; display: flex; justify-content: space-between; gap: 1rem; color: var(--subtext-0); font-size: var(--font-size-sm); line-height: 1.3; }.muscle-label strong { min-width: 0; overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; }.muscle-label span { flex: none; }.muscle-track { height: .3rem; margin-top: .3rem; overflow: hidden; background: var(--surface-0); border-radius: 99rem; }.muscle-track span { display: block; height: 100%; background: var(--mauve); border-radius: inherit; }.muscle-list p { margin: .22rem 0 0; color: var(--overlay-1); font-size: var(--font-size-xs); line-height: 1.3; }
-.muscle-content:has(.muscle-visual) { display: grid; grid-template-columns: minmax(12rem, .8fr) minmax(12rem, 1.2fr); gap: 1rem; align-items: start; }
-.tile-large .muscle-content:has(.muscle-visual) { grid-template-columns: minmax(18rem, 1.15fr) minmax(16rem, 1.85fr); gap: 1.25rem; }
-.body-zone-figure { position: relative; min-width: 0; }
-.body-views { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .45rem; }
-.body-views figure { min-width: 0; margin: 0; text-align: center; }
-.body-views figcaption { margin-bottom: .15rem; color: var(--overlay-1); font: 650 var(--font-size-xs) var(--font-mono); text-transform: uppercase; letter-spacing: .06em; }
-.body-views svg { display: block; width: 100%; height: 10.5rem; overflow: visible; }
-.tile-large .body-views svg { height: 16.5rem; }
-.body-outline { fill: var(--surface-0); stroke: var(--surface-2); stroke-width: 1.2; }
-.body-region { stroke: var(--base); stroke-width: 1.4; vector-effect: non-scaling-stroke; transition: fill 140ms ease, filter 140ms ease; }
-.body-region.intensity-0, .muscle-legend .intensity-0 { fill: var(--surface-0); background: var(--surface-0); }
-.body-region.intensity-1, .muscle-legend .intensity-1 { fill: var(--sapphire); background: var(--sapphire); opacity: .62; }
-.body-region.intensity-2, .muscle-legend .intensity-2 { fill: var(--lavender); background: var(--lavender); opacity: .82; }
-.body-region.intensity-3, .muscle-legend .intensity-3 { fill: var(--mauve); background: var(--mauve); }
-.body-region[tabindex]:hover, .body-region[tabindex]:focus { outline: none; filter: brightness(1.18) drop-shadow(0 0 .18rem rgb(180 190 254 / 45%)); stroke: var(--text); }
-.muscle-legend { margin-top: .35rem; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: .25rem .35rem; color: var(--overlay-1); font-size: var(--font-size-xs); line-height: 1.3; }
-.muscle-legend span { width: .65rem; height: .65rem; border-radius: .15rem; }
-.body-tooltip { position: absolute; z-index: 2; right: .25rem; bottom: 2.2rem; max-width: calc(100% - .5rem); padding: .5rem .65rem; display: grid; gap: .16rem; color: var(--text); background: rgb(24 24 37 / 96%); border: 1px solid var(--surface-2); border-radius: .45rem; box-shadow: 0 .6rem 1.5rem rgb(0 0 0 / 28%); font-size: var(--font-size-xs); pointer-events: none; }
-.body-tooltip span { color: var(--subtext-0); }
-.muted { color: var(--subtext-0); }
-.tile-compact .tile-content { margin-top: .65rem; }.tile-compact .tile-header { gap: .5rem; }.tile-compact .tile-state { padding: .3rem .42rem; }.tile-compact .activity-heatmap { grid-template-rows: repeat(5, .62rem); }.tile-compact .metric-row { margin-bottom: .65rem; }.tile-compact .progression-latest { margin-bottom: 0; }
-.empty-state { max-width: 22rem; color: var(--subtext-0); text-align: center; line-height: 1.5; }
-.empty-state p { margin: .75rem 0 0; overflow-wrap: anywhere; }
-.empty-glyph { display: inline-grid; width: 2.4rem; height: 2.4rem; place-items: center; color: var(--lavender); background: rgb(180 190 254 / 9%); border-radius: 50%; font: 600 1rem var(--font-mono); }
-.principle-panel { grid-column: span 4; min-height: 25rem; padding: 1.6rem; display: flex; flex-direction: column; justify-content: space-between; color: var(--subtext-0); background: var(--mantle); border-radius: var(--radius); }
-.principle-panel h2 { font-size: clamp(1.3rem, 2vw, 1.8rem); }
-.principle-panel p:last-child { line-height: 1.65; }
-.principle-index { color: var(--surface-1); font: 700 4rem/1 var(--font-mono); letter-spacing: -.1em; }
+.tile-grid {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: repeat(12, minmax(0, 1fr));
+ gap: 1rem;
+}
+.tile {
+ min-width: 0;
+ grid-column: span 4;
+ min-height: 17rem;
+ padding: clamp(1.15rem, 2vw, 1.6rem);
+ display: flex;
+ flex-direction: column;
+ background: var(--base);
+ border: 1px solid rgb(69 71 90 / 38%);
+ border-radius: var(--radius);
+ box-shadow: 0 1.2rem 3rem rgb(0 0 0 / 12%);
+}
+.tile-wide {
+ grid-column: span 6;
+}
+.tile-feature {
+ grid-column: span 8;
+ min-height: 25rem;
+}
+.tile-header {
+ min-width: 0;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: start;
+ justify-content: space-between;
+ gap: 1rem;
+}
+.tile-header h2 {
+ margin-bottom: 0;
+}
+.tile-state {
+ flex: none;
+ color: var(--subtext-0);
+ font: 650 var(--font-size-xs)/1 var(--font-mono);
+ letter-spacing: 0.06em;
+ padding: 0.48rem 0.62rem;
+ background: var(--surface-0);
+ border-radius: 99rem;
+}
+.tile-state.is-available {
+ color: var(--green);
+ background: rgb(166 227 161 / 8%);
+}
+.tile-state.is-partial {
+ color: var(--yellow);
+ background: rgb(249 226 175 / 8%);
+}
+.tile-content {
+ flex: 1;
+ min-height: 0;
+ margin-top: 1rem;
+ display: block;
+ overflow: auto;
+ scrollbar-width: thin;
+}
+.dashboard-data-message {
+ margin: 0 0 1rem;
+ padding: 0.65rem 0.8rem;
+ color: var(--subtext-0);
+ background: rgb(49 50 68 / 45%);
+ border-radius: 0.5rem;
+ font-size: var(--font-size-sm);
+}
+.dashboard-data-message.is-error {
+ color: var(--peach);
+}
+.dashboard-data-message.is-warning {
+ color: var(--yellow);
+}
+.tile-data-placeholder {
+ height: 100%;
+ display: grid;
+ align-content: center;
+ gap: 0.65rem;
+ opacity: 0.5;
+}
+.tile-data-placeholder span {
+ height: 0.55rem;
+ background: var(--surface-0);
+ border-radius: 99rem;
+}
+.tile-data-placeholder span:nth-child(2) {
+ width: 72%;
+}
+.tile-data-placeholder span:nth-child(3) {
+ width: 45%;
+}
+.unavailable-content {
+ height: 100%;
+ display: grid;
+ align-content: center;
+ justify-items: center;
+ text-align: center;
+ color: var(--subtext-0);
+}
+.unavailable-content > strong {
+ color: var(--lavender);
+ font: 500 1.7rem/1 var(--font-mono);
+}
+.unavailable-content p {
+ margin: 0.65rem 0 0;
+ color: var(--text);
+ font-weight: 600;
+}
+.unavailable-content small {
+ max-width: 24rem;
+ margin-top: 0.55rem;
+ color: var(--overlay-1);
+ line-height: 1.45;
+}
+.future-action {
+ margin-top: 1.1rem;
+ padding: 0.45rem 0.65rem;
+ color: var(--overlay-1);
+ border: 1px dashed var(--surface-1);
+ border-radius: 0.45rem;
+ font: 600 var(--font-size-xs) var(--font-mono);
+}
+.metric-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.8rem 1.5rem;
+ margin-bottom: 1rem;
+}
+.metric {
+ display: grid;
+}
+.metric strong {
+ color: var(--text);
+ font-size: var(--font-size-xl);
+ line-height: 1;
+}
+.metric span {
+ margin-top: 0.3rem;
+ color: var(--subtext-0);
+ font-size: var(--font-size-sm);
+}
+.activity-heatmap {
+ display: grid;
+ grid-template-rows: repeat(7, 0.72rem);
+ grid-auto-flow: column;
+ grid-auto-columns: minmax(0.35rem, 1fr);
+ gap: 0.22rem;
+ min-width: 0;
+}
+.activity-day {
+ background: var(--surface-0);
+ border-radius: 0.16rem;
+}
+.activity-day.is-active {
+ background: var(--lavender);
+}
+.activity-day.is-multiple {
+ background: var(--blue);
+ box-shadow: inset 0 0 0 1px rgb(255 255 255 / 25%);
+}
+.chart-legend {
+ margin: 0.75rem 0 0;
+ color: var(--overlay-1);
+ font-size: var(--font-size-xs);
+ line-height: 1.45;
+}
+.legend-swatch {
+ display: inline-block;
+ width: 0.55rem;
+ height: 0.55rem;
+ margin-right: 0.3rem;
+ background: var(--lavender);
+ border-radius: 0.12rem;
+}
+.primary-label {
+ margin: 0;
+ color: var(--text);
+ font-size: var(--font-size-lg);
+ line-height: 1.3;
+ font-weight: 650;
+}
+.secondary-label {
+ margin: 0.3rem 0 0;
+ color: var(--subtext-0);
+ font-size: var(--font-size-sm);
+ line-height: 1.4;
+}
+.progression-latest {
+ margin: 0.9rem 0;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 0.4rem 0.7rem;
+}
+.progression-latest strong {
+ color: var(--lavender);
+ font-size: clamp(1.6rem, 3vw, 2.5rem);
+}
+.progression-latest span {
+ color: var(--subtext-0);
+ font-size: var(--font-size-sm);
+}
+.progression-latest em {
+ color: var(--green);
+ font-size: var(--font-size-xs);
+ font-style: normal;
+}
+.progression-chart {
+ width: 100%;
+ height: 6.5rem;
+ min-height: 6rem;
+ margin-top: 0.7rem;
+}
+.tile-large .progression-content {
+ height: 100%;
+ display: grid;
+ grid-template-columns: minmax(14rem, 0.85fr) minmax(18rem, 1.15fr);
+ grid-template-rows: auto auto 1fr auto;
+ column-gap: 1.25rem;
+ align-items: start;
+}
+.tile-large .progression-chart {
+ grid-column: 2;
+ grid-row: 1 / span 4;
+ height: 100%;
+ min-height: 10rem;
+ margin-top: 0;
+}
+.chart-loading {
+ min-height: 8rem;
+ display: grid;
+ place-items: center;
+ color: var(--overlay-1);
+ font-size: var(--font-size-xs);
+}
+.fact-list {
+ margin: 0.8rem 0 0;
+}
+.fact-list.horizontal {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem 1.2rem;
+}
+.fact-list div {
+ min-width: 4rem;
+}
+.fact-list dt {
+ color: var(--overlay-1);
+ font: 650 var(--font-size-xs)/1.25 var(--font-mono);
+ letter-spacing: 0.035em;
+ text-transform: uppercase;
+}
+.fact-list dd {
+ margin: 0.25rem 0 0;
+ color: var(--text);
+ font-size: var(--font-size-md);
+ line-height: 1.3;
+}
+.zone-chips {
+ margin-top: 1rem;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+}
+.zone-chips span {
+ padding: 0.38rem 0.58rem;
+ color: var(--mauve);
+ background: rgb(203 166 247 / 8%);
+ border-radius: 99rem;
+ font-size: var(--font-size-xs);
+}
+.record-list,
+.muscle-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+.record-list {
+ display: grid;
+ gap: 0.72rem;
+}
+.record-list li {
+ min-width: 0;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.8rem;
+}
+.record-list div {
+ min-width: 0;
+ display: grid;
+ gap: 0.12rem;
+}
+.record-list strong {
+ overflow: hidden;
+ color: var(--text);
+ font-size: var(--font-size-md);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.record-list span {
+ overflow: hidden;
+ color: var(--overlay-1);
+ font-size: var(--font-size-xs);
+ line-height: 1.3;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.record-list b {
+ flex: none;
+ color: var(--peach);
+ font-size: 1.125rem;
+}
+.partial-note {
+ margin: 0.8rem 0 0;
+ color: var(--overlay-1);
+ font-size: var(--font-size-xs);
+}
+.muscle-list {
+ display: grid;
+ gap: 0.58rem;
+}
+.muscle-label {
+ min-width: 0;
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ color: var(--subtext-0);
+ font-size: var(--font-size-sm);
+ line-height: 1.3;
+}
+.muscle-label strong {
+ min-width: 0;
+ overflow: hidden;
+ color: var(--text);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.muscle-label span {
+ flex: none;
+}
+.muscle-track {
+ height: 0.3rem;
+ margin-top: 0.3rem;
+ overflow: hidden;
+ background: var(--surface-0);
+ border-radius: 99rem;
+}
+.muscle-track span {
+ display: block;
+ height: 100%;
+ background: var(--mauve);
+ border-radius: inherit;
+}
+.muscle-list p {
+ margin: 0.22rem 0 0;
+ color: var(--overlay-1);
+ font-size: var(--font-size-xs);
+ line-height: 1.3;
+}
+.muscle-content:has(.muscle-visual) {
+ display: grid;
+ grid-template-columns: minmax(12rem, 0.8fr) minmax(12rem, 1.2fr);
+ gap: 1rem;
+ align-items: start;
+}
+.tile-large .muscle-content:has(.muscle-visual) {
+ grid-template-columns: minmax(18rem, 1.15fr) minmax(16rem, 1.85fr);
+ gap: 1.25rem;
+}
+.body-zone-figure {
+ position: relative;
+ min-width: 0;
+}
+.body-views {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.45rem;
+}
+.body-views figure {
+ min-width: 0;
+ margin: 0;
+ text-align: center;
+}
+.body-views figcaption {
+ margin-bottom: 0.15rem;
+ color: var(--overlay-1);
+ font: 650 var(--font-size-xs) var(--font-mono);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+.body-views svg {
+ display: block;
+ width: 100%;
+ height: 10.5rem;
+ overflow: visible;
+}
+.tile-large .body-views svg {
+ height: 16.5rem;
+}
+.body-outline {
+ fill: var(--surface-0);
+ stroke: var(--surface-2);
+ stroke-width: 1.2;
+}
+.body-region {
+ stroke: var(--base);
+ stroke-width: 1.4;
+ vector-effect: non-scaling-stroke;
+ transition:
+ fill 140ms ease,
+ filter 140ms ease;
+}
+.body-region.intensity-0,
+.muscle-legend .intensity-0 {
+ fill: var(--surface-0);
+ background: var(--surface-0);
+}
+.body-region.intensity-1,
+.muscle-legend .intensity-1 {
+ fill: var(--sapphire);
+ background: var(--sapphire);
+ opacity: 0.62;
+}
+.body-region.intensity-2,
+.muscle-legend .intensity-2 {
+ fill: var(--lavender);
+ background: var(--lavender);
+ opacity: 0.82;
+}
+.body-region.intensity-3,
+.muscle-legend .intensity-3 {
+ fill: var(--mauve);
+ background: var(--mauve);
+}
+.body-region[tabindex]:hover,
+.body-region[tabindex]:focus {
+ outline: none;
+ filter: brightness(1.18) drop-shadow(0 0 0.18rem rgb(180 190 254 / 45%));
+ stroke: var(--text);
+}
+.muscle-legend {
+ margin-top: 0.35rem;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: 0.25rem 0.35rem;
+ color: var(--overlay-1);
+ font-size: var(--font-size-xs);
+ line-height: 1.3;
+}
+.muscle-legend span {
+ width: 0.65rem;
+ height: 0.65rem;
+ border-radius: 0.15rem;
+}
+.body-tooltip {
+ position: absolute;
+ z-index: 2;
+ right: 0.25rem;
+ bottom: 2.2rem;
+ max-width: calc(100% - 0.5rem);
+ padding: 0.5rem 0.65rem;
+ display: grid;
+ gap: 0.16rem;
+ color: var(--text);
+ background: rgb(24 24 37 / 96%);
+ border: 1px solid var(--surface-2);
+ border-radius: 0.45rem;
+ box-shadow: 0 0.6rem 1.5rem rgb(0 0 0 / 28%);
+ font-size: var(--font-size-xs);
+ pointer-events: none;
+}
+.body-tooltip span {
+ color: var(--subtext-0);
+}
+.muted {
+ color: var(--subtext-0);
+}
+.tile-compact .tile-content {
+ margin-top: 0.65rem;
+}
+.tile-compact .tile-header {
+ gap: 0.5rem;
+}
+.tile-compact .tile-state {
+ padding: 0.3rem 0.42rem;
+}
+.tile-compact .activity-heatmap {
+ grid-template-rows: repeat(5, 0.62rem);
+}
+.tile-compact .metric-row {
+ margin-bottom: 0.65rem;
+}
+.tile-compact .progression-latest {
+ margin-bottom: 0;
+}
+.empty-state {
+ max-width: 22rem;
+ color: var(--subtext-0);
+ text-align: center;
+ line-height: 1.5;
+}
+.empty-state p {
+ margin: 0.75rem 0 0;
+ overflow-wrap: anywhere;
+}
+.empty-glyph {
+ display: inline-grid;
+ width: 2.4rem;
+ height: 2.4rem;
+ place-items: center;
+ color: var(--lavender);
+ background: rgb(180 190 254 / 9%);
+ border-radius: 50%;
+ font: 600 1rem var(--font-mono);
+}
+.principle-panel {
+ grid-column: span 4;
+ min-height: 25rem;
+ padding: 1.6rem;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ color: var(--subtext-0);
+ background: var(--mantle);
+ border-radius: var(--radius);
+}
+.principle-panel h2 {
+ font-size: clamp(1.3rem, 2vw, 1.8rem);
+}
+.principle-panel p:last-child {
+ line-height: 1.65;
+}
+.principle-index {
+ color: var(--surface-1);
+ font: 700 4rem/1 var(--font-mono);
+ letter-spacing: -0.1em;
+}
-.status-footer { position: fixed; right: 0; bottom: 0; left: 0; z-index: 10; min-height: 3.75rem; padding: .65rem var(--shell-gutter); display: grid; grid-template-columns: 1fr 1.25fr 1.25fr auto; align-items: center; gap: 1.5rem; background: rgb(24 24 37 / 96%); border-top: 1px solid rgb(69 71 90 / 62%); backdrop-filter: blur(16px); }
-.status-block { min-width: 0; display: flex; flex-direction: column; gap: .16rem; }
-.status-label { color: var(--overlay-1); font: 700 var(--font-size-xs)/1.15 var(--font-mono); letter-spacing: .08em; }
-.status-value { overflow: hidden; color: var(--subtext-0); font-size: var(--font-size-md); line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; }
-.backend-state { display: flex; align-items: center; gap: .42rem; color: var(--green); font: 650 var(--font-size-xs)/1.2 var(--font-mono); white-space: nowrap; }
-.backend-state.is-error { color: var(--red); }
-.backend-dot { width: .45rem; height: .45rem; background: currentColor; border-radius: 50%; }
+.status-footer {
+ position: fixed;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 10;
+ min-height: 3.75rem;
+ padding: 0.65rem var(--shell-gutter);
+ display: grid;
+ grid-template-columns: 1fr 1.25fr 1.25fr auto;
+ align-items: center;
+ gap: 1.5rem;
+ background: rgb(24 24 37 / 96%);
+ border-top: 1px solid rgb(69 71 90 / 62%);
+ backdrop-filter: blur(16px);
+}
+.status-block {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.16rem;
+}
+.status-label {
+ color: var(--overlay-1);
+ font: 700 var(--font-size-xs)/1.15 var(--font-mono);
+ letter-spacing: 0.08em;
+}
+.status-value {
+ overflow: hidden;
+ color: var(--subtext-0);
+ font-size: var(--font-size-md);
+ line-height: 1.25;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.backend-state {
+ display: flex;
+ align-items: center;
+ gap: 0.42rem;
+ color: var(--green);
+ font: 650 var(--font-size-xs)/1.2 var(--font-mono);
+ white-space: nowrap;
+}
+.backend-state.is-error {
+ color: var(--red);
+}
+.backend-dot {
+ width: 0.45rem;
+ height: 0.45rem;
+ background: currentColor;
+ border-radius: 50%;
+}
@media (max-width: 70rem) {
- .app-shell { padding-bottom: 5.4rem; }
- .tile { grid-column: span 6; }
- .tile-feature { grid-column: span 8; }
- .principle-panel { grid-column: span 4; }
- .status-footer { grid-template-columns: 1fr 1fr 1fr; }
- .backend-state { grid-column: 1 / -1; justify-self: end; }
+ .app-shell {
+ padding-bottom: 5.4rem;
+ }
+ .tile {
+ grid-column: span 6;
+ }
+ .tile-feature {
+ grid-column: span 8;
+ }
+ .principle-panel {
+ grid-column: span 4;
+ }
+ .status-footer {
+ grid-template-columns: 1fr 1fr 1fr;
+ }
+ .backend-state {
+ grid-column: 1 / -1;
+ justify-self: end;
+ }
}
@media (max-width: 48rem) {
- .app-shell { padding-bottom: 8rem; }
- .app-header { align-items: stretch; flex-direction: column; gap: .55rem; padding-block: .7rem .45rem; }
- .brand { align-self: start; }
- .main-nav { margin-inline: calc(var(--shell-gutter) * -1); padding-inline: var(--shell-gutter); }
- .sync-control { margin-left: 0; justify-content: space-between; }
- .sync-details[open] { position: static; min-width: 0; }
- .page-heading { align-items: start; flex-direction: column; gap: 1rem; }
- .tile-grid { display: flex; flex-direction: column; }
- .tile, .tile-wide, .tile-feature, .principle-panel { width: 100%; grid-column: auto; }
- .tile { min-height: 14rem; }
- .principle-panel { min-height: 17rem; }
- .layout-toolbar { justify-content: flex-start; }
- .layout-help { flex-basis: 100%; }
- .tile-large .progression-content { height: auto; display: block; }
- .tile-large .progression-chart { height: 6.5rem; min-height: 6rem; margin-top: .7rem; }
- .muscle-content:has(.muscle-visual) { grid-template-columns: minmax(0, 1fr); }
- .body-views svg, .tile-large .body-views svg { height: 11rem; }
- .status-footer { grid-template-columns: 1fr 1fr; gap: .8rem 1.25rem; }
- .status-zone { grid-column: 1 / -1; }
- .backend-state { grid-column: 1 / -1; justify-self: start; }
+ .app-shell {
+ padding-bottom: 8rem;
+ }
+ .app-header {
+ align-items: stretch;
+ flex-direction: column;
+ gap: 0.55rem;
+ padding-block: 0.7rem 0.45rem;
+ }
+ .brand {
+ align-self: start;
+ }
+ .main-nav {
+ margin-inline: calc(var(--shell-gutter) * -1);
+ padding-inline: var(--shell-gutter);
+ }
+ .sync-control {
+ margin-left: 0;
+ justify-content: space-between;
+ }
+ .sync-details[open] {
+ position: static;
+ min-width: 0;
+ }
+ .page-heading {
+ align-items: start;
+ flex-direction: column;
+ gap: 1rem;
+ }
+ .tile-grid {
+ display: flex;
+ flex-direction: column;
+ }
+ .tile,
+ .tile-wide,
+ .tile-feature,
+ .principle-panel {
+ width: 100%;
+ grid-column: auto;
+ }
+ .tile {
+ min-height: 14rem;
+ }
+ .principle-panel {
+ min-height: 17rem;
+ }
+ .layout-toolbar {
+ justify-content: flex-start;
+ }
+ .layout-help {
+ flex-basis: 100%;
+ }
+ .tile-large .progression-content {
+ height: auto;
+ display: block;
+ }
+ .tile-large .progression-chart {
+ height: 6.5rem;
+ min-height: 6rem;
+ margin-top: 0.7rem;
+ }
+ .muscle-content:has(.muscle-visual) {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .body-views svg,
+ .tile-large .body-views svg {
+ height: 11rem;
+ }
+ .status-footer {
+ grid-template-columns: 1fr 1fr;
+ gap: 0.8rem 1.25rem;
+ }
+ .status-zone {
+ grid-column: 1 / -1;
+ }
+ .backend-state {
+ grid-column: 1 / -1;
+ justify-self: start;
+ }
}
@media (max-width: 28rem) {
- .app-shell { padding-bottom: 0; }
- .status-footer { position: static; grid-template-columns: 1fr; }
- .status-zone, .backend-state { grid-column: auto; }
- h1 { font-size: 2.3rem; }
+ .app-shell {
+ padding-bottom: 0;
+ }
+ .status-footer {
+ position: static;
+ grid-template-columns: 1fr;
+ }
+ .status-zone,
+ .backend-state {
+ grid-column: auto;
+ }
+ h1 {
+ font-size: 2.3rem;
+ }
}
@media (prefers-reduced-motion: reduce) {
- *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
- .react-grid-item { transition: none !important; }
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ transition-duration: 0.01ms !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ }
+ .react-grid-item {
+ transition: none !important;
+ }
}