mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
6ad2dc858a
commit
2dc9947de2
5 files changed
+124
-47
No files matched your search
+61
-18
@@ -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"/);
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user