mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
61969fb025
commit
3f5816112b
12 files changed
+405
-6
No files matched your search
+10
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user