From 6948e0bbd59c6ac6e204e3635a58ff02a56bbc2a Mon Sep 17 00:00:00 2001 From: fy59 Date: Sun, 20 Sep 2026 21:06:10 +0200 Subject: [PATCH] refactor(web): refine analysis visual presentation --- CHANGELOG.md | 8 + docs/current_state.md | 2 +- web/src/dashboard/DashboardTiles.test.tsx | 480 +++- web/src/dashboard/DashboardTiles.tsx | 698 +++++- web/src/dashboard/dashboardFormat.test.ts | 15 + web/src/dashboard/dashboardFormat.ts | 40 +- web/src/routes/AnalysisPage.test.tsx | 223 +- web/src/routes/AnalysisPage.tsx | 962 +++++++- web/src/theme/app.css | 2712 ++++++++++++++++++--- 9 files changed, 4367 insertions(+), 773 deletions(-) create mode 100644 web/src/dashboard/dashboardFormat.test.ts 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) =>
  1. {formatDateTime(point.timestamp, dateFormat)}, {formatWeight(point.weight_kg)}{point.improved ? ', amélioration' : ''}
  2. )}
} - {size === 'large' &&

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

} -
+ const progression = snapshot.data.progression; + if (!progression.available) + return ; + const first = progression.points[0]; + const last = progression.points.at(-1); + return ( +
+
+

{progression.identity.exercise_name}

+ {size !== "compact" && ( +

+ {progression.identity.equipment_label} +

+ )} +
+ {last === undefined ? ( +

Aucun point disponible

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

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

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

-
{size !== 'compact' && <>}
- {size === 'large' &&
{session.primary_zones.map((zone) => {zone.label})}
} -
+ const { dateFormat } = useDatePreferences(); + const session = snapshot.data.last_session; + if (!session.available) + return ; + return ( +
+

+ +

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

8 MAX récents affichés

}
+ const { dateFormat } = useDatePreferences(); + const records = snapshot.data.max_records.records; + if (!snapshot.data.max_records.available || records.length === 0) + return ; + const limit = size === "compact" ? 1 : size === "medium" ? 3 : 8; + return ( +
+
    + {records.slice(0, limit).map((record) => ( +
  1. +
    + {record.exercise_name} + + {formatDate(record.timestamp, dateFormat)} + {size === "large" && record.equipment_id + ? ` · ${record.equipment_id}` + : ""} + +
    + {formatWeight(record.weight_kg)} +
  2. + ))} +
+ {snapshot.meta.partial && records.length >= 8 && ( +

8 MAX récents affichés

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

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

} -

Longueur : nombre de séances sur 30 jours

    {shown.map((zone) => )}
Voir dans Analyse
-
+ 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

    {text}

    } +function Metric({ value, label }: { value: string; label: string }) { + return ( +
    + {value} + {label} +
    + ); +} +function Fact({ label, value }: { label: string; value: ReactNode }) { + return ( +
    +
    {label}
    +
    {value}
    +
    + ); +} +function Unavailable({ text }: { text: string }) { + return ( +
    + +

    {text}

    +
    + ); +} -export type DashboardTileComponent = ComponentType +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
    {ordered.map((point, index) => {new Intl.DateTimeFormat(language === 'fr' ? 'fr-FR' : 'en-US').format(new Date(point.timestamp))} · {formatNumber(point.value, language)} {unit})}
    {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} +
    + + + + + {formatNumber(maximum, language)} + + + {formatNumber(minimum, language)} + + {ordered.length > 1 && ( + + )} + {ordered.map((point, index) => ( + + + + {shortDate(point.timestamp, language)} ·{" "} + {formatValue(unit === "min" ? point.value * 60 : point.value)} + + + {(index === 0 || index === ordered.length - 1) && ( + + {shortDate(point.timestamp, language)} + + )} + + ))} + +
    + {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
    -

    {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 &&
    {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}

    + ) : ( +
    + +
      + {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.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; + } }