From 3f5816112b6737110b1925a0d1f9d85669e04263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 15:47:44 +0200 Subject: [PATCH] feat(dashboard): recent workouts tile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `` as a default-visible dashboard widget (`recentWorkouts`). The tile reads the top three canonical workouts through `useWorkouts({ limit: 3 })`, renders each as a deep-link row with the sport icon + the day + the duration, and falls back to an Apple-Health-onboarding cue when the workout list is empty. `DASHBOARD_WIDGET_IDS` + `DEFAULT_DASHBOARD_LAYOUT.widgets` carry the new id; `widgetIdEnum` on `/api/dashboard/widgets` picks it up from the same source-of-truth array. The Settings → Dashboard layout editor surfaces it under the existing widget toggle list so users can opt out without touching the layout JSON. Six locales updated. --- messages/de.json | 11 +- messages/en.json | 11 +- messages/es.json | 11 +- messages/fr.json | 11 +- messages/it.json | 11 +- messages/pl.json | 11 +- src/app/page.tsx | 14 ++ .../__tests__/recent-workouts-tile.test.tsx | 134 ++++++++++++++ .../dashboard/recent-workouts-tile.tsx | 173 ++++++++++++++++++ .../settings/dashboard-layout-section.tsx | 2 + src/lib/__tests__/dashboard-layout.test.ts | 9 + src/lib/dashboard-layout.ts | 13 ++ 12 files changed, 405 insertions(+), 6 deletions(-) create mode 100644 src/components/dashboard/__tests__/recent-workouts-tile.test.tsx create mode 100644 src/components/dashboard/recent-workouts-tile.tsx diff --git a/messages/de.json b/messages/de.json index 0a09b0432..02eaf4e21 100644 --- a/messages/de.json +++ b/messages/de.json @@ -258,7 +258,16 @@ "layoutChartColumn": "Chart", "emptyTitle": "Dein Dashboard ist leer", "emptyDescription": "Erfasse den ersten Messwert oder Stimmungseintrag. Kacheln und Charts erscheinen automatisch, sobald Daten vorhanden sind.", - "emptyAddMeasurement": "Ersten Messwert erfassen" + "emptyAddMeasurement": "Ersten Messwert erfassen", + "recentWorkouts": { + "title": "Letzte Workouts", + "viewAll": "Alle anzeigen", + "loading": "Workouts werden geladen…", + "empty": { + "title": "Noch keine Workouts", + "cta": "Öffne die Apple-Health-App auf dem iPhone, damit Workouts hier erscheinen." + } + } }, "measurements": { "title": "Messungen", diff --git a/messages/en.json b/messages/en.json index e795a87ad..930a8d047 100644 --- a/messages/en.json +++ b/messages/en.json @@ -258,7 +258,16 @@ "layoutChartColumn": "Chart", "emptyTitle": "Your dashboard is empty", "emptyDescription": "Log your first measurement or mood entry. Tiles and charts appear automatically as data lands.", - "emptyAddMeasurement": "Add your first measurement" + "emptyAddMeasurement": "Add your first measurement", + "recentWorkouts": { + "title": "Recent workouts", + "viewAll": "View all", + "loading": "Loading workouts…", + "empty": { + "title": "No workouts yet", + "cta": "Open Apple Health on your iPhone to sync workouts here." + } + } }, "measurements": { "title": "Measurements", diff --git a/messages/es.json b/messages/es.json index d38f8c991..29809030d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -258,7 +258,16 @@ "layoutChartColumn": "Gráfico", "emptyTitle": "Your dashboard is empty", "emptyDescription": "Log your first measurement or mood entry. Tiles and charts appear automatically as data lands.", - "emptyAddMeasurement": "Add your first measurement" + "emptyAddMeasurement": "Add your first measurement", + "recentWorkouts": { + "title": "Entrenamientos recientes", + "viewAll": "Ver todos", + "loading": "Cargando entrenamientos…", + "empty": { + "title": "Aún no hay entrenamientos", + "cta": "Abre la app Apple Salud en el iPhone para sincronizar los entrenamientos." + } + } }, "measurements": { "title": "Measurements", diff --git a/messages/fr.json b/messages/fr.json index b5e93317c..ad78d7b30 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -258,7 +258,16 @@ "layoutChartColumn": "Graphique", "emptyTitle": "Your dashboard is empty", "emptyDescription": "Log your first measurement or mood entry. Tiles and charts appear automatically as data lands.", - "emptyAddMeasurement": "Add your first measurement" + "emptyAddMeasurement": "Add your first measurement", + "recentWorkouts": { + "title": "Entraînements récents", + "viewAll": "Tout voir", + "loading": "Chargement des entraînements…", + "empty": { + "title": "Aucun entraînement", + "cta": "Ouvre l'app Apple Santé sur ton iPhone pour synchroniser les entraînements." + } + } }, "measurements": { "title": "Measurements", diff --git a/messages/it.json b/messages/it.json index 457227f4c..e4065b941 100644 --- a/messages/it.json +++ b/messages/it.json @@ -258,7 +258,16 @@ "layoutChartColumn": "Grafico", "emptyTitle": "Your dashboard is empty", "emptyDescription": "Log your first measurement or mood entry. Tiles and charts appear automatically as data lands.", - "emptyAddMeasurement": "Add your first measurement" + "emptyAddMeasurement": "Add your first measurement", + "recentWorkouts": { + "title": "Allenamenti recenti", + "viewAll": "Vedi tutti", + "loading": "Caricamento allenamenti…", + "empty": { + "title": "Nessun allenamento", + "cta": "Apri l'app Salute di Apple sull'iPhone per sincronizzare gli allenamenti." + } + } }, "measurements": { "title": "Measurements", diff --git a/messages/pl.json b/messages/pl.json index 865e2cb50..db1c5a183 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -258,7 +258,16 @@ "layoutChartColumn": "Wykres", "emptyTitle": "Your dashboard is empty", "emptyDescription": "Log your first measurement or mood entry. Tiles and charts appear automatically as data lands.", - "emptyAddMeasurement": "Add your first measurement" + "emptyAddMeasurement": "Add your first measurement", + "recentWorkouts": { + "title": "Ostatnie treningi", + "viewAll": "Zobacz wszystkie", + "loading": "Wczytywanie treningów…", + "empty": { + "title": "Brak treningów", + "cta": "Otwórz aplikację Apple Zdrowie na iPhonie, aby zsynchronizować treningi." + } + } }, "measurements": { "title": "Measurements", diff --git a/src/app/page.tsx b/src/app/page.tsx index 9a91e9904..3031cff3f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -44,6 +44,7 @@ import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta"; import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist"; import { TourLauncher } from "@/components/onboarding/tour-launcher"; import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card"; +import { RecentWorkoutsTile } from "@/components/dashboard/recent-workouts-tile"; const MoodChart = dynamic( () => @@ -347,6 +348,10 @@ export default function DashboardPage() { // the layout-toggle gate here. No data-floor check (the empty card is // intentional — the maintainer wants the user to discover the feature). const showAchievementsCard = isChartVisible("achievements"); + // v1.4.32 — recent workouts dashboard tile. Self-gates on the + // workouts query response so we only need the layout toggle here; + // the tile renders an Apple-Health-onboarding hint when empty. + const showRecentWorkoutsTile = isChartVisible("recentWorkouts"); // Glucose widget — visible iff layout enables it AND at least one reading exists. // Glucose has no separate chart slot today, so the tile flag is the @@ -1147,6 +1152,15 @@ export default function DashboardPage() { node: , }); } + if (showRecentWorkoutsTile) { + // v1.4.32 — slotted via the same `order` mechanism. Self-gates + // on the workouts query response inside the tile itself. + charts.push({ + id: "recentWorkouts", + order: widgetOrder("recentWorkouts"), + node: , + }); + } charts.sort((a, b) => a.order - b.order); diff --git a/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx new file mode 100644 index 000000000..1bc2d2b61 --- /dev/null +++ b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { WorkoutListEntry } from "@/hooks/use-workouts"; + +/** + * v1.4.32 — `` contract. + * + * - Empty state surfaces the Apple-Health-sync onboarding cue. + * - Populated state renders the three most-recent rows + a "View + * all" link to `/insights/workouts`. + * - Loading state renders a brief placeholder. + */ + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true }), +})); + +let mockResult: { + data: + | { + workouts: WorkoutListEntry[]; + meta: { + total: number; + limit: number; + offset: number; + droppedDuplicates: number; + }; + } + | undefined; + isLoading: boolean; + isEmpty: boolean; + error: Error | null; +} = { + data: undefined, + isLoading: false, + isEmpty: false, + error: null, +}; + +vi.mock("@/hooks/use-workouts", async () => { + const actual = + await vi.importActual( + "@/hooks/use-workouts", + ); + return { + ...actual, + useWorkouts: () => mockResult, + }; +}); + +import { RecentWorkoutsTile } from "../recent-workouts-tile"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockResult = { + data: undefined, + isLoading: false, + isEmpty: false, + error: null, + }; +}); + +describe("", () => { + it("renders the loading placeholder while the query is in flight", () => { + mockResult.isLoading = true; + const html = render(); + expect(html).toContain('data-slot="recent-workouts-loading"'); + expect(html).toContain("Loading workouts"); + }); + + it("renders the empty-state with the Apple-Health onboarding cue", () => { + mockResult.data = { + workouts: [], + meta: { total: 0, limit: 3, offset: 0, droppedDuplicates: 0 }, + }; + const html = render(); + expect(html).toContain('data-slot="recent-workouts-empty"'); + expect(html).toContain("No workouts yet"); + expect(html).toContain("Apple Health"); + }); + + it("renders the populated list with deep-links into each detail page", () => { + mockResult.data = { + workouts: [ + { + id: "w-1", + sportType: "running", + startedAt: "2026-05-15T07:00:00Z", + endedAt: "2026-05-15T07:30:00Z", + durationSec: 1800, + distanceM: 5200, + activeEnergyKcal: 320, + avgHr: 145, + maxHr: 170, + source: "APPLE_HEALTH", + externalId: "ext-w-1", + }, + { + id: "w-2", + sportType: "cycling", + startedAt: "2026-05-14T17:00:00Z", + endedAt: "2026-05-14T18:00:00Z", + durationSec: 3600, + distanceM: 24000, + activeEnergyKcal: 600, + avgHr: 138, + maxHr: 162, + source: "WITHINGS", + externalId: "wi-1", + }, + ], + meta: { total: 2, limit: 3, offset: 0, droppedDuplicates: 0 }, + }; + const html = render(); + expect(html).toContain('data-slot="recent-workouts-tile"'); + expect(html).toContain('href="/insights/workouts/w-1"'); + expect(html).toContain('href="/insights/workouts/w-2"'); + expect(html).toContain('href="/insights/workouts"'); + expect(html).toContain("Running"); + expect(html).toContain("Cycling"); + expect(html).toContain("30m"); + expect(html).toContain("1h 00m"); + }); +}); diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx new file mode 100644 index 000000000..88b5a2a3f --- /dev/null +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -0,0 +1,173 @@ +"use client"; + +import Link from "next/link"; +import { + Activity, + Bike, + Dumbbell, + Footprints, + HeartPulse, + Mountain, + PersonStanding, + type LucideIcon, +} from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts"; +import { cn } from "@/lib/utils"; + +/** + * v1.4.32 — ``. + * + * Dashboard card that surfaces the user's three most-recent workouts, + * one row each, with the sport icon + the day + the duration. The + * tile is gated on the `recentWorkouts` widget id in + * `DEFAULT_DASHBOARD_LAYOUT` — operators / users opt out via Settings + * → Dashboard. Empty-state mounts a neutral notice that steers the + * user toward the Apple Health sync onboarding cue rather than a + * dead "Add workout" button (the web has no manual workout-entry + * form today; the iOS client owns the ingest path). + * + * Cache: shares the `["workouts", "recent", { limit: 3 }]` slot with + * the canonical `useWorkouts({ limit: 3 })` consumer so navigating + * between the dashboard and `/insights/workouts` is a single + * round-trip per session. + */ + +const SPORT_TYPE_ICON: Record = { + walking: Footprints, + running: PersonStanding, + cycling: Bike, + hiking: Mountain, + swimming: Activity, + rowing: Activity, + elliptical: Activity, + stairClimber: Activity, + yoga: PersonStanding, + mindAndBody: PersonStanding, + strength: Dumbbell, + hiit: Activity, + dance: Activity, + golf: Activity, + tennis: Activity, + basketball: Activity, + soccer: Activity, + crossTraining: Activity, + mixedCardio: HeartPulse, + other: Activity, +}; + +function iconForSport(sportType: string): LucideIcon { + return SPORT_TYPE_ICON[sportType] ?? Activity; +} + +function formatDuration(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (h > 0) return `${h}h ${m.toString().padStart(2, "0")}m`; + return `${m}m`; +} + +function formatDayLabel(iso: string, locale: string): string { + const d = new Date(iso); + return new Intl.DateTimeFormat(locale, { + weekday: "short", + day: "2-digit", + month: "short", + }).format(d); +} + +function renderRow(workout: WorkoutListEntry, sportName: string) { + const Icon = iconForSport(workout.sportType); + return ( + + + {sportName} + + ); +} + +export function RecentWorkoutsTile() { + const { t, locale } = useTranslations(); + const { data, isLoading } = useWorkouts({ limit: 3 }); + const workouts = data?.workouts ?? []; + + return ( +
+
+
+
+ + {t("dashboard.recentWorkouts.viewAll")} + +
+ + {isLoading ? ( +

+ {t("dashboard.recentWorkouts.loading")} +

+ ) : workouts.length === 0 ? ( +
+

+ {t("dashboard.recentWorkouts.empty.title")} +

+

+ {t("dashboard.recentWorkouts.empty.cta")} +

+
+ ) : ( +
    + {workouts.map((workout) => { + const sportLabelKey = `insights.workouts.sport.${workout.sportType}`; + const sportLabel = t(sportLabelKey); + const sportName = + sportLabel === sportLabelKey ? workout.sportType : sportLabel; + return ( +
  • + + {renderRow(workout, sportName)} + + {formatDayLabel(workout.startedAt, locale)} + + + {formatDuration(workout.durationSec)} + + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 3b445a1ec..a2338e6c6 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -46,6 +46,8 @@ const WIDGET_LABEL_KEYS: Record = { achievements: "achievements.title", // v1.4.25 W8d — VO2 max secondary-metric tile (opt-in). vo2Max: "dashboard.vo2Max", + // v1.4.32 — Recent workouts tile (default-on). + recentWorkouts: "dashboard.recentWorkouts.title", }; export function DashboardLayoutSection({ id }: { id: string }) { diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index ccebc0c15..bf41f6133 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -141,6 +141,15 @@ describe("DEFAULT_DASHBOARD_LAYOUT contract", () => { expect(ids).toContain("achievements"); }); + it("includes the recentWorkouts widget (v1.4.32) default-visible", () => { + const widget = DEFAULT_DASHBOARD_LAYOUT.widgets.find( + (w) => w.id === "recentWorkouts", + ); + expect(widget).toBeDefined(); + expect(widget?.visible).toBe(true); + expect(widget?.tileVisible).toBe(true); + }); + it("does NOT advertise a retired insightsPreview widget (v1.4.27 B1)", () => { // v1.4.27 B1 retired the standalone dashboard preview because it // duplicated the much-richer `/insights` advisor surface. The diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 0c6b542b0..fb6e73331 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -35,6 +35,13 @@ export const DASHBOARD_WIDGET_IDS = [ // Settings → Dashboard. Only the tile-strip surface; chart row // lands alongside the iOS-app body-composition sub-page in v1.5. "vo2Max", + // v1.4.32 — "Recent workouts" tile. Surfaces the three most-recent + // canonical workouts; self-gates on at least one row. Default-on + // (`visible: true`) because the maintainer wants new accounts to + // see the surface even when empty so the Apple-Health onboarding + // cue is discoverable from the dashboard rather than hidden + // behind Settings. + "recentWorkouts", ] as const; export type DashboardWidgetId = (typeof DASHBOARD_WIDGET_IDS)[number]; @@ -216,6 +223,12 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = { // representation today (`visible` stays false so the chart-row // gate never fires; the chart card lands in v1.5). { id: "vo2Max", visible: false, tileVisible: false, order: 14 }, + // v1.4.32 — Recent workouts dashboard tile. Default-visible so + // brand-new accounts discover the workout surface without + // hunting through Settings; the tile self-gates on a non-empty + // workouts list and renders an Apple-Health onboarding hint + // otherwise. + { id: "recentWorkouts", visible: true, tileVisible: true, order: 15 }, ], };