From b92728d5e86d70a7dde89370acb4a242e80277d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 12:33:10 +0200 Subject: [PATCH] insights: lift the wellness strip above the daily briefing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wellness-score strip rendered inside the vitals dashboard, below the daily briefing. Lift it out so it sits as its own full-width section directly under the hero and above the briefing — the overview spine is now hero, wellness, briefing, vitals, trends, retrospective, signals. The page now owns the one shared derived batch and feeds the same query instance to both the wellness strip and the vitals grid, so the lift adds no second request. Each wellness tile and the section heading lead with the canonical TileHeader (a per-metric icon plus the metric name in the foreground colour), matching the rest of the Insights card language. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/insights/page.tsx | 58 +++++++--- .../__tests__/vitals-dashboard.test.tsx | 82 ++++++-------- .../__tests__/wellness-scores.test.tsx | 105 ++++++++++++++++++ .../insights/derived/use-dashboard-derived.ts | 88 +++++++++++++++ .../insights/derived/vitals-dashboard.tsx | 85 +++----------- .../insights/derived/wellness-scores.tsx | 42 ++++--- 6 files changed, 314 insertions(+), 146 deletions(-) create mode 100644 src/components/insights/derived/__tests__/wellness-scores.test.tsx create mode 100644 src/components/insights/derived/use-dashboard-derived.ts diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index a98cfe275..19b1f2235 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -18,6 +18,7 @@ import { HeroStrip } from "@/components/insights/hero-strip"; import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; +import { useDashboardDerived } from "@/components/insights/derived/use-dashboard-derived"; // v1.4.41 W-ORG — shared shape lives in `src/types/analytics.ts` as // `InsightsAnalyticsData`; aliased back to the local name to keep the // rest of this file readable. @@ -78,6 +79,20 @@ const TrendsRow = dynamic( loading: () => , }, ); +// v1.12.6 — the wellness-score strip, lifted OUT of the vitals dashboard so +// it renders as its own full-width section directly above the daily briefing. +// Both this strip and the vitals grid read the ONE shared derived batch the +// page owns (passed in via `batch`), so the lift adds no second request. +const WellnessScores = dynamic( + () => + import("@/components/insights/derived").then((mod) => ({ + default: mod.WellnessScores, + })), + { + ssr: false, + loading: () => , + }, +); // v1.10.0 — the Vitals dashboard (Apple-Health-Highlights grid of // personal-typical-range + passthrough-reframe tiles). Deferred behind // `next/dynamic` like the other below-the-fold blocks; each tile owns its @@ -224,6 +239,11 @@ export default function InsightsPage() { const analyticsQuery = useAnalyticsQuery(); const analytics = analyticsQuery.data as AnalyticsData | undefined; + // v1.12.6 — the page owns the ONE overview derived batch. The wellness + // strip (above the briefing) and the vitals grid (below it) both read this + // single query instance, so the wellness lift adds no second request. + const dashboardDerived = useDashboardDerived(isAuthenticated); + // Empty-state shortcut — only paint once the comprehensive query has // resolved AND reported zero measurements. While it's in-flight we // fall through to the streamed shell so the user gets the hero + @@ -265,22 +285,25 @@ export default function InsightsPage() { // server children would require a Server-Component refactor; that's // a v1.5.x track. - // v1.12.4 — overview section order, top → bottom: - // 1. "Guten Morgen" Hero + Coach questions (HeroStrip) + the detailed - // "Heute auf einen Blick" briefing right below it (same briefing - // cluster, stays at the top). - // 2. Wellnesswerte ┐ both live inside , which renders - // 3. Vitalwerte ┘ the wellness-score strip first, then the vitals - // grid — so a single mount already gives the 2 → 3 sequence. - // 4. Trends (TrendsRow). - // 5. "Dein Zeitraum im Rückblick" retrospective (PeriodNarrativeCard). - // 6. "Signale des Tages" — today's signal (CoincidentDeviationCard) plus + // v1.12.6 — overview section order, top → bottom: + // 1. "Guten Morgen" Hero + Coach questions (HeroStrip). + // 2. Wellnesswerte — the wellness-score strip (lifted OUT of + // so it sits ABOVE the briefing as its own + // full-width section). + // 3. "Heute auf einen Blick" briefing (DailyBriefing). + // 4. Vitalwerte — the vitals grid (+ mobility) inside , + // which no longer renders the wellness strip. + // 5. Trends (TrendsRow). + // 6. "Dein Zeitraum im Rückblick" retrospective (PeriodNarrativeCard). + // 7. "Signale des Tages" — today's signal (CoincidentDeviationCard) plus // the rhythm-events alert timeline, kept out of the AI gate so a // health alert is never hidden. - // The per-metric correlation cards moved onto the metric pages, so the - // overview renders no correlation row. The duplicate footer "prepare - // assessments" control was removed in v1.12.4 — the tab-strip regenerate - // button is the single affordance. + // The wellness strip and the vitals grid share the ONE `dashboardDerived` + // batch the page owns — one request, two sections. The per-metric + // correlation cards moved onto the metric pages, so the overview renders no + // correlation row. The duplicate footer "prepare assessments" control was + // removed in v1.12.4 — the tab-strip regenerate button is the single + // affordance. The generic disclaimer lives once in the layout-shell footer. return (
+ + {flags.briefing && ( )} - + ({ - useDerivedBatch: (...a: unknown[]) => useDerivedBatch(...a), -})); -// The auth hook gates the (mocked-away) query — stub it authenticated. -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ isAuthenticated: true }), -})); +import type { DashboardDerived } from "../use-dashboard-derived"; import { VitalsDashboard } from "../vitals-dashboard"; @@ -58,33 +47,36 @@ function insufficient(reason: string, historyDays = 0): Resp { } /** - * Install a batch mock that resolves each requested token from `resolve`, - * defaulting unmatched tokens to the absent state so a scenario only has to - * name the tiles it cares about. + * Build a shared-batch handle that resolves each requested token from + * `resolve`, defaulting unmatched tokens to the absent state so a scenario + * only has to name the tiles it cares about. The page owns this batch in + * production; the dashboard now receives it via the `batch` prop, so the test + * passes a stub handle directly instead of mocking the query hook. */ function mockBatch( resolve: (token: DerivedBatchToken) => Resp, extra: { isLoading?: boolean; isError?: boolean; refetch?: () => void } = {}, -) { - useDerivedBatch.mockReturnValue({ +): DashboardDerived { + return { isLoading: extra.isLoading ?? false, isError: extra.isError ?? false, refetch: extra.refetch ?? (() => {}), - read: (token: DerivedBatchToken) => resolve(token) as unknown as DerivedMetricResponse, - }); + read: (token: DerivedBatchToken) => + resolve(token) as unknown as DerivedMetricResponse, + } as unknown as DashboardDerived; } beforeEach(() => vi.clearAllMocks()); describe("", () => { it("renders an available baseline vital as an ok tile", () => { - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "VITALS_BASELINE" && token.type === "RESTING_HEART_RATE") { return ok({ type: "RESTING_HEART_RATE", center: 55, low: 48, high: 62, spread: 7, sampleDays: 30, k: 3 }); } return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(html).toContain('data-slot="vitals-dashboard"'); expect(html).toContain('data-metric="RESTING_HEART_RATE"'); expect(html).toContain('data-state="ok"'); @@ -93,8 +85,8 @@ describe("", () => { }); it("hides the whole section (heading + grid) when no vital has content", () => { - mockBatch(() => insufficient("no_readings_in_window")); - const html = render(); + const batch = mockBatch(() => insufficient("no_readings_in_window")); + const html = render(); // No content under the heading → the heading must not strand over blank // space. The whole section un-mounts. expect(html).not.toContain('data-slot="vitals-dashboard"'); @@ -104,8 +96,8 @@ describe("", () => { }); it("renders a skeleton row (no tiles, no heading strand) while loading", () => { - mockBatch(() => insufficient("no_readings_in_window"), { isLoading: true }); - const html = render(); + const batch = mockBatch(() => insufficient("no_readings_in_window"), { isLoading: true }); + const html = render(); // Heading + grid present so the section reserves its final height, with a // busy/live region and skeleton placeholders — but no real tiles yet. expect(html).toContain('data-slot="vitals-dashboard"'); @@ -116,8 +108,8 @@ describe("", () => { }); it("shows an inline error + retry when the batch fails", () => { - mockBatch(() => insufficient("no_readings_in_window"), { isError: true }); - const html = render(); + const batch = mockBatch(() => insufficient("no_readings_in_window"), { isError: true }); + const html = render(); // The surface must not silently vanish — a compact error + a Retry button. expect(html).toContain('data-slot="vitals-dashboard-error"'); expect(html).toContain('role="alert"'); @@ -127,13 +119,13 @@ describe("", () => { }); it("shows a provisional baseline tile with the coverage meter, not a headline", () => { - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "VITALS_BASELINE" && token.type === "WEIGHT") { return insufficient("insufficient_history_for_band", 4); } return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(html).toContain('data-metric="WEIGHT"'); expect(html).toContain('data-state="provisional"'); expect(html).toContain('data-slot="coverage-meter"'); @@ -141,40 +133,40 @@ describe("", () => { }); it("renders the fitness-age tile with its age framing", () => { - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "FITNESS_AGE") { return ok({ vo2Max: 46, band: "green", fitnessAgeDeltaYears: -6, referenceBand: { low: 35, high: 45 }, trendDelta: 2, readingCount: 4 }); } return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(html).toContain('data-metric="FITNESS_AGE"'); expect(html).toContain("yr younger"); }); it("renders the BMI tile with its WHO category", () => { - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "BMI") { return ok({ bmi: 24.7, category: "normal", band: "green", weightKg: 80, heightCm: 180 }); } return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(html).toContain('data-metric="BMI"'); expect(html).toContain("Normal range (WHO)"); }); it("hides BMI when there is no height/weight", () => { - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "BMI") return insufficient("no_height_on_profile"); return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(html).not.toContain('data-metric="BMI"'); }); it("renders the estimated 6-minute-walk band tile with its percent framing", () => { - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "SIX_MINUTE_WALK_BAND") { return ok({ distanceM: 540, @@ -188,14 +180,14 @@ describe("", () => { } return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(html).toContain('data-slot="vitals-mobility"'); expect(html).toContain('data-metric="SIX_MINUTE_WALK_BAND"'); expect(html).toContain("90% of predicted"); }); it("renders the 6-minute-walk tile distance-only when demographics are absent", () => { - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "SIX_MINUTE_WALK_BAND") { return ok({ distanceM: 540, @@ -209,28 +201,28 @@ describe("", () => { } return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(html).toContain('data-metric="SIX_MINUTE_WALK_BAND"'); // Honest prompt, never a fabricated placement. expect(html).toContain("Add your age, height, weight and sex"); }); it("renders a stair-ascent-speed baseline band tile under Mobility & body", () => { - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "STAIR_ASCENT_SPEED_BASELINE") { return ok({ type: "STAIR_ASCENT_SPEED", center: 0.4, low: 0.3, high: 0.5, spread: 0.05, sampleDays: 21, k: 3, series: [0.38, 0.4, 0.42] }); } return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(html).toContain('data-slot="vitals-mobility"'); expect(html).toContain('data-metric="STAIR_ASCENT_SPEED_BASELINE"'); expect(html).toContain("Mobility & body"); }); it("hides the whole Mobility & body section when no mobility metric has content", () => { - mockBatch(() => insufficient("no_readings_in_window")); - const html = render(); + const batch = mockBatch(() => insufficient("no_readings_in_window")); + const html = render(); expect(html).not.toContain('data-slot="vitals-mobility"'); expect(html).not.toContain("Mobility & body"); expect(html).not.toContain('data-metric="SIX_MINUTE_WALK_BAND"'); @@ -241,11 +233,11 @@ describe("", () => { // The flag moved to the dedicated `CoincidentDeviationCard`; the dashboard // batch no longer requests it and the grid never paints it. let requestedCoincident = false; - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "COINCIDENT_DEVIATION") requestedCoincident = true; return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(requestedCoincident).toBe(false); expect(html).not.toContain('data-metric="COINCIDENT_DEVIATION"'); }); diff --git a/src/components/insights/derived/__tests__/wellness-scores.test.tsx b/src/components/insights/derived/__tests__/wellness-scores.test.tsx new file mode 100644 index 000000000..54e66eb52 --- /dev/null +++ b/src/components/insights/derived/__tests__/wellness-scores.test.tsx @@ -0,0 +1,105 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { + DerivedMetricResponse, + DerivedBatchToken, +} from "../use-derived-metric"; +import { WellnessScores } from "../wellness-scores"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +type Resp = DerivedMetricResponse>; + +function ok(value: Record): Resp { + return { + metric: "X", + status: "ok", + value, + coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] }, + confidence: { score: 90, band: "high" }, + provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "x" }, + reason: null, + }; +} + +function absent(): Resp { + return { + metric: "X", + status: "insufficient", + value: null, + coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: [] }, + confidence: null, + provenance: { inputs: [], source: "none", windowDays: 30, computedAt: "x" }, + reason: "no_readings_in_window", + }; +} + +function readFrom(map: Record): ( + token: DerivedBatchToken, +) => DerivedMetricResponse | null { + return (token: DerivedBatchToken) => + (map[token.metric] ?? absent()) as unknown as DerivedMetricResponse; +} + +describe("", () => { + it("un-mounts the whole strip when no score has data", () => { + const html = render( + , + ); + expect(html).toBe(""); + }); + + it("renders nothing while loading (the page reserves the row)", () => { + const html = render( + , + ); + // While loading no tiles resolve, so the strip stays un-mounted. + expect(html).toBe(""); + }); + + it("renders a ring tile with a canonical TileHeader icon + heading", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="wellness-scores"'); + expect(html).toContain('data-metric="READINESS"'); + // Each tile leads with the shared TileHeader (icon + heading row), with + // the metric name in the foreground-colour CardTitle. + expect(html).toContain('data-slot="tile-header"'); + expect(html).toContain('data-slot="card-title"'); + expect(html).toContain("Readiness"); + // The ring carries its band on the arc + data-attribute (not the number). + expect(html).toContain('data-band="green"'); + // The band word renders under the ring. + expect(html).toContain('data-slot="wellness-score-band-word"'); + }); + + it("renders one tile per available score and hides absent ones", () => { + const html = render( + , + ); + expect(html).toContain('data-metric="READINESS"'); + expect(html).toContain('data-metric="SLEEP_SCORE"'); + expect(html).not.toContain('data-metric="RECOVERY_SCORE"'); + expect(html).not.toContain('data-metric="STRESS_SCORE"'); + expect(html).not.toContain('data-metric="STRAIN_SCORE"'); + }); +}); diff --git a/src/components/insights/derived/use-dashboard-derived.ts b/src/components/insights/derived/use-dashboard-derived.ts new file mode 100644 index 000000000..c86c1f8ce --- /dev/null +++ b/src/components/insights/derived/use-dashboard-derived.ts @@ -0,0 +1,88 @@ +"use client"; + +import { useMemo } from "react"; + +import { + useDerivedBatch, + type DerivedBatchToken, +} from "./use-derived-metric"; +// Type-only — keeps the derived registry's server graph out of the bundle. +import type { DerivedMetricId } from "@/lib/insights/derived/registry"; + +/** + * v1.12.6 — the shared derived-metric batch for the Insights overview. + * + * The overview paints the wellness-score strip and the vitals grid as two + * separate, full-width sections (wellness lifted above the daily briefing, + * vitals below it). Both surfaces read the same already-computed derived + * values, so this hook owns the ONE batched `/api/insights/derived/batch` + * request and hands its `read`/`isLoading`/`isError`/`refetch` handle to + * both. A single cache entry, one network round-trip — never a per-section + * fan-out (the v1.9.1 "hangs then recovers" symptom). + * + * The page mounts this once and passes the handle down, so the wellness + * strip and the vitals dashboard share the same query instance. + */ + +/** The per-vital baseline tiles the dashboard renders (HRV has its own tile). */ +export const SECTION_VITALS: string[] = [ + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "RESPIRATORY_RATE", + "OXYGEN_SATURATION", + "BODY_TEMPERATURE", + "BLOOD_GLUCOSE", + "WEIGHT", +]; + +/** + * The any-user HealthKit baseline bands (Mobility section). The `metric` is + * the batch token + provenance key; `type` drives icon/label/unit. + */ +export const SECTION_MOBILITY: { metric: DerivedMetricId; type: string }[] = [ + { metric: "STAIR_ASCENT_SPEED_BASELINE", type: "STAIR_ASCENT_SPEED" }, + { metric: "STAIR_DESCENT_SPEED_BASELINE", type: "STAIR_DESCENT_SPEED" }, + { metric: "WRIST_TEMPERATURE_BASELINE", type: "WRIST_TEMPERATURE" }, +]; + +/** + * The full set of tokens the overview reads in one batch — the five wellness + * scores + the four derived re-frames + one baseline per vital (minus HRV, + * which has its own balance tile) + the mobility/body metrics. The + * coincident-deviation flag is read by the dedicated "Today's signal" card, + * not here. + */ +export function dashboardTokens(): DerivedBatchToken[] { + const tokens: DerivedBatchToken[] = [ + { metric: "READINESS" }, + { metric: "SLEEP_SCORE" }, + { metric: "RECOVERY_SCORE" }, + { metric: "STRESS_SCORE" }, + { metric: "STRAIN_SCORE" }, + { metric: "FITNESS_AGE" }, + { metric: "VASCULAR_AGE_DELTA" }, + { metric: "HRV_BALANCE" }, + { metric: "BMI" }, + { metric: "SIX_MINUTE_WALK_BAND" }, + ]; + for (const type of SECTION_VITALS) { + if (type === "HEART_RATE_VARIABILITY") continue; + tokens.push({ metric: "VITALS_BASELINE", type }); + } + for (const { metric } of SECTION_MOBILITY) { + tokens.push({ metric }); + } + return tokens; +} + +/** The shared batch handle both the wellness strip and the vitals grid read. */ +export type DashboardDerived = ReturnType; + +/** + * Mount the one overview-wide derived batch. `enabled` gates the fetch on the + * page's auth state; the hook itself also gates on the session internally. + */ +export function useDashboardDerived(enabled: boolean): DashboardDerived { + const tokens = useMemo(() => dashboardTokens(), []); + return useDerivedBatch(tokens, { enabled }); +} diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index 0bfb8ccb0..7eb93abad 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -5,7 +5,6 @@ import { Footprints, Gauge, HeartPulse, RefreshCw, Scale } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; -import { useAuth } from "@/hooks/use-auth"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { getUnitForType } from "@/lib/validations/measurement"; import { @@ -14,14 +13,14 @@ import { } from "@/components/measurements/measurement-list-meta"; import { SparklineDeltaTile } from "./sparkline-delta-tile"; import { CoverageMeter } from "./coverage-meter"; -import { WellnessScores } from "./wellness-scores"; import { ProvenanceExplainer } from "./provenance-explainer"; import { METRIC_PROVENANCE } from "./standards"; +import { type DerivedBatchRead } from "./use-derived-metric"; import { - useDerivedBatch, - type DerivedBatchRead, - type DerivedBatchToken, -} from "./use-derived-metric"; + SECTION_VITALS, + SECTION_MOBILITY, + type DashboardDerived, +} from "./use-dashboard-derived"; import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment"; // Type-only — the compute payloads never drag the server graph into the bundle. import type { VitalsBaselineValue } from "@/lib/insights/derived/baseline"; @@ -53,29 +52,9 @@ import type { DerivedProvenance } from "@/lib/insights/derived/types"; * the "app hangs then recovers" symptom (v1.9.1 / v1.4.49.1). */ -const SECTION_VITALS: string[] = [ - "RESTING_HEART_RATE", - "HEART_RATE_VARIABILITY", - "RESPIRATORY_RATE", - "OXYGEN_SATURATION", - "BODY_TEMPERATURE", - "BLOOD_GLUCOSE", - "WEIGHT", -]; - -/** - * v1.10.3 — the any-user HealthKit baseline bands, each pinning one input and - * framing as a personal typical range (median ± k·MAD). The `metric` is the - * batch token + provenance key; `type` drives icon/label/unit. The estimated - * 6-minute-walk re-frame rides the same Mobility section but reads its own - * value shape (`SixMinuteWalkTile`). Most users carry none of these, so the - * tiles simply do not appear (the per-tile absent gate). - */ -const SECTION_MOBILITY: { metric: keyof typeof METRIC_PROVENANCE; type: string }[] = [ - { metric: "STAIR_ASCENT_SPEED_BASELINE", type: "STAIR_ASCENT_SPEED" }, - { metric: "STAIR_DESCENT_SPEED_BASELINE", type: "STAIR_DESCENT_SPEED" }, - { metric: "WRIST_TEMPERATURE_BASELINE", type: "WRIST_TEMPERATURE" }, -]; +// v1.12.6 — `SECTION_VITALS`, `SECTION_MOBILITY` and the batch token list +// moved to `use-dashboard-derived.ts` so the page can own the one shared batch +// and feed it to both the wellness strip and this grid. Imported above. /** Up-is-bad for the vitals where a rise is unfavourable. */ const UP_BAD_VITALS = new Set([ @@ -111,7 +90,13 @@ function vitalSentiment(type: string): TrendDirectionSentiment { } interface DashboardProps { - enabled?: boolean; + /** + * The shared overview derived batch (owned by the page so the wellness + * strip and this vitals grid read the same one request). Lifted out of + * this component in v1.12.6 — the wellness strip now renders as its own + * full-width section above the daily briefing. + */ + batch: DashboardDerived; className?: string; } @@ -521,47 +506,10 @@ function hasRenderableMobility(read: DerivedBatchRead): boolean { return false; } -/** - * The full set of tokens the dashboard reads in one batch — the five - * wellness scores + the four derived re-frames + one baseline per vital - * (minus HRV, which has its own balance tile) + the four v1.10.3 mobility/body - * metrics (estimated 6-minute-walk band + the three HealthKit baseline bands). - * The coincident-deviation flag is no longer read here: it is now the - * dedicated "Today's signal" card at the top of the overview - * (`CoincidentDeviationCard`). - */ -function dashboardTokens(): DerivedBatchToken[] { - const tokens: DerivedBatchToken[] = [ - { metric: "READINESS" }, - { metric: "SLEEP_SCORE" }, - { metric: "RECOVERY_SCORE" }, - { metric: "STRESS_SCORE" }, - { metric: "STRAIN_SCORE" }, - { metric: "FITNESS_AGE" }, - { metric: "VASCULAR_AGE_DELTA" }, - { metric: "HRV_BALANCE" }, - { metric: "BMI" }, - { metric: "SIX_MINUTE_WALK_BAND" }, - ]; - for (const type of SECTION_VITALS) { - if (type === "HEART_RATE_VARIABILITY") continue; - tokens.push({ metric: "VITALS_BASELINE", type }); - } - for (const { metric } of SECTION_MOBILITY) { - tokens.push({ metric }); - } - return tokens; -} - -export function VitalsDashboard({ enabled = true, className }: DashboardProps) { +export function VitalsDashboard({ batch, className }: DashboardProps) { const { t } = useTranslations(); - const { isAuthenticated } = useAuth(); const vitals = useMemo(() => SECTION_VITALS, []); - const tokens = useMemo(() => dashboardTokens(), []); - const batch = useDerivedBatch(tokens, { - enabled: enabled && isAuthenticated, - }); const read = batch.read; const isLoading = batch.isLoading; const isError = batch.isError; @@ -618,7 +566,6 @@ export function VitalsDashboard({ enabled = true, className }: DashboardProps) { return (
- {showSection && (
; }) { return ( -
- {/* The metric title rides an HTML caption below the ring, not the - in-SVG `label` slot: a long localised title ("Schlaf-Score", - "Bereitschaft") overflowed the small ring's centred SVG text and - the recharts `overflow:hidden` clipped its leading glyph. As HTML - it wraps within the tile and never truncates. The ring keeps just - the number centred. */} + {/* v1.12.6 — every tile leads with the canonical icon + heading + (foreground colour, matching the Einschätzung reference) so the + wellness strip reads as the same card language as the rest of + Insights. The metric name no longer rides an under-ring caption. */} + +
+ {/* The ring keeps just the number centred; the metric name lives in + the TileHeader above (a long localised title would overflow the + small ring's centred SVG text and clip under recharts' + `overflow:hidden`). */} - - {label} - , ); } @@ -121,6 +124,7 @@ export function WellnessScores({ read, isLoading, className }: ScoreStripProps) bandWord={bandWord(recovery.value.band)} href="/insights/scores/recovery" metricSlot="RECOVERY_SCORE" + icon={HeartPulse} />, ); } @@ -134,6 +138,7 @@ export function WellnessScores({ read, isLoading, className }: ScoreStripProps) bandWord={bandWord(sleep.value.band)} href="/insights/scores/sleep" metricSlot="SLEEP_SCORE" + icon={Moon} />, ); } @@ -147,6 +152,7 @@ export function WellnessScores({ read, isLoading, className }: ScoreStripProps) bandWord={bandWord(stress.value.band)} href="/insights/scores/stress" metricSlot="STRESS_SCORE" + icon={Activity} />, ); } @@ -160,6 +166,7 @@ export function WellnessScores({ read, isLoading, className }: ScoreStripProps) bandWord={bandWord(strain.value.band)} href="/insights/scores/strain" metricSlot="STRAIN_SCORE" + icon={Flame} />, ); } @@ -187,9 +194,10 @@ export function WellnessScores({ read, isLoading, className }: ScoreStripProps) aria-label={t("insights.derived.scores.sectionTitle")} className={cn("space-y-3", className)} > -

- {t("insights.derived.scores.sectionTitle")} -

+