feat(dashboard): recent workouts tile

Adds `<RecentWorkoutsTile>` 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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 15:47:44 +02:00
1 parent 61969fb025
commit 3f5816112b
12 files changed
+405 -6

No files matched your search

+10 -1
View File
@@ -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",
+10 -1
View File
@@ -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",
+10 -1
View File
@@ -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",
+10 -1
View File
@@ -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",
+10 -1
View File
@@ -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",
+10 -1
View File
@@ -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",
+14
View File
@@ -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: <RecentAchievementsCard key="achievements" />,
});
}
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: <RecentWorkoutsTile key="recentWorkouts" />,
});
}
charts.sort((a, b) => a.order - b.order);
@@ -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 — `<RecentWorkoutsTile>` 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<typeof import("@/hooks/use-workouts")>(
"@/hooks/use-workouts",
);
return {
...actual,
useWorkouts: () => mockResult,
};
});
import { RecentWorkoutsTile } from "../recent-workouts-tile";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<RecentWorkoutsTile />
</I18nProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
mockResult = {
data: undefined,
isLoading: false,
isEmpty: false,
error: null,
};
});
describe("<RecentWorkoutsTile>", () => {
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");
});
});
@@ -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 — `<RecentWorkoutsTile>`.
*
* 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<string, LucideIcon> = {
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 (
<span
data-slot="recent-workouts-row"
className="flex items-center gap-3 text-sm"
>
<span
aria-hidden="true"
className="bg-muted text-muted-foreground flex size-7 shrink-0 items-center justify-center rounded-full"
>
<Icon className="size-3.5" />
</span>
<span className="min-w-0 flex-1 truncate font-medium">{sportName}</span>
</span>
);
}
export function RecentWorkoutsTile() {
const { t, locale } = useTranslations();
const { data, isLoading } = useWorkouts({ limit: 3 });
const workouts = data?.workouts ?? [];
return (
<div
data-slot="recent-workouts-tile"
className={cn(
"bg-card border-border space-y-3 rounded-xl border p-4",
)}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Activity className="text-primary size-4" aria-hidden="true" />
<h2 className="text-sm font-semibold">
{t("dashboard.recentWorkouts.title")}
</h2>
</div>
<Link
href="/insights/workouts"
className="text-muted-foreground hover:text-foreground text-xs"
>
{t("dashboard.recentWorkouts.viewAll")}
</Link>
</div>
{isLoading ? (
<p
data-slot="recent-workouts-loading"
className="text-muted-foreground text-xs"
>
{t("dashboard.recentWorkouts.loading")}
</p>
) : workouts.length === 0 ? (
<div data-slot="recent-workouts-empty" className="space-y-1">
<p className="text-sm">
{t("dashboard.recentWorkouts.empty.title")}
</p>
<p className="text-muted-foreground text-xs">
{t("dashboard.recentWorkouts.empty.cta")}
</p>
</div>
) : (
<ul className="space-y-2">
{workouts.map((workout) => {
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
const sportLabel = t(sportLabelKey);
const sportName =
sportLabel === sportLabelKey ? workout.sportType : sportLabel;
return (
<li key={workout.id}>
<Link
href={`/insights/workouts/${encodeURIComponent(workout.id)}`}
data-slot="recent-workouts-link"
className={cn(
"flex items-center gap-2 rounded-md px-1 py-1 transition-colors",
"hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
)}
>
{renderRow(workout, sportName)}
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{formatDayLabel(workout.startedAt, locale)}
</span>
<span className="ml-1 shrink-0 text-xs font-medium tabular-nums">
{formatDuration(workout.durationSec)}
</span>
</Link>
</li>
);
})}
</ul>
)}
</div>
);
}
@@ -46,6 +46,8 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
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 }) {
@@ -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
+13
View File
@@ -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 },
],
};