From ed6911bb0347a02763a3b63433857593c1f3c221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 5 Jul 2026 15:07:59 +0200 Subject: [PATCH 1/6] feat(dashboard): add the selectable hero score-ring preference Add selectedScoreRings to the dashboard layout blob: a closed four-id set (READINESS, RECOVERY_SCORE, SLEEP_SCORE, MED_COMPLIANCE) capped at three rings, with a client-safe ring-to-module map alongside. The resolver drops unknown ids, dedupes, and clamps; a legacy blob without the field defaults to the single MED_COMPLIANCE ring while an explicit empty array is respected. The widgets PUT validates the field against the closed enum and preserves the stored value when a client omits it, the same contract heroVisible already rides. --- src/app/api/dashboard/widgets/route.ts | 27 +++++-- src/lib/__tests__/dashboard-layout.test.ts | 89 ++++++++++++++++++++++ src/lib/dashboard-layout.ts | 87 +++++++++++++++++++++ 3 files changed, 198 insertions(+), 5 deletions(-) diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 15121fc65..989dd17f4 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -22,8 +22,11 @@ import { DASHBOARD_WIDGET_CATALOGUE_IDS, COMPARISON_BASELINES, CHART_OVERLAY_KEYS, + SCORE_RING_IDS, + MAX_SELECTED_SCORE_RINGS, type ChartOverlayPrefsMap, type DashboardLayout, + type ScoreRingId, } from "@/lib/dashboard-layout"; import { Prisma } from "@/generated/prisma/client"; import { z } from "zod/v4"; @@ -121,6 +124,13 @@ const layoutSchema = z.object({ // `chartOverlayPrefs` — a Settings save that doesn't know the field // must not silently reset the user's choice). heroVisible: z.boolean().optional(), + // v1.27.7 — hero score rings (max 3, closed id set). Optional with the + // same preserve-when-absent contract as `heroVisible`; the resolver + // dedupes on read/serialize, `.max()` bounds the wire length. + selectedScoreRings: z + .array(z.enum(SCORE_RING_IDS)) + .max(MAX_SELECTED_SCORE_RINGS) + .optional(), }); async function buildDashboardLayout(userId: string): Promise { @@ -262,16 +272,19 @@ export const PUT = apiHandler(async (request: NextRequest) => { // didn't send. The dashboard-layout PUT typically saves widget // visibility / order; chart prefs are PUT through their own route // (`/api/dashboard/chart-overlay-prefs`) and would otherwise be - // wiped here on a subsequent layout save. `heroVisible` rides the - // same preserve-when-absent contract — an older client's layout - // save must not reset the hero toggle. One stored-layout read - // covers both fallbacks. + // wiped here on a subsequent layout save. `heroVisible` and + // `selectedScoreRings` ride the same preserve-when-absent contract — + // an older client's layout save must not reset either choice. One + // stored-layout read covers all fallbacks. let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined = parsed.data .chartOverlayPrefs as ChartOverlayPrefsMap | undefined; let mergedHeroVisible: boolean | undefined = parsed.data.heroVisible; + let mergedScoreRings: ScoreRingId[] | undefined = + parsed.data.selectedScoreRings; if ( mergedChartOverlayPrefs === undefined || - mergedHeroVisible === undefined + mergedHeroVisible === undefined || + mergedScoreRings === undefined ) { const existing = await prisma.user.findUnique({ where: { id: user.id }, @@ -286,11 +299,15 @@ export const PUT = apiHandler(async (request: NextRequest) => { if (mergedHeroVisible === undefined) { mergedHeroVisible = existingLayout.heroVisible === true; } + if (mergedScoreRings === undefined) { + mergedScoreRings = existingLayout.selectedScoreRings; + } } const normalized = serializeDashboardLayout({ ...parsed.data, chartOverlayPrefs: mergedChartOverlayPrefs, heroVisible: mergedHeroVisible, + selectedScoreRings: mergedScoreRings, } as DashboardLayout); await prisma.user.update({ diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index 8772cca2f..69a99582e 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -657,3 +657,92 @@ describe("resolveDashboardLayout() — iOS-only id retention (v1.7.0)", () => { } }); }); + +/** + * v1.27.7 — hero score rings (`selectedScoreRings`). Closed id set, max + * three, dedupe + unknown-drop on read, default single MED_COMPLIANCE + * ring for legacy blobs, explicit empty array respected. + */ +describe("resolveDashboardLayout() — selectedScoreRings", () => { + const base = { + version: 1, + widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }], + }; + + it("defaults a legacy blob (field missing) to the single MED_COMPLIANCE ring", () => { + const resolved = resolveDashboardLayout(base); + expect(resolved.selectedScoreRings).toEqual(["MED_COMPLIANCE"]); + }); + + it("defaults a null / non-array field to MED_COMPLIANCE", () => { + for (const bad of [null, "READINESS", 3, { READINESS: true }]) { + const resolved = resolveDashboardLayout({ + ...base, + selectedScoreRings: bad, + }); + expect(resolved.selectedScoreRings).toEqual(["MED_COMPLIANCE"]); + } + }); + + it("respects an explicitly-saved empty array (user chose no rings)", () => { + const resolved = resolveDashboardLayout({ + ...base, + selectedScoreRings: [], + }); + expect(resolved.selectedScoreRings).toEqual([]); + }); + + it("drops unknown ids and dedupes, preserving selection order", () => { + const resolved = resolveDashboardLayout({ + ...base, + selectedScoreRings: [ + "SLEEP_SCORE", + "STRESS_SCORE", // not a ring id — drops + "READINESS", + "SLEEP_SCORE", // duplicate — collapses + ], + }); + expect(resolved.selectedScoreRings).toEqual(["SLEEP_SCORE", "READINESS"]); + }); + + it("clamps the selection to three rings (first three win)", () => { + const resolved = resolveDashboardLayout({ + ...base, + selectedScoreRings: [ + "READINESS", + "RECOVERY_SCORE", + "SLEEP_SCORE", + "MED_COMPLIANCE", + ], + }); + expect(resolved.selectedScoreRings).toEqual([ + "READINESS", + "RECOVERY_SCORE", + "SLEEP_SCORE", + ]); + }); + + it("serializeDashboardLayout persists the coerced selection explicitly", () => { + const serialized = serializeDashboardLayout({ + ...DEFAULT_DASHBOARD_LAYOUT, + selectedScoreRings: [ + "MED_COMPLIANCE", + "MED_COMPLIANCE", + "READINESS", + "RECOVERY_SCORE", + "SLEEP_SCORE", + ], + } as DashboardLayout); + expect(serialized.selectedScoreRings).toEqual([ + "MED_COMPLIANCE", + "READINESS", + "RECOVERY_SCORE", + ]); + }); + + it("the default layout carries the MED_COMPLIANCE ring", () => { + expect(DEFAULT_DASHBOARD_LAYOUT.selectedScoreRings).toEqual([ + "MED_COMPLIANCE", + ]); + }); +}); diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 7c37f9dae..9a52c4f87 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -4,6 +4,7 @@ * Single source of truth for which widgets show on /, what order, and * what the default layout is for new users. Null / missing = default. */ +import type { ModuleKey } from "@/lib/modules/registry"; /** * Every widget the dashboard layout knows about. The order of @@ -334,6 +335,76 @@ function coerceChartOverlayPrefsMap(value: unknown): ChartOverlayPrefsMap { return out; } +/** + * v1.27.7 — selectable hero score rings. The closed id set the hero's + * ring row can render next to the health score: + * + * - `READINESS` / `RECOVERY_SCORE` / `SLEEP_SCORE` resolve through the + * derived registry's engines (module-gated like the derived routes); + * - `MED_COMPLIANCE` is the pooled 7-day medication adherence from the + * canonical compliance engine — the ring that absorbs the retired + * hero dose row's information role. + * + * The preference piggy-backs on the layout blob like `heroVisible` (a UI + * affordance, no Prisma migration). The resolver drops unknown ids, + * dedupes, and clamps to `MAX_SELECTED_SCORE_RINGS`; a missing / + * malformed field falls back to the default single MED_COMPLIANCE ring, + * while an explicitly-saved empty array stays empty (the user chose no + * extra rings). + */ +export const SCORE_RING_IDS = [ + "READINESS", + "RECOVERY_SCORE", + "SLEEP_SCORE", + "MED_COMPLIANCE", +] as const; +export type ScoreRingId = (typeof SCORE_RING_IDS)[number]; + +export const MAX_SELECTED_SCORE_RINGS = 3; + +export const DEFAULT_SELECTED_SCORE_RINGS: ScoreRingId[] = ["MED_COMPLIANCE"]; + +/** + * Ring id → owning toggleable module. Mirrors the derived routes' + * `DERIVED_MODULE` map for the three derived rings (READINESS rides the + * recovery module like the recovery/strain/stress trio); MED_COMPLIANCE + * belongs to the medications module. Client-safe (type-only ModuleKey + * import) so the Settings picker and the server snapshot resolver gate + * on the same map — the `WIDGET_MODULE_BY_ID` pattern. + */ +export const SCORE_RING_MODULE: Record = { + READINESS: "recovery", + RECOVERY_SCORE: "recovery", + SLEEP_SCORE: "sleep", + MED_COMPLIANCE: "medications", +}; + +function isScoreRingId(value: unknown): value is ScoreRingId { + return ( + typeof value === "string" && + (SCORE_RING_IDS as readonly string[]).includes(value) + ); +} + +/** + * Coerce the persisted `selectedScoreRings` field: unknown ids drop, + * duplicates collapse (first occurrence wins), the list clamps to + * `MAX_SELECTED_SCORE_RINGS`. A non-array (missing field, legacy blob, + * malformed client) falls back to the default; an array — even an empty + * one — is respected as an explicit choice. + */ +function coerceSelectedScoreRings(value: unknown): ScoreRingId[] { + if (!Array.isArray(value)) return [...DEFAULT_SELECTED_SCORE_RINGS]; + const out: ScoreRingId[] = []; + for (const entry of value) { + if (!isScoreRingId(entry)) continue; + if (out.includes(entry)) continue; + out.push(entry); + if (out.length >= MAX_SELECTED_SCORE_RINGS) break; + } + return out; +} + export interface DashboardLayout { version: number; widgets: DashboardWidgetConfig[]; @@ -351,6 +422,12 @@ export interface DashboardLayout { * serializer persists the resolved boolean explicitly. */ heroVisible?: boolean; + /** + * v1.27.7 — hero score rings (max 3, closed `SCORE_RING_IDS` set) + * rendered next to the health-score ring. See the doc on + * `SCORE_RING_IDS`; the resolver clamps/dedupes/drops-unknown. + */ + selectedScoreRings?: ScoreRingId[]; } const DASHBOARD_LAYOUT_VERSION = 1; @@ -365,6 +442,9 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = { // Hero (daily verdict) is off by default; users opt in via // Settings → Dashboard. heroVisible: false, + // One medication-adherence ring next to the health score by default — + // the successor of the hero dose row's information role. + selectedScoreRings: [...DEFAULT_SELECTED_SCORE_RINGS], widgets: [ { id: "weight", visible: true, tileVisible: true, order: 0 }, { id: "bp", visible: true, tileVisible: true, order: 1 }, @@ -506,6 +586,10 @@ export function resolveDashboardLayout(raw: unknown): DashboardLayout { // clamps to `false` (default-off for legacy blobs and malformed // values alike; the hero is opt-in). heroVisible: candidate.heroVisible === true, + // v1.27.7 — hero score rings: unknown ids drop, duplicates collapse, + // the list clamps to three. A legacy blob without the field gets the + // default single MED_COMPLIANCE ring. + selectedScoreRings: coerceSelectedScoreRings(candidate.selectedScoreRings), }; } @@ -536,5 +620,8 @@ export function serializeDashboardLayout( // Persist hero visibility explicitly (same clamp as the resolver) // so a re-read never has to guess the default. heroVisible: layout.heroVisible === true, + // v1.27.7 — persist the score-ring selection through the same + // coercion the resolver runs so the wire shape is stable. + selectedScoreRings: coerceSelectedScoreRings(layout.selectedScoreRings), }; } From 5c0cde207f58bcbc3f3b162558fa33bd0fd20dd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 5 Jul 2026 15:08:09 +0200 Subject: [PATCH 2/6] feat(dashboard): resolve the selected score rings on the snapshot Resolve the selected rings server-side and publish them as scoreRings on the dashboard snapshot (additive, optional for cached payloads). READINESS, RECOVERY_SCORE and SLEEP_SCORE go through the same computeDerivedMetric dispatcher the derived batch route calls, with the profile loaded once via the shared loader; MED_COMPLIANCE pools the 7-day adherence across active non-PRN medications through the canonical compliance engine (taken over taken-plus-missed, skips out of the denominator) and bands on the targets-tile thresholds. Rings gate on their owning module plus the insights gate for the derived trio, drop out when no data backs them, and resolve fail-soft per ring so a throwing engine never sinks the snapshot. OpenAPI regenerated. --- docs/api/openapi.yaml | 32 +++ .../dashboard/__tests__/score-rings.test.ts | 269 ++++++++++++++++++ src/lib/dashboard/__tests__/snapshot.test.ts | 66 +++++ src/lib/dashboard/score-rings.ts | 245 ++++++++++++++++ src/lib/dashboard/snapshot.ts | 100 +++++-- src/lib/openapi/routes/insights/schemas.ts | 20 ++ 6 files changed, 707 insertions(+), 25 deletions(-) create mode 100644 src/lib/dashboard/__tests__/score-rings.test.ts create mode 100644 src/lib/dashboard/score-rings.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d5a8533ea..f471036a0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -21417,6 +21417,38 @@ components: description: Personal health score summary (0..100 score, traffic-light band, week-over-week delta). Null on a rollup-coverage miss (it rides the thick phase alongside `extras`) and when no pillar is computable. Component breakdown is deliberately not serialised here. + scoreRings: + description: "User-selected hero score rings (max 3, `selectedScoreRings` on the dashboard layout), resolved + server-side: READINESS / RECOVERY_SCORE / SLEEP_SCORE via the derived engines (module-gated like + `/api/insights/derived`), MED_COMPLIANCE as the pooled 7-day medication adherence (0..100, banded ≥90 green + / ≥70 yellow / else red). Only rings with data appear, in selection order — a missing entry means no data or + a disabled module, never zero. Clients render what arrives and never recompute." + type: array + items: + type: object + properties: + id: + type: string + enum: + - READINESS + - RECOVERY_SCORE + - SLEEP_SCORE + - MED_COMPLIANCE + score: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + band: + type: string + enum: + - green + - yellow + - red + required: + - id + - score + - band + additionalProperties: false briefing: anyOf: - type: object diff --git a/src/lib/dashboard/__tests__/score-rings.test.ts b/src/lib/dashboard/__tests__/score-rings.test.ts new file mode 100644 index 000000000..767011a87 --- /dev/null +++ b/src/lib/dashboard/__tests__/score-rings.test.ts @@ -0,0 +1,269 @@ +/** + * v1.27.7 — unit tests for the hero score-ring resolver. + * + * The resolver is tested in isolation with the derived dispatcher and + * the compliance engine mocked, pinning: + * - module gating (owning module + the insights gate on derived rings); + * - the pass-through contract for derived scores (same resolvers the + * batch route calls, no recomputation, non-`ok` → no ring); + * - the pooled 7-day adherence math (Σtaken / Σ(taken+missed), skips + * out of the denominator, empty denominator → no ring); + * - the adherence band thresholds (≥90 / ≥70 — the targets-tile rule); + * - per-ring fail-softness (one throwing engine drops only its ring). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const computeDerivedMetric = vi.fn(); +const loadBaselineProfile = vi.fn(); +const calculateCompliance = vi.fn(); +const buildComplianceMedicationContext = vi.fn(); +const lastNonSkippedTakenAt = vi.fn(); + +vi.mock("@/lib/insights/derived", () => ({ + computeDerivedMetric: (...a: unknown[]) => computeDerivedMetric(...a), + loadBaselineProfile: (...a: unknown[]) => loadBaselineProfile(...a), + isDerivedOk: (d: { status: string }) => d.status === "ok", +})); +vi.mock("@/lib/analytics/compliance", () => ({ + calculateCompliance: (...a: unknown[]) => calculateCompliance(...a), + buildComplianceMedicationContext: (...a: unknown[]) => + buildComplianceMedicationContext(...a), + lastNonSkippedTakenAt: (...a: unknown[]) => lastNonSkippedTakenAt(...a), + SCHEDULE_COMPLIANCE_SELECT: { id: true }, +})); + +import { buildScoreRingsBlock, complianceBandForRate } from "../score-rings"; +import type { ModuleKey } from "@/lib/modules/gate"; + +const NOW = new Date("2026-07-01T10:00:00.000Z"); +const TZ = "Europe/Berlin"; + +function moduleMap( + overrides: Partial> = {}, +): Record { + return { + recovery: true, + sleep: true, + medications: true, + insights: true, + ...overrides, + } as Record; +} + +const fakePrisma = { + medication: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn() }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as any; + +/** A minimal medication row matching the resolver's select. */ +function med(id: string) { + return { + id, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + startsOn: null, + endsOn: null, + oneShot: false, + schedules: [{ id: `${id}-s` }], + scheduleRevisions: [], + pauseEras: [], + }; +} + +function complianceResult(taken: number, missed: number, skipped = 0) { + return { + totalExpected: taken + missed + skipped, + taken, + skipped, + missed, + rate: 0, // the resolver pools counts, never this per-med rate + streak: 0, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + loadBaselineProfile.mockResolvedValue({ userId: "user-1" }); + buildComplianceMedicationContext.mockReturnValue({ ctx: true }); + lastNonSkippedTakenAt.mockReturnValue(null); + fakePrisma.medication.findMany.mockResolvedValue([]); + fakePrisma.medicationIntakeEvent.findMany.mockResolvedValue([]); +}); + +describe("complianceBandForRate()", () => { + it("bands on the targets-tile thresholds (≥90 green / ≥70 yellow / else red)", () => { + expect(complianceBandForRate(100)).toBe("green"); + expect(complianceBandForRate(90)).toBe("green"); + expect(complianceBandForRate(89)).toBe("yellow"); + expect(complianceBandForRate(70)).toBe("yellow"); + expect(complianceBandForRate(69)).toBe("red"); + expect(complianceBandForRate(0)).toBe("red"); + }); +}); + +describe("buildScoreRingsBlock() — selection + module gating", () => { + it("returns [] for an empty selection without touching any engine", async () => { + const rings = await buildScoreRingsBlock( + fakePrisma, + "user-1", + TZ, + [], + moduleMap(), + NOW, + ); + expect(rings).toEqual([]); + expect(loadBaselineProfile).not.toHaveBeenCalled(); + expect(computeDerivedMetric).not.toHaveBeenCalled(); + expect(fakePrisma.medication.findMany).not.toHaveBeenCalled(); + }); + + it("drops rings whose owning module is disabled", async () => { + computeDerivedMetric.mockResolvedValue({ + status: "ok", + value: { score: 80, band: "green" }, + }); + const rings = await buildScoreRingsBlock( + fakePrisma, + "user-1", + TZ, + ["READINESS", "SLEEP_SCORE"], + moduleMap({ recovery: false }), + NOW, + ); + expect(rings).toEqual([{ id: "SLEEP_SCORE", score: 80, band: "green" }]); + expect(computeDerivedMetric).toHaveBeenCalledTimes(1); + expect(computeDerivedMetric).toHaveBeenCalledWith( + expect.objectContaining({ metric: "SLEEP_SCORE", userId: "user-1" }), + ); + }); + + it("the insights gate drops derived rings but keeps the adherence ring", async () => { + fakePrisma.medication.findMany.mockResolvedValue([med("m1")]); + calculateCompliance.mockReturnValue(complianceResult(9, 1)); + const rings = await buildScoreRingsBlock( + fakePrisma, + "user-1", + TZ, + ["READINESS", "MED_COMPLIANCE"], + moduleMap({ insights: false }), + NOW, + ); + expect(computeDerivedMetric).not.toHaveBeenCalled(); + expect(loadBaselineProfile).not.toHaveBeenCalled(); + expect(rings).toEqual([{ id: "MED_COMPLIANCE", score: 90, band: "green" }]); + }); +}); + +describe("buildScoreRingsBlock() — derived rings", () => { + it("passes through the engine's score + band (rounded) in selection order", async () => { + computeDerivedMetric.mockImplementation( + async ({ metric }: { metric: string }) => + metric === "READINESS" + ? { status: "ok", value: { score: 71.6, band: "green" } } + : { status: "ok", value: { score: 38, band: "red" } }, + ); + const rings = await buildScoreRingsBlock( + fakePrisma, + "user-1", + TZ, + ["RECOVERY_SCORE", "READINESS"], + moduleMap(), + NOW, + ); + expect(rings).toEqual([ + { id: "RECOVERY_SCORE", score: 38, band: "red" }, + { id: "READINESS", score: 72, band: "green" }, + ]); + // Profile loaded exactly once (the batch-route pattern). + expect(loadBaselineProfile).toHaveBeenCalledTimes(1); + }); + + it("a non-ok compute yields no ring (self-gating, never a fabricated value)", async () => { + computeDerivedMetric.mockResolvedValue({ + status: "insufficient", + reason: "not_enough_history", + }); + const rings = await buildScoreRingsBlock( + fakePrisma, + "user-1", + TZ, + ["SLEEP_SCORE"], + moduleMap(), + NOW, + ); + expect(rings).toEqual([]); + }); + + it("a throwing engine drops only its own ring (fail-soft)", async () => { + computeDerivedMetric.mockImplementation( + async ({ metric }: { metric: string }) => { + if (metric === "READINESS") throw new Error("engine down"); + return { status: "ok", value: { score: 55, band: "yellow" } }; + }, + ); + const rings = await buildScoreRingsBlock( + fakePrisma, + "user-1", + TZ, + ["READINESS", "SLEEP_SCORE"], + moduleMap(), + NOW, + ); + expect(rings).toEqual([{ id: "SLEEP_SCORE", score: 55, band: "yellow" }]); + }); +}); + +describe("buildScoreRingsBlock() — pooled 7-day adherence", () => { + it("pools Σtaken / Σ(taken+missed) across medications; skips stay out", async () => { + fakePrisma.medication.findMany.mockResolvedValue([med("m1"), med("m2")]); + calculateCompliance + .mockReturnValueOnce(complianceResult(6, 1, 2)) // m1: skips ignored + .mockReturnValueOnce(complianceResult(2, 3)); + const rings = await buildScoreRingsBlock( + fakePrisma, + "user-1", + TZ, + ["MED_COMPLIANCE"], + moduleMap(), + NOW, + ); + // (6+2) / (6+1+2+3) = 8/12 = 66.67 → 67, red band. + expect(rings).toEqual([{ id: "MED_COMPLIANCE", score: 67, band: "red" }]); + // The 7-day window rides the canonical engine call. + expect(calculateCompliance).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 7, + expect.anything(), + expect.objectContaining({ now: NOW }), + ); + }); + + it("no active non-PRN medication → no ring", async () => { + fakePrisma.medication.findMany.mockResolvedValue([]); + const rings = await buildScoreRingsBlock( + fakePrisma, + "user-1", + TZ, + ["MED_COMPLIANCE"], + moduleMap(), + NOW, + ); + expect(rings).toEqual([]); + expect(fakePrisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled(); + }); + + it("zero expected doses in the window → no ring (never a hollow 100%)", async () => { + fakePrisma.medication.findMany.mockResolvedValue([med("m1")]); + calculateCompliance.mockReturnValue(complianceResult(0, 0, 0)); + const rings = await buildScoreRingsBlock( + fakePrisma, + "user-1", + TZ, + ["MED_COMPLIANCE"], + moduleMap(), + NOW, + ); + expect(rings).toEqual([]); + }); +}); diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 0cf95f422..656b147eb 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -64,6 +64,12 @@ const resolveModuleMap = vi.fn(); vi.mock("@/lib/modules/gate", () => ({ resolveModuleMap: (...a: unknown[]) => resolveModuleMap(...a), })); +// v1.27.7 — hero score rings. Mocked so the suite never touches the +// derived engines / compliance reads; the resolver has its own suite. +const buildScoreRingsBlock = vi.fn(); +vi.mock("@/lib/dashboard/score-rings", () => ({ + buildScoreRingsBlock: (...a: unknown[]) => buildScoreRingsBlock(...a), +})); /** Every toggleable key on, with optional per-key overrides. */ function moduleMap( @@ -173,6 +179,7 @@ beforeEach(() => { }); computeUserHealthScoreFastPath.mockResolvedValue(null); resolveModuleMap.mockResolvedValue(moduleMap()); + buildScoreRingsBlock.mockResolvedValue([]); }); describe("buildDashboardSnapshot — envelope shape", () => { @@ -1171,3 +1178,62 @@ describe("buildDashboardSnapshot — ambient narrative (A4/A5/A6)", () => { expect(snap.briefingMemory).toBeNull(); }); }); + +/** + * v1.27.7 — hero score rings on the snapshot wire. The resolver itself + * is pinned in `score-rings.test.ts`; here only the assembly contract: + * the builder receives the RESOLVED `selectedScoreRings` preference + + * the module map, its result rides `snapshot.scoreRings`, and a + * rejection degrades to an empty row (never a sunk snapshot). + */ +describe("buildDashboardSnapshot — scoreRings wire", () => { + it("threads the default selection (MED_COMPLIANCE) for a layout-less user", async () => { + probeRollupCoverage.mockResolvedValue(new Map()); + buildScoreRingsBlock.mockResolvedValue([ + { id: "MED_COMPLIANCE", score: 92, band: "green" }, + ]); + + const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); + + expect(buildScoreRingsBlock).toHaveBeenCalledTimes(1); + const [, userId, tz, selected, modules] = + buildScoreRingsBlock.mock.calls[0]; + expect(userId).toBe("user-1"); + expect(tz).toBe("Europe/Berlin"); + expect(selected).toEqual(["MED_COMPLIANCE"]); + expect(modules).toEqual(moduleMap()); + expect(snap.scoreRings).toEqual([ + { id: "MED_COMPLIANCE", score: 92, band: "green" }, + ]); + }); + + it("threads a saved multi-ring selection in order", async () => { + probeRollupCoverage.mockResolvedValue(new Map()); + buildScoreRingsBlock.mockResolvedValue([]); + + await buildDashboardSnapshot( + fakePrisma, + baseUser({ + dashboardWidgetsJson: { + version: 1, + widgets: [{ id: "weight", visible: true, order: 0 }], + selectedScoreRings: ["READINESS", "SLEEP_SCORE"], + }, + }), + ); + + const [, , , selected] = buildScoreRingsBlock.mock.calls[0]; + expect(selected).toEqual(["READINESS", "SLEEP_SCORE"]); + }); + + it("degrades a rejecting resolver to an empty ring row (fail-soft)", async () => { + probeRollupCoverage.mockResolvedValue(new Map()); + buildScoreRingsBlock.mockRejectedValue(new Error("engine down")); + + const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); + + expect(snap.scoreRings).toEqual([]); + // The rest of the envelope is untouched by the failure. + expect(snap.tiles.summaries.WEIGHT).toBeDefined(); + }); +}); diff --git a/src/lib/dashboard/score-rings.ts b/src/lib/dashboard/score-rings.ts new file mode 100644 index 000000000..26bee67e3 --- /dev/null +++ b/src/lib/dashboard/score-rings.ts @@ -0,0 +1,245 @@ +/** + * v1.27.7 — hero score-ring resolution for the dashboard snapshot. + * + * Resolves the user-selected `selectedScoreRings` (max 3, closed + * `SCORE_RING_IDS` set) into `{ id, score, band }` entries the hero + * renders next to the health-score ring: + * + * - `READINESS` / `RECOVERY_SCORE` / `SLEEP_SCORE` call the SAME + * `computeDerivedMetric` dispatcher the `/api/insights/derived` + * batch route uses (profile loaded once via the shared + * `loadBaselineProfile`) — no score logic is re-derived here, and a + * non-`ok` compute (insufficient data, engine gate) simply yields no + * ring, mirroring the wellness strip's self-gating. + * - `MED_COMPLIANCE` is the pooled 7-day adherence across active + * non-PRN medications through the canonical `calculateCompliance` + * engine (the exact per-medication pattern the targets tile and the + * health-score pillar use): per-med `ComplianceResult`s are pooled + * as Σtaken / Σ(taken + missed) — skips stay out of the denominator, + * matching the per-medication rate semantics — and banded on the + * same ≥90 / ≥70 thresholds the targets-tile classification carries. + * + * Module gating rides the client-safe `SCORE_RING_MODULE` map in + * `@/lib/dashboard-layout` (mirroring the derived routes' + * `DERIVED_MODULE`: readiness/recovery → recovery module, sleep score → + * sleep module, MED_COMPLIANCE → medications) plus the `insights` gate + * every derived read sits behind. + * + * Every ring resolves fail-soft: a throwing engine drops its ring, never + * the snapshot. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import type { ModuleKey } from "@/lib/modules/gate"; +import { + SCORE_RING_IDS, + SCORE_RING_MODULE, + type ScoreRingId, +} from "@/lib/dashboard-layout"; +import { + computeDerivedMetric, + loadBaselineProfile, + isDerivedOk, + type DerivedMetricId, +} from "@/lib/insights/derived"; +import { + buildComplianceMedicationContext, + calculateCompliance, + lastNonSkippedTakenAt, + SCHEDULE_COMPLIANCE_SELECT, +} from "@/lib/analytics/compliance"; + +export type ScoreRingBand = "green" | "yellow" | "red"; + +/** One resolved hero ring — score + band only, no component breakdown. */ +export interface DashboardScoreRing { + id: ScoreRingId; + /** The 0..100 score (rounded). */ + score: number; + band: ScoreRingBand; +} + +/** The derived-registry id behind each derived ring (identity mapping). */ +const DERIVED_RING_METRIC: Partial> = { + READINESS: "READINESS", + RECOVERY_SCORE: "RECOVERY_SCORE", + SLEEP_SCORE: "SLEEP_SCORE", +}; + +/** + * Adherence % → band, on the thresholds the targets tile's + * MEDICATION_COMPLIANCE classification already uses (≥90 "very good" / + * ≥70 "good" / below "low"), so the ring and the targets surface can + * never disagree on colour for the same rate. + */ +export function complianceBandForRate(rate: number): ScoreRingBand { + if (rate >= 90) return "green"; + if (rate >= 70) return "yellow"; + return "red"; +} + +/** The compute payload slice every derived score ring reads. */ +interface ScoreBandValue { + score: number; + band: ScoreRingBand; +} + +/** + * Pooled 7-day medication adherence, or `null` when nothing was expected + * (no active non-PRN medication, or no due slot in the window) — the + * ring self-gates instead of asserting a hollow 100%. + */ +async function resolveMedComplianceRing( + prisma: PrismaClient, + userId: string, + userTz: string, + now: Date, +): Promise { + const medications = await prisma.medication.findMany({ + // As-needed (PRN) medications carry no expected doses and are + // excluded from every compliance rate (the v1.16.11 rule). + where: { userId, active: true, asNeeded: false }, + select: { + id: true, + createdAt: true, + startsOn: true, + endsOn: true, + oneShot: true, + schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, + // Archived schedule eras + pause eras so a past day scores against + // the schedule that was live then and paused days drop out of the + // denominator — the same context every compliance caller threads. + scheduleRevisions: { + orderBy: { validFrom: "asc" }, + select: { + id: true, + validFrom: true, + validUntil: true, + payload: true, + supersededByRevisionId: true, + }, + }, + pauseEras: { select: { pausedAt: true, resumedAt: true } }, + }, + }); + if (medications.length === 0) return null; + + // 30 days of intake events even though the rate window is 7: rolling / + // interval cadences anchor `nextDue` off the last non-skipped take, + // which can sit before the window start (the health-score pillar + // fetches the same margin). + const since = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const intakeEvents = await prisma.medicationIntakeEvent.findMany({ + where: { + userId, + deletedAt: null, + medicationId: { in: medications.map((m) => m.id) }, + scheduledFor: { gte: since, lte: now }, + }, + select: { + medicationId: true, + scheduledFor: true, + takenAt: true, + skipped: true, + }, + }); + const eventsByMed = new Map(); + for (const ev of intakeEvents) { + const list = eventsByMed.get(ev.medicationId); + if (list) list.push(ev); + else eventsByMed.set(ev.medicationId, [ev]); + } + + let taken = 0; + let missed = 0; + for (const med of medications) { + const events = eventsByMed.get(med.id) ?? []; + const medicationContext = buildComplianceMedicationContext( + med, + lastNonSkippedTakenAt(events), + userTz, + ); + const result = calculateCompliance( + events, + med.schedules, + 7, + med.createdAt, + { + now, + medicationContext, + }, + ); + taken += result.taken; + missed += result.missed; + } + + const denominator = taken + missed; + if (denominator === 0) return null; + const rate = Math.min(100, Math.round((taken / denominator) * 100)); + return { + id: "MED_COMPLIANCE", + score: rate, + band: complianceBandForRate(rate), + }; +} + +/** + * Resolve the selected hero rings against the module map. Selection + * order is preserved; rings without data drop out. Every ring is + * fail-soft on its own — one throwing engine never takes the row (or + * the snapshot) down. + */ +export async function buildScoreRingsBlock( + prisma: PrismaClient, + userId: string, + userTz: string, + selected: ScoreRingId[], + modules: Record, + now: Date, +): Promise { + const eligible = selected.filter((id) => { + if (!(SCORE_RING_IDS as readonly string[]).includes(id)) return false; + if (modules[SCORE_RING_MODULE[id]] === false) return false; + // The derived scores are an insights-layer read everywhere else + // (`/api/insights/derived*` sits behind the insights module) — the + // snapshot honours the same gate. + if (DERIVED_RING_METRIC[id] && modules.insights === false) return false; + return true; + }); + if (eligible.length === 0) return []; + + // Profile loaded ONCE via the shared loader (the batch-route pattern); + // only when a derived ring is actually selected. + const needsProfile = eligible.some((id) => DERIVED_RING_METRIC[id]); + const profile = needsProfile + ? await loadBaselineProfile(prisma, userId) + : null; + + const resolved = await Promise.all( + eligible.map(async (id): Promise => { + try { + if (id === "MED_COMPLIANCE") { + return await resolveMedComplianceRing(prisma, userId, userTz, now); + } + const metric = DERIVED_RING_METRIC[id]; + if (!metric || !profile) return null; + const derived = await computeDerivedMetric({ + metric, + userId, + profile, + now, + }); + if (!isDerivedOk(derived)) return null; + const value = derived.value as ScoreBandValue; + if (typeof value?.score !== "number" || !Number.isFinite(value.score)) { + return null; + } + return { id, score: Math.round(value.score), band: value.band }; + } catch { + // Fail-soft per ring — a transient engine error drops the ring, + // never the snapshot. + return null; + } + }), + ); + return resolved.filter((r): r is DashboardScoreRing => r !== null); +} diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 1c2ca209f..1b26e6bdd 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -93,6 +93,10 @@ import { buildCoachMemoryBlock, type CoachMemoryBlock, } from "@/lib/ai/coach/memory-snapshot"; +import { + buildScoreRingsBlock, + type DashboardScoreRing, +} from "@/lib/dashboard/score-rings"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import type { Locale } from "@/lib/i18n/config"; import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; @@ -401,6 +405,17 @@ export interface DashboardSnapshot { * thick phase alongside `extras`) and when no pillar is computable. */ healthScore: DashboardSnapshotHealthScore | null; + /** + * v1.27.7 — the user-selected hero score rings (max 3), resolved + * server-side: READINESS / RECOVERY_SCORE / SLEEP_SCORE through the + * same engines the derived batch route calls, MED_COMPLIANCE as the + * pooled 7-day adherence from the canonical compliance engine. Only + * rings with data appear (selection order preserved); module-disabled + * and data-less selections drop out, so the hero row self-gates. + * Optional on the type (additive contract) so older cached snapshots + * / fixtures stay valid; the live builder always sets it. + */ + scoreRings?: DashboardScoreRing[]; briefing: DailyBriefing | null; /** * v1.21.2 (A4) — server-resolved recall + forward-look for the briefing card. @@ -1027,6 +1042,12 @@ export async function buildDashboardSnapshot( * derived engines. Defaults to the real `buildScoreNarrativeBlock`. */ scoreNarrative?: typeof buildScoreNarrativeBlock; + /** + * v1.27.7 — injectable hero score-ring resolver so the snapshot tests + * can pin the wire without the derived engines / compliance reads. + * Defaults to the real `buildScoreRingsBlock`. + */ + scoreRings?: typeof buildScoreRingsBlock; } = {}, ): Promise { const userTz = user.timezone ?? DEFAULT_TIMEZONE; @@ -1045,28 +1066,56 @@ export async function buildDashboardSnapshot( const coverage = await time("coverage", () => probeRollupCoverage(user.id)); const warm = isThickPhaseWarm(coverage); - const [slimRaw, moodRaw, extrasResult, flags, medsToday, modules] = - await Promise.all([ - // A5 — reuse the coverage map already probed above so the slice - // doesn't re-run the identical `probeRollupCoverage` query. - time("summaries", () => computeSummariesSlice(user.id, coverage)), - time("mood", () => buildMoodBlock(prisma, user.id)), - warm - ? time("extras", () => - buildExtras(prisma, user, userTz, coverage, now, time), - ) - : Promise.resolve(null), - time("flags", () => getAssistantFlags()), - // Fast phase — projection-backed today tally + earliest next-due. - time("medsToday", () => - buildMedsTodayBlock(prisma, user.id, userTz, now), + // Resolve the stored layout once up front: the hero score rings read + // the `selectedScoreRings` preference off it, and the module-gated + // `layout` block below reuses the same resolution. + const storedLayout = resolveDashboardLayout(user.dashboardWidgetsJson); + + // v1.18.0 — resolved module map (memoised per request); gates the + // toggleable tiles below at the build layer. Held as a shared promise + // so the score-ring task can await it inside the same `Promise.all` + // without a second resolver call. + const modulesPromise = time("modules", () => + (options.modules ?? (() => resolveModuleMap(user.id)))(), + ); + + const [ + slimRaw, + moodRaw, + extrasResult, + flags, + medsToday, + modules, + scoreRings, + ] = await Promise.all([ + // A5 — reuse the coverage map already probed above so the slice + // doesn't re-run the identical `probeRollupCoverage` query. + time("summaries", () => computeSummariesSlice(user.id, coverage)), + time("mood", () => buildMoodBlock(prisma, user.id)), + warm + ? time("extras", () => + buildExtras(prisma, user, userTz, coverage, now, time), + ) + : Promise.resolve(null), + time("flags", () => getAssistantFlags()), + // Fast phase — projection-backed today tally + earliest next-due. + time("medsToday", () => buildMedsTodayBlock(prisma, user.id, userTz, now)), + modulesPromise, + // v1.27.7 — the selected hero score rings, resolved through the + // same engines the derived batch route calls (+ the canonical + // compliance engine for MED_COMPLIANCE). Fail-soft: a throwing + // resolver yields no rings, never a sunk snapshot. + time("scoreRings", async () => + (options.scoreRings ?? buildScoreRingsBlock)( + prisma, + user.id, + userTz, + storedLayout.selectedScoreRings ?? [], + await modulesPromise, + now, ), - // v1.18.0 — resolved module map (memoised per request); gates the - // toggleable tiles below at the build layer. - time("modules", () => - (options.modules ?? (() => resolveModuleMap(user.id)))(), - ), - ]); + ).catch(() => [] as DashboardScoreRing[]), + ]); // v1.18.0 — strip disabled-module data before it leaves the server. // Mood is blanked when the mood module is off; sleep / glucose summary @@ -1090,10 +1139,7 @@ export async function buildDashboardSnapshot( }); } - const layout = gateLayoutByModules( - resolveDashboardLayout(user.dashboardWidgetsJson), - modules, - ); + const layout = gateLayoutByModules(storedLayout, modules); // v1.18.0 — the Daily Briefing is the dashboard's AI-narrative surface, // so the `insights` module gates it alongside the operator briefing // flag + per-user coach opt-out. Disabling `insights` yields the same @@ -1191,6 +1237,10 @@ export async function buildDashboardSnapshot( returnToBand: scoreNarrative?.returnToBand ?? null, } : null, + // v1.27.7 — the resolved hero score rings (selection order, data- + // gated). Always set by the live builder; optional on the type so + // older cached snapshots stay valid. + scoreRings, briefing: briefing.briefing, // v1.21.2 (A4) — the briefing recall + forward-look. Null when there is no // prior narrative on file or no briefing is shown. diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 09f805707..dcdcb3d3c 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -852,6 +852,26 @@ export const dashboardSnapshotResponse = z .describe( "Personal health score summary (0..100 score, traffic-light band, week-over-week delta). Null on a rollup-coverage miss (it rides the thick phase alongside `extras`) and when no pillar is computable. Component breakdown is deliberately not serialised here.", ), + // v1.27.7 — user-selected hero score rings (max 3), resolved + // server-side next to the health score. Additive; optional so + // cached pre-v1.27.7 snapshots stay decodable. + scoreRings: z + .array( + z.object({ + id: z.enum([ + "READINESS", + "RECOVERY_SCORE", + "SLEEP_SCORE", + "MED_COMPLIANCE", + ]), + score: z.number().int(), + band: z.enum(["green", "yellow", "red"]), + }), + ) + .optional() + .describe( + "User-selected hero score rings (max 3, `selectedScoreRings` on the dashboard layout), resolved server-side: READINESS / RECOVERY_SCORE / SLEEP_SCORE via the derived engines (module-gated like `/api/insights/derived`), MED_COMPLIANCE as the pooled 7-day medication adherence (0..100, banded ≥90 green / ≥70 yellow / else red). Only rings with data appear, in selection order — a missing entry means no data or a disabled module, never zero. Clients render what arrives and never recompute.", + ), briefing: z.record(z.string(), z.unknown()).nullable(), briefingState: z.enum(["ready", "preparing", "disabled", "no-provider"]), briefingUpdatedAt: z.string().nullable(), From 8d73542fde01f276598aac3ced56d7cb8490faae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 5 Jul 2026 15:08:20 +0200 Subject: [PATCH 3/6] feat(dashboard): replace the hero dose row with the score-ring row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hero's dose text row gives way to a ring row: the selected score rings render at the health-score ring's sm flat geometry, readiness / recovery / sleep in their wellness-strip hues and the 7-day adherence ring in band semantics — the ring that carries the dose row's information role. Rings self-gate on data, the verdict line above is untouched, and the skeleton mirrors the new geometry (plain card shell, no dose pill, the always-present health circle reserved). Settings -> Dashboard gains a picker under the overview toggle: up to three rings, module-gated rows, switches disable at the cap, persisted through the existing widgets PUT. Locale bundles drop the retired dose-row strings and pick up the picker copy in all six languages. --- messages/de.json | 10 +- messages/en.json | 10 +- messages/es.json | 10 +- messages/fr.json | 10 +- messages/it.json | 10 +- messages/pl.json | 10 +- .../dashboard-hero-skeleton.test.tsx | 14 +- .../hero/__tests__/dashboard-hero.test.tsx | 113 +++++++------- .../hero/dashboard-hero-skeleton.tsx | 20 +-- .../dashboard/hero/dashboard-hero.tsx | 139 +++++++++--------- .../dashboard-layout-section.test.tsx | 71 ++++++++- .../settings/dashboard-layout-section.tsx | 89 +++++++++++ 12 files changed, 325 insertions(+), 181 deletions(-) diff --git a/messages/de.json b/messages/de.json index 28604c9b1..f0799f5ed 100644 --- a/messages/de.json +++ b/messages/de.json @@ -307,12 +307,6 @@ "scoreLabel": "Health Score", "scoreProvisional": "Noch nicht genug Daten", "scoreComputing": "Score wird berechnet…", - "doses": { - "summary": "{taken} von {scheduled} Dosen genommen", - "nextAt": "Nächste um {time}", - "allDone": "Alle Dosen für heute erledigt", - "none": "Heute keine Dosen geplant" - }, "verdict": { "doseOverdue": "Eine Dosis {name} ist überfällig.", "doseOverdueNoName": "Eine Dosis ist überfällig.", @@ -390,7 +384,9 @@ "mood": "Stimmung", "customizeTitle": "Dashboard-Layout", "heroToggleLabel": "Tagesüberblick", - "heroToggleDescription": "Zeigt Begrüßung, Score, Tagesfokus und Dosen-Status ganz oben auf dem Dashboard.", + "heroToggleDescription": "Zeigt Begrüßung, Tagesfokus und Score-Ringe ganz oben auf dem Dashboard.", + "scoreRingsTitle": "Score-Ringe", + "scoreRingsDescription": "Bis zu drei zusätzliche Ringe neben dem Health Score – zum Beispiel Bereitschaft, Erholung, Schlaf oder Therapietreue.", "layoutReset": "Auf Defaults zurücksetzen", "layoutSaveSuccess": "Dashboard-Layout gespeichert", "layoutSaveError": "Layout konnte nicht gespeichert werden", diff --git a/messages/en.json b/messages/en.json index df45fbd25..6efedd9c6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -307,12 +307,6 @@ "scoreLabel": "Health Score", "scoreProvisional": "Not enough data yet", "scoreComputing": "Calculating score…", - "doses": { - "summary": "{taken} of {scheduled} doses taken", - "nextAt": "Next at {time}", - "allDone": "All doses done for today", - "none": "No doses scheduled today" - }, "verdict": { "doseOverdue": "A dose of {name} is overdue.", "doseOverdueNoName": "A dose is overdue.", @@ -390,7 +384,9 @@ "mood": "Mood", "customizeTitle": "Dashboard layout", "heroToggleLabel": "Daily overview", - "heroToggleDescription": "Shows your greeting, score, daily focus and dose status at the top of the dashboard.", + "heroToggleDescription": "Shows your greeting, daily focus and score rings at the top of the dashboard.", + "scoreRingsTitle": "Score rings", + "scoreRingsDescription": "Up to three additional rings next to the health score — for example readiness, recovery, sleep or adherence.", "layoutReset": "Reset to defaults", "layoutSaveSuccess": "Dashboard layout saved", "layoutSaveError": "Could not save dashboard layout", diff --git a/messages/es.json b/messages/es.json index 35f95049a..08c8f7ae8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -307,12 +307,6 @@ "scoreLabel": "Health Score", "scoreProvisional": "Aún no hay datos suficientes", "scoreComputing": "Calculando la puntuación …", - "doses": { - "summary": "{taken} de {scheduled} dosis tomadas", - "nextAt": "Próxima a las {time}", - "allDone": "Todas las dosis de hoy completadas", - "none": "Hoy no hay dosis programadas" - }, "verdict": { "doseOverdue": "Una dosis de {name} está atrasada.", "doseOverdueNoName": "Una dosis está atrasada.", @@ -390,7 +384,9 @@ "mood": "Estado de ánimo", "customizeTitle": "Diseño del panel", "heroToggleLabel": "Resumen del día", - "heroToggleDescription": "Muestra el saludo, la puntuación, el foco del día y el estado de las dosis en la parte superior del panel.", + "heroToggleDescription": "Muestra el saludo, el foco del día y los anillos de puntuación en la parte superior del panel.", + "scoreRingsTitle": "Anillos de puntuación", + "scoreRingsDescription": "Hasta tres anillos adicionales junto a la puntuación de salud, por ejemplo preparación, recuperación, sueño o cumplimiento.", "layoutReset": "Restablecer valores por defecto", "layoutSaveSuccess": "Diseño del panel guardado", "layoutSaveError": "No se ha podido guardar el diseño", diff --git a/messages/fr.json b/messages/fr.json index 92682f7a4..629c762b6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -307,12 +307,6 @@ "scoreLabel": "Health Score", "scoreProvisional": "Pas encore assez de données", "scoreComputing": "Calcul du score en cours …", - "doses": { - "summary": "{taken} dose(s) prise(s) sur {scheduled}", - "nextAt": "Prochaine à {time}", - "allDone": "Toutes les doses du jour sont prises", - "none": "Aucune dose prévue aujourd’hui" - }, "verdict": { "doseOverdue": "Une dose de {name} est en retard.", "doseOverdueNoName": "Une dose est en retard.", @@ -390,7 +384,9 @@ "mood": "Humeur", "customizeTitle": "Mise en page du tableau", "heroToggleLabel": "Aperçu du jour", - "heroToggleDescription": "Affiche le message d’accueil, le score, le focus du jour et l’état des doses tout en haut du tableau de bord.", + "heroToggleDescription": "Affiche le message d’accueil, le focus du jour et les anneaux de score tout en haut du tableau de bord.", + "scoreRingsTitle": "Anneaux de score", + "scoreRingsDescription": "Jusqu’à trois anneaux supplémentaires à côté du score de santé — par exemple préparation, récupération, sommeil ou observance.", "layoutReset": "Rétablir les valeurs par défaut", "layoutSaveSuccess": "Mise en page enregistrée", "layoutSaveError": "Impossible d’enregistrer la mise en page", diff --git a/messages/it.json b/messages/it.json index 6a20cc358..5b0c07d0c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -307,12 +307,6 @@ "scoreLabel": "Health Score", "scoreProvisional": "Dati ancora insufficienti", "scoreComputing": "Calcolo del punteggio in corso …", - "doses": { - "summary": "{taken} dosi assunte su {scheduled}", - "nextAt": "Prossima alle {time}", - "allDone": "Tutte le dosi di oggi completate", - "none": "Nessuna dose in programma oggi" - }, "verdict": { "doseOverdue": "Una dose di {name} è in ritardo.", "doseOverdueNoName": "Una dose è in ritardo.", @@ -390,7 +384,9 @@ "mood": "Umore", "customizeTitle": "Layout della dashboard", "heroToggleLabel": "Panoramica del giorno", - "heroToggleDescription": "Mostra saluto, punteggio, focus del giorno e stato delle dosi in cima alla dashboard.", + "heroToggleDescription": "Mostra saluto, focus del giorno e anelli dei punteggi in cima alla dashboard.", + "scoreRingsTitle": "Anelli dei punteggi", + "scoreRingsDescription": "Fino a tre anelli aggiuntivi accanto al punteggio di salute, ad esempio prontezza, recupero, sonno o aderenza.", "layoutReset": "Ripristina i valori predefiniti", "layoutSaveSuccess": "Layout della dashboard salvato", "layoutSaveError": "Impossibile salvare il layout", diff --git a/messages/pl.json b/messages/pl.json index 179663f05..b760ee5a2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -307,12 +307,6 @@ "scoreLabel": "Health Score", "scoreProvisional": "Za mało danych", "scoreComputing": "Trwa obliczanie wyniku …", - "doses": { - "summary": "Przyjęto {taken} z {scheduled} dawek", - "nextAt": "Następna o {time}", - "allDone": "Wszystkie dzisiejsze dawki przyjęte", - "none": "Brak zaplanowanych dawek na dziś" - }, "verdict": { "doseOverdue": "Dawka {name} jest zaległa.", "doseOverdueNoName": "Dawka jest zaległa.", @@ -390,7 +384,9 @@ "mood": "Nastrój", "customizeTitle": "Układ panelu", "heroToggleLabel": "Przegląd dnia", - "heroToggleDescription": "Pokazuje powitanie, wynik, fokus dnia i status dawek na górze pulpitu.", + "heroToggleDescription": "Pokazuje powitanie, fokus dnia i pierścienie wyników na górze pulpitu.", + "scoreRingsTitle": "Pierścienie wyników", + "scoreRingsDescription": "Maksymalnie trzy dodatkowe pierścienie obok wskaźnika zdrowia — np. gotowość, regeneracja, sen lub przestrzeganie zaleceń.", "layoutReset": "Przywróć wartości domyślne", "layoutSaveSuccess": "Układ panelu zapisany", "layoutSaveError": "Nie udało się zapisać układu", diff --git a/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx b/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx index 0084eb4c6..3e903572e 100644 --- a/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx +++ b/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx @@ -23,14 +23,26 @@ describe("", () => { }); it("reserves the hero's exact footprint classes", () => { + // v1.27.7 — the shell mirrors the loaded band's plain `bg-card` + // surface (the old `hero-gradient` chrome was a leftover from the + // pre-v1.18.1 gradient hero and never matched the loaded state). for (const cls of [ "min-h-[8.75rem]", "md:min-h-[9.5rem]", - "hero-gradient", + "bg-card", + "border-border", "rounded-xl", ]) { expect(html).toContain(cls); } + expect(html).not.toContain("hero-gradient"); + }); + + it("reserves no dose-row pill — the ring row replaced it (v1.27.7)", () => { + // The left column carries exactly three skeleton bars (greeting + + // verdict + CTA); the old fourth dose-pill bar is gone. + const bars = html.match(/data-slot="skeleton"/g) ?? []; + expect(bars.length).toBe(4); // 3 left-column bars + 1 ring circle }); it("mirrors the two-column md:flex-row split with the fixed ring circle", () => { diff --git a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx index 85c65068c..aadc2fbba 100644 --- a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx +++ b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx @@ -19,9 +19,11 @@ import { DashboardHero } from "../dashboard-hero"; * a button; allQuiet carries none); * - the briefing rung renders the model headline VERBATIM as plain * text (no i18n key); - * - the dose row prints the day tally with next-at / all-done / none - * detail, and the documented stale-cache state (past `nextDueAt`, - * `nextDueOverdue: false`) renders as the plain summary — never as + * - v1.27.7 — the ring row replaced the dose text row: the selected + * `scoreRings` render beside the health-score ring (hue per ring, + * band semantics for the adherence ring) and self-gate on data; + * the documented stale-cache state (past `nextDueAt`, + * `nextDueOverdue: false`) still renders a calm verdict — never * overdue; * - the greeting derives from the snapshot's server-computed * `greetingHour` and personalises from the snapshot username; @@ -233,8 +235,6 @@ describe(" — verdict variants", () => { expect(html).toContain('data-verdict-variant="doseUpcoming"'); expect(html).toContain(`Um ${expectedTime} steht Jardiance an.`); expect(html).toContain("Jetzt eintragen"); - // The dose row repeats the same instant as "Nächste um …". - expect(html).toContain(`Nächste um ${expectedTime}`); }); it("weightDrift: sentence + link to the weight insight", () => { @@ -366,49 +366,65 @@ describe(" — verdict variants", () => { }); }); -describe(" — dose row", () => { - it("prints the day tally with the neutral chip chrome and the Pill icon", () => { +describe(" — ring row (v1.27.7)", () => { + it("renders the selected rings beside the health-score ring, in selection order", () => { + const html = render( + baseSnapshot({ + healthScore: { score: 82, band: "green", delta: 2 }, + scoreRings: [ + { id: "READINESS", score: 71, band: "green" }, + { id: "MED_COMPLIANCE", score: 95, band: "green" }, + ], + }), + ); + const row = html.match(/data-slot="dashboard-hero-rings"/g) ?? []; + expect(row).toHaveLength(1); + // Selection order preserved, health ring trailing (3 rings total). + const readinessIdx = html.indexOf('data-ring="READINESS"'); + const complianceIdx = html.indexOf('data-ring="MED_COMPLIANCE"'); + expect(readinessIdx).toBeGreaterThan(-1); + expect(complianceIdx).toBeGreaterThan(readinessIdx); + const rings = html.match(/data-slot="score-ring"/g) ?? []; + expect(rings).toHaveLength(3); + // Labels (de locale): the derived ring reuses its wellness-strip + // title, the adherence ring the medications adherence label. + expect(html).toContain("Bereitschaft"); + expect(html).toContain("Therapietreue"); + }); + + it("no dose text row renders anymore — the ring row carries the slot", () => { const html = render( baseSnapshot({ medsToday: medsToday({ scheduledToday: 3, takenToday: 1 }), }), ); - expect(html).toContain("1 von 3 Dosen genommen"); - const doseRow = html.match( - /]*data-slot="dashboard-hero-doses"[^>]*>/, - ); - expect(doseRow).not.toBeNull(); - // v1.18.1: chip sits on a neutral muted surface over the plain card — - // no translucent card tint, no blur, no shadow (the gradient is gone). - for (const cls of ["bg-muted/50", "border-border/60", "rounded-xl"]) { - expect(doseRow![0]).toContain(cls); - } - expect(doseRow![0]).not.toContain("backdrop-blur"); - expect(doseRow![0]).not.toContain("shadow-sm"); - }); - - it("all doses resolved (taken + skipped) → 'Alle Dosen für heute erledigt'", () => { - const html = render( - baseSnapshot({ - medsToday: medsToday({ - scheduledToday: 2, - takenToday: 1, - skippedToday: 1, - }), - }), - ); - expect(html).toContain("1 von 2 Dosen genommen"); - expect(html).toContain("Alle Dosen für heute erledigt"); - expect(html).not.toContain("Nächste um"); - }); - - it("no doses scheduled → 'Heute keine Dosen geplant'", () => { - const html = render(baseSnapshot()); - expect(html).toContain("Heute keine Dosen geplant"); + expect(html).not.toContain('data-slot="dashboard-hero-doses"'); expect(html).not.toContain("Dosen genommen"); }); - it("DEFENSIVE: a stale past nextDueAt with nextDueOverdue false renders the plain summary, never overdue", () => { + it("an empty / absent scoreRings block leaves the health ring alone (self-gating)", () => { + const html = render( + baseSnapshot({ healthScore: { score: 60, band: "yellow", delta: 0 } }), + ); + const rings = html.match(/data-slot="score-ring"/g) ?? []; + expect(rings).toHaveLength(1); + expect(html).not.toContain('data-slot="dashboard-hero-ring"'); + }); + + it("the adherence ring paints band semantics (data-band), not a metric hue", () => { + const html = render( + baseSnapshot({ + scoreRings: [{ id: "MED_COMPLIANCE", score: 65, band: "red" }], + }), + ); + const ring = html.match( + /]*data-ring="MED_COMPLIANCE"[\s\S]*?data-band="([a-z]+)"/, + ); + expect(ring).not.toBeNull(); + expect(ring![1]).toBe("red"); + }); + + it("DEFENSIVE: a stale past nextDueAt with nextDueOverdue false still renders a calm verdict, never overdue", () => { const html = render( baseSnapshot({ medsToday: medsToday({ @@ -420,26 +436,9 @@ describe(" — dose row", () => { }), }), ); - expect(html).toContain("1 von 2 Dosen genommen"); - expect(html).not.toContain("Nächste um"); expect(html).not.toContain("überfällig"); expect(html).toContain('data-verdict-variant="allQuiet"'); }); - - it("a next-due anchor on TOMORROW's local day stays off the dose row", () => { - const html = render( - baseSnapshot({ - medsToday: medsToday({ - scheduledToday: 2, - takenToday: 1, - nextDueAt: isoHoursFromNow(20), // 08:00 Berlin tomorrow - nextDueMedicationName: "Metformin", - }), - }), - ); - expect(html).toContain("1 von 2 Dosen genommen"); - expect(html).not.toContain("Nächste um"); - }); }); describe(" — greeting", () => { diff --git a/src/components/dashboard/hero/dashboard-hero-skeleton.tsx b/src/components/dashboard/hero/dashboard-hero-skeleton.tsx index 3b85873cc..5d73ebf9d 100644 --- a/src/components/dashboard/hero/dashboard-hero-skeleton.tsx +++ b/src/components/dashboard/hero/dashboard-hero-skeleton.tsx @@ -3,12 +3,16 @@ import { Skeleton } from "@/components/ui/skeleton"; /** * Loading silhouette for the dashboard hero band. * - * Mirrors the real `` footprint exactly — same - * `.hero-gradient` chrome, same `min-h-[8.75rem] md:min-h-[9.5rem]` + * Mirrors the real `` footprint — same plain `bg-card` + * shell (border + radius + `p-4 md:p-6`, matching the loaded band since + * the v1.18.1 gradient removal), same `min-h-[8.75rem] md:min-h-[9.5rem]` * floor, same two-column `md:flex-row` split with the fixed 120 px ring * circle on the right — so the swap to the loaded hero happens in place - * with zero layout shift. The left column reserves the greeting line, - * the verdict sentence + CTA row, and the dose-row pill. + * with zero layout shift. The left column reserves the greeting line and + * the verdict sentence + CTA row; the right column reserves the + * health-score circle (v1.27.7 — the old dose-row pill is gone; the + * user-selected extra rings self-gate on data, so the skeleton reserves + * only the always-present health ring and lets the row grow in place). * * Always `aria-hidden` with no focusable content: the tile-strip * skeleton alongside carries the page's loading semantics, and a second @@ -21,7 +25,7 @@ export function DashboardHeroSkeleton() { - {/* Right column — fixed 120 px ring footprint. A null score - renders the ring's provisional state (aria announces "not - enough data", never 0) at identical geometry, so the column - NEVER collapses. */} -
+ {/* Ring row — the selected score rings (max 3, each at the + fixed 120 px sm footprint, flat) lead into the health-score + ring on the trailing edge. A null health score renders the + ring's provisional state (aria announces "not enough data", + never 0) at identical geometry, so the column NEVER + collapses; missing selected rings render nothing. */} +
+ {scoreRings.map((ring) => ( +
+ +
+ ))} — hero visibility switch", () => { const de = render(, "de"); expect(de).toContain("Tagesüberblick"); expect(de).toContain( - "Zeigt Begrüßung, Score, Tagesfokus und Dosen-Status ganz oben auf dem Dashboard.", + "Zeigt Begrüßung, Tagesfokus und Score-Ringe ganz oben auf dem Dashboard.", ); const en = render(); expect(en).toContain("Daily overview"); @@ -438,3 +438,72 @@ describe(" — disabled-module widget toggles", () => { expect(html).toContain(ACHIEVEMENTS_ARIA); }); }); + +/** + * v1.27.7 — hero score-ring picker. Rendered only while the hero is on + * (the rings live inside the hero band); offers only rings whose owning + * module is enabled; caps the selection at three via disabled switches. + * SSR smoke assertions, matching the rest of this suite. + */ +describe(" — hero score-ring picker (v1.27.7)", () => { + it("stays hidden while the hero is off (default layout)", () => { + const html = render(); + expect(html).not.toContain('data-slot="score-rings-picker"'); + }); + + it("renders one switch per ring when the hero is on (fail-open module map)", () => { + queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true }; + const html = render(); + expect(html).toContain('data-slot="score-rings-picker"'); + const switches = html.match(/data-slot="score-ring-switch"/g) ?? []; + expect(switches).toHaveLength(4); + // The default selection (MED_COMPLIANCE) reads checked. + const compliance = html.match( + /]*data-slot="score-ring-switch"[^>]*data-ring="MED_COMPLIANCE"[^>]*>/, + ); + expect(compliance).not.toBeNull(); + expect(compliance![0]).toContain('data-state="checked"'); + }); + + it("hides rings whose owning module is disabled", () => { + queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true }; + authState.modules = { recovery: false, sleep: false }; + const html = render(); + const switches = html.match(/data-slot="score-ring-switch"/g) ?? []; + // READINESS + RECOVERY_SCORE (recovery) and SLEEP_SCORE (sleep) are + // gone; only the adherence ring remains offered. + expect(switches).toHaveLength(1); + expect(html).toContain('data-ring="MED_COMPLIANCE"'); + }); + + it("hides the derived rings when the insights module is off", () => { + queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true }; + authState.modules = { insights: false }; + const html = render(); + const switches = html.match(/data-slot="score-ring-switch"/g) ?? []; + expect(switches).toHaveLength(1); + expect(html).toContain('data-ring="MED_COMPLIANCE"'); + }); + + it("disables the remaining switches at the three-ring cap", () => { + queryState.layout = { + ...DEFAULT_DASHBOARD_LAYOUT, + heroVisible: true, + selectedScoreRings: ["READINESS", "RECOVERY_SCORE", "SLEEP_SCORE"], + }; + const html = render(); + const compliance = html.match( + /]*data-slot="score-ring-switch"[^>]*data-ring="MED_COMPLIANCE"[^>]*>/, + ); + expect(compliance).not.toBeNull(); + // The rendered `disabled` HTML attribute — not the Tailwind + // `disabled:` variant prefixes that every switch class list carries. + expect(compliance![0]).toContain('disabled=""'); + // The three selected rings stay enabled (unchecking must stay possible). + const readiness = html.match( + /]*data-slot="score-ring-switch"[^>]*data-ring="READINESS"[^>]*>/, + ); + expect(readiness).not.toBeNull(); + expect(readiness![0]).not.toContain('disabled=""'); + }); +}); diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 52678914f..84a8c1e2b 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -36,10 +36,14 @@ import { type DashboardLayout, type DashboardWidgetId, type ComparisonBaseline, + type ScoreRingId, COMPARISON_BASELINES, DEFAULT_DASHBOARD_LAYOUT, DASHBOARD_WIDGET_IDS, IOS_PIN_ONLY_WIDGET_IDS, + SCORE_RING_IDS, + SCORE_RING_MODULE, + MAX_SELECTED_SCORE_RINGS, } from "@/lib/dashboard-layout"; import { Select, @@ -152,6 +156,19 @@ const WIDGET_LABEL_KEYS: Record = { vorsorge: "measurementReminders.sectionTitle", }; +/** + * v1.27.7 — option labels for the hero score-ring picker. The three + * derived rings reuse their wellness-strip titles; the adherence ring + * reuses the existing "adherence (7 days)" label so the picker names + * the exact window the ring shows. + */ +const SCORE_RING_LABEL_KEYS: Record = { + READINESS: "insights.derived.composite.READINESS.title", + RECOVERY_SCORE: "insights.derived.scores.recovery", + SLEEP_SCORE: "insights.derived.composite.SLEEP_SCORE.title", + MED_COMPLIANCE: "dashboard.compliance7d", +}; + export function DashboardLayoutSection({ id }: { id: string }) { const { t } = useTranslations(); const queryClient = useQueryClient(); @@ -283,6 +300,24 @@ export function DashboardLayoutSection({ id }: { id: string }) { setDraft({ ...layout, heroVisible: value }); } + /** + * v1.27.7 — toggle one hero score ring. Selection order is preserved + * (a newly-enabled ring appends), the count is capped at + * `MAX_SELECTED_SCORE_RINGS` (the remaining switches disable at the + * cap), and the choice persists as `selectedScoreRings` on the layout + * blob through the same PUT the widget toggles use. + */ + function toggleScoreRing(ringId: ScoreRingId, enabled: boolean) { + if (!layout) return; + const current = layout.selectedScoreRings ?? []; + const next = enabled + ? current.includes(ringId) + ? current + : [...current, ringId].slice(0, MAX_SELECTED_SCORE_RINGS) + : current.filter((id) => id !== ringId); + setDraft({ ...layout, selectedScoreRings: next }); + } + /** * v1.4.48 M6a — the arrow buttons now delegate to `reorderWidgets()` * so the swap-and-renumber logic lives in exactly one place. The @@ -439,6 +474,60 @@ export function DashboardLayoutSection({ id }: { id: string }) {
)} + {/* v1.27.7 — hero score-ring picker. Only rendered while the hero + is on (the rings live inside the hero band, so the picker would + be a dead control otherwise). Offers only rings whose owning + module is enabled — the WIDGET_MODULE_BY_ID gating pattern; the + three derived rings additionally sit behind the insights + module, mirroring the derived routes. Selection caps at + MAX_SELECTED_SCORE_RINGS; unchecked switches disable at the cap. */} + {layout && layout.heroVisible === true && ( +
+
+

+ {t("dashboard.scoreRingsTitle")} +

+

+ {t("dashboard.scoreRingsDescription")} +

+
+
+ {SCORE_RING_IDS.filter((ringId) => { + if (modules?.[SCORE_RING_MODULE[ringId]] === false) return false; + if (ringId !== "MED_COMPLIANCE" && modules?.insights === false) + return false; + return true; + }).map((ringId) => { + const selected = layout.selectedScoreRings ?? []; + const checked = selected.includes(ringId); + const atCap = + !checked && selected.length >= MAX_SELECTED_SCORE_RINGS; + return ( +
+ + {t(SCORE_RING_LABEL_KEYS[ringId])} + + toggleScoreRing(ringId, v)} + disabled={saveMutation.isPending || atCap} + aria-label={t(SCORE_RING_LABEL_KEYS[ringId])} + data-slot="score-ring-switch" + data-ring={ringId} + /> +
+ ); + })} +
+
+ )} + {isLoading || !layout ? (
From 2d73e70dd40757484b4892246a759f978e6f8966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 5 Jul 2026 15:48:35 +0200 Subject: [PATCH 4/6] feat(dashboard): drop the broad Insights CTA and deep-link the briefing signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scoreDrop/briefing verdict button only opened the Insights overview, which the health-score card already links — gone. The briefing-spotlight rows now deep-link to their metric's sub-page via the shared METRIC_HREF mapping; metrics without a routed page fall back to the overview. --- src/components/dashboard/hero/briefing-spotlight.tsx | 6 +++++- src/components/dashboard/hero/dashboard-hero.tsx | 8 +++++--- src/components/insights/daily-briefing.tsx | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/components/dashboard/hero/briefing-spotlight.tsx b/src/components/dashboard/hero/briefing-spotlight.tsx index ae222e0b8..dcdc6f95a 100644 --- a/src/components/dashboard/hero/briefing-spotlight.tsx +++ b/src/components/dashboard/hero/briefing-spotlight.tsx @@ -49,6 +49,7 @@ import { Zap, } from "lucide-react"; import { ListRow } from "@/components/ui/list-row"; +import { METRIC_HREF } from "@/components/insights/daily-briefing"; import { useTranslations } from "@/lib/i18n/context"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { cn } from "@/lib/utils"; @@ -178,7 +179,10 @@ export function BriefingSpotlight({ "focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none", )} > - + {/* Deep-link each signal to its metric sub-page (the same + mapping the briefing card's finding rows use); metrics + without a routed sub-page fall back to the overview. */} +