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")} -

+