fix(dashboard): link metric tiles to Insights detail + unify tile header

- Dashboard metric tiles link to their /insights/<slug> detail page
  (weight, blood pressure, pulse, glucose, mood, sleep, steps, VO2 max);
  tiles with no dedicated sub-page stay un-linked rather than misroute.
- Daily-briefing key findings now route hrv, resting HR, active energy,
  flights, distance, body temperature and recovery to their sub-pages —
  the METRIC_HREF map had gone stale as those routes shipped.
- RecentWorkoutsTile adopts the canonical TileHeader (foreground icon +
  title) instead of a muted hand-rolled row.
- Hero-strip header glyph moves off text-primary to the foreground tier.
This commit is contained in:
Marc-André Bombeck committed 2026-07-08 23:35:41 +02:00
1 parent 6ad2dc858a
commit 2dc9947de2
5 files changed
+124 -47

No files matched your search

+61 -18
View File
@@ -25,6 +25,7 @@ import {
} from "@/lib/dashboard-layout";
import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics";
import dynamic from "next/dynamic";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
@@ -131,6 +132,30 @@ import {
resolveChartRowPlaceholderCount,
} from "@/components/dashboard/dashboard-gates";
/**
* Dashboard metric tiles are no longer dead-ends: each tile links to its
* Insights detail page (bands, correlations, history). Keyed by the stable
* tile `id` used in the `trendCards` builder; glucose contexts share one
* blood-glucose destination via the `glucose-` prefix. Tiles with no
* dedicated sub-page (body fat %) stay un-linked rather than misroute.
*/
const TILE_INSIGHT_HREF: Record<string, string> = {
weight: "/insights/weight",
"bp-sys": "/insights/blood-pressure",
"bp-dia": "/insights/blood-pressure",
bpInTarget: "/insights/blood-pressure",
pulse: "/insights/pulse",
mood: "/insights/mood",
sleep: "/insights/sleep",
steps: "/insights/steps",
vo2Max: "/insights/cardio-fitness",
};
function tileInsightHref(id: string): string | null {
if (id.startsWith("glucose-")) return "/insights/blood-glucose";
return TILE_INSIGHT_HREF[id] ?? null;
}
export default function DashboardPageClient() {
const { isAuthenticated, user } = useAuth();
const mounted = useMounted();
@@ -1611,27 +1636,45 @@ export default function DashboardPageClient() {
data-tour-id="dashboard-tile-strip"
data-tile-count={trendCards.length}
>
{trendCards.map((entry) => (
<div key={entry.id} className="flex min-w-0">
{/*
* Per-tile `<Suspense>` boundary with a layout-stable
* placeholder that mirrors the trend-card chrome. Tile
* bodies are synchronous today so the fallback rarely
* paints, but a future RSC hoist of any tile slot
* would otherwise leave the grid track empty and
* trigger CLS as the cell paints in.
*
* v1.4.40 W-RSC — boundary added; v1.4.41 W-FRONTEND-FACTORY — fallback hoisted to layout-stable placeholder.
*/}
{/* v1.16.0 — the fallback is the same structured
silhouette the tile-strip skeleton paints, so a
suspending tile slot previews the final shape
instead of an empty card. */}
{trendCards.map((entry) => {
/*
* Per-tile `<Suspense>` boundary with a layout-stable
* placeholder that mirrors the trend-card chrome. Tile
* bodies are synchronous today so the fallback rarely
* paints, but a future RSC hoist of any tile slot
* would otherwise leave the grid track empty and
* trigger CLS as the cell paints in.
*
* v1.16.0 — the fallback is the same structured
* silhouette the tile-strip skeleton paints, so a
* suspending tile slot previews the final shape
* instead of an empty card.
*/
const cell = (
<Suspense fallback={<TrendCardSkeleton />}>
{entry.node}
</Suspense>
</div>
))}
);
// A tile is no longer a dead-end: it links to its Insights
// detail page. The link fills the grid cell and paints a
// ring on hover / focus so the whole tile reads as tappable.
const href = tileInsightHref(entry.id);
return href ? (
<Link
key={entry.id}
href={href}
data-slot="dashboard-tile-link"
data-tile-id={entry.id}
className="group focus-visible:ring-ring/50 flex min-w-0 rounded-xl transition-[box-shadow] hover:ring-2 hover:ring-ring/30 focus-visible:ring-2 focus-visible:outline-none"
>
{cell}
</Link>
) : (
<div key={entry.id} className="flex min-w-0">
{cell}
</div>
);
})}
</div>
)}
{charts.map((entry) => (
@@ -15,6 +15,7 @@ import {
import { useTranslations } from "@/lib/i18n/context";
import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts";
import { cn } from "@/lib/utils";
import { TileHeader } from "@/components/insights/tile-header";
/**
* v1.4.32 — `<RecentWorkoutsTile>`.
@@ -107,23 +108,23 @@ export function RecentWorkoutsTile() {
"bg-card border-border space-y-3 rounded-xl border p-4 md:p-6",
)}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Activity
className="text-muted-foreground 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 inline-flex min-h-11 items-center text-xs"
>
{t("dashboard.recentWorkouts.viewAll")}
</Link>
</div>
{/* Canonical tile header (foreground icon + title) — the same
`TileHeader` contract the Insights tiles use, so the dashboard
stops speaking a separate header language. `size="sm"` matches the
compact dashboard tile. */}
<TileHeader
icon={Activity}
size="sm"
title={t("dashboard.recentWorkouts.title")}
right={
<Link
href="/insights/workouts"
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-xs"
>
{t("dashboard.recentWorkouts.viewAll")}
</Link>
}
/>
{isLoading ? (
// v1.4.43 W11-L6 — reserve roughly the loaded tile height
@@ -95,6 +95,36 @@ describe("<DailyBriefing>", () => {
expect(html).toContain("Weight down 30 d");
});
it("links the key-finding rows for metrics whose Insights sub-page exists", () => {
// Regression guard for the stale METRIC_HREF map: hrv / resting_hr /
// active_energy / flights / distance / body_temp / recovery all have
// dedicated Insights sub-pages now, so their briefing rows must be
// tappable `<a>` links, not inert rows.
const html = render(
<DailyBriefing
briefing={{
...baseBriefing,
keyFindings: [
{ ...baseBriefing.keyFindings[2], sourceMetric: "hrv" },
{ ...baseBriefing.keyFindings[2], sourceMetric: "resting_hr" },
{ ...baseBriefing.keyFindings[2], sourceMetric: "active_energy" },
{ ...baseBriefing.keyFindings[2], sourceMetric: "flights" },
{ ...baseBriefing.keyFindings[2], sourceMetric: "distance" },
{ ...baseBriefing.keyFindings[2], sourceMetric: "body_temp" },
{ ...baseBriefing.keyFindings[2], sourceMetric: "recovery" },
],
}}
/>,
);
expect(html).toContain('href="/insights/hrv"');
expect(html).toContain('href="/insights/resting-pulse"');
expect(html).toContain('href="/insights/active-energy"');
expect(html).toContain('href="/insights/flights-climbed"');
expect(html).toContain('href="/insights/walking-distance"');
expect(html).toContain('href="/insights/body-temperature"');
expect(html).toContain('href="/insights/recovery"');
});
it("renders the delta string when supplied", () => {
const html = render(<DailyBriefing briefing={baseBriefing} />);
expect(html).toMatch(/data-slot="daily-briefing-delta"/);
+11 -11
View File
@@ -53,28 +53,28 @@ export const METRIC_HREF: Record<
pulse: "/insights/pulse",
mood: "/insights/mood",
compliance: "/insights/medications",
// Apple Health / GLP-1 additive metrics — no dedicated sub-page yet.
// They render as static rows; v1.4.28 can wire each as they ship.
hrv: null,
// Apple Health / GLP-1 additive metrics — each now routes to its
// dedicated sub-page that shipped since the original map was written.
hrv: "/insights/hrv",
sleep: "/insights/sleep",
resting_hr: null,
resting_hr: "/insights/resting-pulse",
// v1.12.4 — steps has no dedicated metric sub-page, so it routes to the
// generic readings view keyed to its `MeasurementType` (the same target
// the category pages use to drill into a single type). Every other
// briefing finding with a real destination is already tappable; steps
// was the lone static row.
steps: "/insights/steps",
active_energy: null,
flights: null,
distance: null,
active_energy: "/insights/active-energy",
flights: "/insights/flights-climbed",
distance: "/insights/walking-distance",
vo2_max: "/insights/pulse",
body_temp: null,
body_temp: "/insights/body-temperature",
glp1_plateau: "/insights/medications",
// ── v1.10.0 derived-wellness additive ──
// Readiness has a score-anatomy detail page; recovery is a read-only
// ring with no dedicated sub-page yet (renders as a static row).
// Readiness has a score-anatomy detail page; recovery has its own
// read-only sub-page.
readiness: "/insights/scores/readiness",
recovery: null,
recovery: "/insights/recovery",
};
/**
+4 -1
View File
@@ -284,8 +284,11 @@ export function HeroStrip({
<div className="flex min-w-0 flex-1 flex-col gap-5">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
{/* Header glyph stays in the foreground tier — UI-STANDARDS §1
reserves primary/accent for actions, not decorative header
icons. */}
<Sparkles
className="text-primary h-5 w-5 shrink-0"
className="text-foreground h-5 w-5 shrink-0"
aria-hidden="true"
/>
<h1