feat(dashboard): configure Today highlights

This commit is contained in:
Marc-André Bombeck committed 2026-07-28 13:22:08 +02:00
1 parent a2f2a30ac2
commit a689329e79
30 files changed
+951 -1098

No files matched your search

+35 -2
View File
@@ -11689,10 +11689,11 @@ paths:
tags:
- Dashboard
summary: Reset the calling user's dashboard widget layout
description: Clears the persisted layout and returns the default layout. Idempotent.
description: Resets the tile layout and Today-highlight visibility. It preserves the stored comparison baseline, chart
overlays, selected score rings, and hero ring order. Idempotent.
responses:
"200":
description: Layout reset; the default layout is returned.
description: Web-controlled layout settings reset; preserved dashboard preferences are returned with the defaults.
content:
application/json:
schema:
@@ -14377,6 +14378,22 @@ components:
- RECOVERY_SCORE
- SLEEP_SCORE
- MED_COMPLIANCE
enabledHeroItemKinds:
description: Today-highlight item kinds in catalogue order. Missing means every current kind is enabled; an empty
array disables all kinds.
maxItems: 8
type: array
items:
type: string
enum:
- coach_checkin
- dose_window
- preventive_care
- sync_issue
- milestone
- ecg_new_recording
- tension_window
- same_time_baseline
baseUpdatedAt:
description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the
legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored
@@ -33206,6 +33223,22 @@ components:
- RECOVERY_SCORE
- SLEEP_SCORE
- MED_COMPLIANCE
enabledHeroItemKinds:
description: Today-highlight item kinds in catalogue order. Missing means every current kind is enabled; an empty
array disables all kinds.
maxItems: 8
type: array
items:
type: string
enum:
- coach_checkin
- dose_window
- preventive_care
- sync_issue
- milestone
- ecg_new_recording
- tension_window
- same_time_baseline
updatedAt:
description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as
`baseUpdatedAt` on the next write. Opaque."
+14 -8
View File
@@ -396,10 +396,6 @@
},
"welcomeBack": "{greeting}, willkommen zurück.",
"welcomeBackWithName": "{greeting} {name}, willkommen zurück.",
"hero": {
"scoreLabel": "Health Score",
"ringDoses": "Dosen heute"
},
"greetingSalutation": "Hallo, {name}",
"streakLabel": "Tage in Folge",
"weight": "Gewicht",
@@ -452,11 +448,22 @@
"compliance7d": "Therapietreue (7 Tage)",
"mood": "Stimmung",
"customizeTitle": "Dashboard-Layout",
"scoreRingsTitle": "Score-Ringe",
"scoreRingsDescription": "Bis zu drei zusätzliche Ringe neben dem Health Score – zum Beispiel Bereitschaft, Erholung, Schlaf oder Therapietreue. Zum Umsortieren ziehen; der Health Score steht standardmäßig vorne.",
"heroItemsTitle": "Highlights für heute",
"heroItemsDescription": "Wähle aus, welche Eintragstypen in den Highlights für heute erscheinen.",
"heroItemsNotificationNote": "Benachrichtigungseinstellungen werden separat verwaltet.",
"heroItems": {
"coach_checkin": "Coach-Check-ins",
"dose_window": "Medikamentendosen",
"preventive_care": "Vorsorge",
"sync_issue": "Synchronisierungsprobleme",
"milestone": "Meilensteine",
"ecg_new_recording": "Neue EKG-Aufzeichnungen",
"tension_window": "Anspannungsphasen",
"same_time_baseline": "Aktivität zur gleichen Tageszeit"
},
"layoutReset": "Auf Defaults zurücksetzen",
"layoutResetTitle": "Dashboard-Layout zurücksetzen?",
"layoutResetBody": "Deine Kachel-Reihenfolge, die ausgeblendeten Kacheln und die gewählten Ringe kehren zu den Standardwerten zurück. Die Anordnung wird nirgends sonst gespeichert.",
"layoutResetBody": "Dein Kachel-Layout und deine Auswahl für die Highlights für heute werden auf die Standardwerte zurückgesetzt.",
"layoutResetConfirm": "Layout zurücksetzen",
"layoutSaveSuccess": "Dashboard-Layout gespeichert",
"layoutSaveError": "Layout konnte nicht gespeichert werden",
@@ -2081,7 +2088,6 @@
},
"comparison": {
"toggleLabel": "Vergleichen mit",
"toggleHint": "Vergleicht den aktuellen Zeitraum mit dem Vorjahreszeitraum.",
"baseline": {
"none": "Aus",
"lastMonth": "Vormonat",
+14 -8
View File
@@ -396,10 +396,6 @@
},
"welcomeBack": "{greeting}, welcome back.",
"welcomeBackWithName": "{greeting} {name}, welcome back.",
"hero": {
"scoreLabel": "Health Score",
"ringDoses": "Doses today"
},
"greetingSalutation": "Hi, {name}",
"streakLabel": "days in a row",
"weight": "Weight",
@@ -452,11 +448,22 @@
"compliance7d": "Adherence (7 days)",
"mood": "Mood",
"customizeTitle": "Dashboard layout",
"scoreRingsTitle": "Score rings",
"scoreRingsDescription": "Up to three additional rings next to the health score — for example readiness, recovery, sleep or adherence. Drag to reorder; the health score leads by default.",
"heroItemsTitle": "Today highlights",
"heroItemsDescription": "Choose which item types can appear in Today highlights.",
"heroItemsNotificationNote": "Notification settings are managed separately.",
"heroItems": {
"coach_checkin": "Coach check-ins",
"dose_window": "Medication doses",
"preventive_care": "Preventive care",
"sync_issue": "Sync issues",
"milestone": "Milestones",
"ecg_new_recording": "New ECG recordings",
"tension_window": "Tension windows",
"same_time_baseline": "Same-time activity"
},
"layoutReset": "Reset to defaults",
"layoutResetTitle": "Reset the dashboard layout?",
"layoutResetBody": "Your tile order, the tiles you hid and the rings you chose all go back to defaults. The arrangement is not stored anywhere else.",
"layoutResetBody": "Your tile layout and Today highlight choices return to their defaults.",
"layoutResetConfirm": "Reset layout",
"layoutSaveSuccess": "Dashboard layout saved",
"layoutSaveError": "Could not save dashboard layout",
@@ -2081,7 +2088,6 @@
},
"comparison": {
"toggleLabel": "Compare to",
"toggleHint": "Show the same period from last year alongside today's chart.",
"baseline": {
"none": "None",
"lastMonth": "Last month",
+14 -8
View File
@@ -396,10 +396,6 @@
},
"welcomeBack": "{greeting}, te damos la bienvenida de nuevo.",
"welcomeBackWithName": "{greeting} {name}, te damos la bienvenida de nuevo.",
"hero": {
"scoreLabel": "Health Score",
"ringDoses": "Dosis de hoy"
},
"greetingSalutation": "Hola, {name}",
"streakLabel": "días seguidos",
"weight": "Peso",
@@ -452,11 +448,22 @@
"compliance7d": "Cumplimiento (7 días)",
"mood": "Estado de ánimo",
"customizeTitle": "Diseño 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. Arrastra para reordenar; la puntuación de salud va primero de forma predeterminada.",
"heroItemsTitle": "Destacados de hoy",
"heroItemsDescription": "Elige qué tipos de elementos pueden aparecer en los destacados de hoy.",
"heroItemsNotificationNote": "Los ajustes de notificaciones se gestionan por separado.",
"heroItems": {
"coach_checkin": "Revisión con Coach",
"dose_window": "Dosis de medicamentos",
"preventive_care": "Atención preventiva",
"sync_issue": "Problemas de sincronización",
"milestone": "Hitos",
"ecg_new_recording": "Nuevos registros de ECG",
"tension_window": "Periodos de tensión",
"same_time_baseline": "Actividad a la misma hora"
},
"layoutReset": "Restablecer valores por defecto",
"layoutResetTitle": "¿Restablecer el diseño del panel?",
"layoutResetBody": "El orden de tus tarjetas, las que ocultaste y los anillos que elegiste vuelven a los valores predeterminados. La disposición no se guarda en ningún otro sitio.",
"layoutResetBody": "El diseño de tus tarjetas y tus opciones para los destacados de hoy vuelven a los valores predeterminados.",
"layoutResetConfirm": "Restablecer diseño",
"layoutSaveSuccess": "Diseño del panel guardado",
"layoutSaveError": "No se ha podido guardar el diseño",
@@ -2081,7 +2088,6 @@
},
"comparison": {
"toggleLabel": "Comparar con",
"toggleHint": "Compara el periodo actual con el mismo periodo del año anterior.",
"baseline": {
"none": "Desactivado",
"lastMonth": "Mes anterior",
+14 -8
View File
@@ -396,10 +396,6 @@
},
"welcomeBack": "{greeting}, content de te revoir.",
"welcomeBackWithName": "{greeting} {name}, content de te revoir.",
"hero": {
"scoreLabel": "Health Score",
"ringDoses": "Doses du jour"
},
"greetingSalutation": "Bonjour, {name}",
"streakLabel": "jours d’affilée",
"weight": "Poids",
@@ -452,11 +448,22 @@
"compliance7d": "Observance (7 jours)",
"mood": "Humeur",
"customizeTitle": "Mise en page du tableau",
"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. Glissez pour réordonner ; le score de santé est placé en premier par défaut.",
"heroItemsTitle": "Temps forts du jour",
"heroItemsDescription": "Choisis les types d’éléments qui peuvent apparaître dans les temps forts du jour.",
"heroItemsNotificationNote": "Les réglages des notifications sont gérés séparément.",
"heroItems": {
"coach_checkin": "Bilan avec Coach",
"dose_window": "Doses de médicaments",
"preventive_care": "Prévention",
"sync_issue": "Problèmes de synchronisation",
"milestone": "Étapes franchies",
"ecg_new_recording": "Nouveaux enregistrements ECG",
"tension_window": "Périodes de tension",
"same_time_baseline": "Activité à la même heure"
},
"layoutReset": "Rétablir les valeurs par défaut",
"layoutResetTitle": "Réinitialiser la disposition du tableau de bord ?",
"layoutResetBody": "L’ordre de vos tuiles, celles que vous avez masquées et les anneaux choisis reviennent aux valeurs par défaut. Cette disposition n’est conservée nulle part ailleurs.",
"layoutResetBody": "La disposition de tes tuiles et tes choix pour les temps forts du jour reviennent aux valeurs par défaut.",
"layoutResetConfirm": "Réinitialiser la disposition",
"layoutSaveSuccess": "Mise en page enregistrée",
"layoutSaveError": "Impossible d’enregistrer la mise en page",
@@ -2081,7 +2088,6 @@
},
"comparison": {
"toggleLabel": "Comparer avec",
"toggleHint": "Compare la période actuelle avec la même période de l’année précédente.",
"baseline": {
"none": "Désactivé",
"lastMonth": "Mois précédent",
+14 -8
View File
@@ -396,10 +396,6 @@
},
"welcomeBack": "{greeting}, bentornato.",
"welcomeBackWithName": "{greeting} {name}, bentornato.",
"hero": {
"scoreLabel": "Health Score",
"ringDoses": "Dosi di oggi"
},
"greetingSalutation": "Ciao, {name}",
"streakLabel": "giorni di fila",
"weight": "Peso",
@@ -452,11 +448,22 @@
"compliance7d": "Aderenza (7 giorni)",
"mood": "Umore",
"customizeTitle": "Layout della dashboard",
"scoreRingsTitle": "Anelli dei punteggi",
"scoreRingsDescription": "Fino a tre anelli aggiuntivi accanto al punteggio di salute, ad esempio prontezza, recupero, sonno o aderenza. Trascina per riordinare; il punteggio di salute è primo per impostazione predefinita.",
"heroItemsTitle": "In evidenza oggi",
"heroItemsDescription": "Scegli quali tipi di elementi possono apparire nella sezione In evidenza oggi.",
"heroItemsNotificationNote": "Le impostazioni delle notifiche vengono gestite separatamente.",
"heroItems": {
"coach_checkin": "Verifiche con Coach",
"dose_window": "Dosi dei farmaci",
"preventive_care": "Prevenzione",
"sync_issue": "Problemi di sincronizzazione",
"milestone": "Traguardi",
"ecg_new_recording": "Nuove registrazioni ECG",
"tension_window": "Periodi di tensione",
"same_time_baseline": "Attività alla stessa ora"
},
"layoutReset": "Ripristina i valori predefiniti",
"layoutResetTitle": "Reimpostare il layout della dashboard?",
"layoutResetBody": "L’ordine dei riquadri, quelli che hai nascosto e gli anelli scelti tornano ai valori predefiniti. La disposizione non è salvata altrove.",
"layoutResetBody": "Il layout dei riquadri e le scelte per la sezione In evidenza oggi tornano ai valori predefiniti.",
"layoutResetConfirm": "Reimposta layout",
"layoutSaveSuccess": "Layout della dashboard salvato",
"layoutSaveError": "Impossibile salvare il layout",
@@ -2081,7 +2088,6 @@
},
"comparison": {
"toggleLabel": "Confronta con",
"toggleHint": "Confronta il periodo attuale con lo stesso periodo dell’anno precedente.",
"baseline": {
"none": "Disattivato",
"lastMonth": "Mese precedente",
+14 -8
View File
@@ -396,10 +396,6 @@
},
"welcomeBack": "{greeting}, witaj ponownie.",
"welcomeBackWithName": "{greeting} {name}, witaj ponownie.",
"hero": {
"scoreLabel": "Health Score",
"ringDoses": "Dzisiejsze dawki"
},
"greetingSalutation": "Cześć, {name}",
"streakLabel": "dni z rzędu",
"weight": "Waga",
@@ -452,11 +448,22 @@
"compliance7d": "Przestrzeganie (7 dni)",
"mood": "Nastrój",
"customizeTitle": "Układ panelu",
"scoreRingsTitle": "Pierścienie wyników",
"scoreRingsDescription": "Maksymalnie trzy dodatkowe pierścienie obok wskaźnika zdrowia — np. gotowość, regeneracja, sen lub przestrzeganie zaleceń. Przeciągnij, aby zmienić kolejność; wskaźnik zdrowia jest domyślnie na początku.",
"heroItemsTitle": "Dzisiejsze wyróżnienia",
"heroItemsDescription": "Wybierz typy elementów, które mogą pojawiać się w dzisiejszych wyróżnieniach.",
"heroItemsNotificationNote": "Ustawieniami powiadomień zarządzasz osobno.",
"heroItems": {
"coach_checkin": "Podsumowania z Coachem",
"dose_window": "Dawki leków",
"preventive_care": "Profilaktyka",
"sync_issue": "Problemy z synchronizacją",
"milestone": "Kamienie milowe",
"ecg_new_recording": "Nowe zapisy EKG",
"tension_window": "Okresy napięcia",
"same_time_baseline": "Aktywność o tej samej porze"
},
"layoutReset": "Przywróć wartości domyślne",
"layoutResetTitle": "Zresetować układ pulpitu?",
"layoutResetBody": "Kolejność kafelków, ukryte kafelki i wybrane pierścienie wracają do ustawień domyślnych. Ten układ nie jest nigdzie indziej zapisany.",
"layoutResetBody": "Układ kafelków i wybory dotyczące dzisiejszych wyróżnień wracają do ustawień domyślnych.",
"layoutResetConfirm": "Zresetuj układ",
"layoutSaveSuccess": "Układ panelu zapisany",
"layoutSaveError": "Nie udało się zapisać układu",
@@ -2081,7 +2088,6 @@
},
"comparison": {
"toggleLabel": "Porównaj z",
"toggleHint": "Porównuje bieżący okres z tym samym okresem rok wcześniej.",
"baseline": {
"none": "Wyłączone",
"lastMonth": "Poprzedni miesiąc",
@@ -102,7 +102,6 @@ const LEGIT_IDENTICAL = new Set<string>([
"admin.overview.snapshotOfflineGeoOff",
"admin.overview.snapshotOfflineGeoOn",
"admin.webPushVapidTitle",
"dashboard.hero.scoreLabel",
"insights.healthScore.provenance.sources.appleHealth",
"measurements.sourceAppleHealth",
"onboarding.source.more.appleHealth",
@@ -32,6 +32,16 @@ describe("dashboard hero — page wiring", () => {
expect(stripIdx).toBeGreaterThan(heroIdx);
});
it("asks an empty filtered digest to render its all-clear state", () => {
expect(src).toContain("renderFilteredHeroAllClear");
expect(src).toContain(
"renderFilteredAllClear={renderFilteredHeroAllClear}",
);
expect(src).toContain(
"(layout.enabledHeroItemKinds ?? PRIORITY_ITEM_KINDS).length",
);
});
it("no longer references the retired legacy hero", () => {
expect(src).not.toContain("DashboardHero");
expect(src).not.toContain("DashboardHeroSkeleton");
@@ -11,6 +11,11 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const transactionUser = vi.hoisted(() => ({
findUnique: vi.fn(),
update: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: {
@@ -22,6 +27,7 @@ vi.mock("@/lib/db", () => ({
auditLog: {
create: vi.fn(),
},
$transaction: vi.fn(),
},
toJson: (v: unknown) => v,
}));
@@ -55,7 +61,7 @@ vi.mock("next/headers", () => ({
})),
}));
import { GET, PUT, __resetAuditDedupMemoForTests } from "../route";
import { DELETE, GET, PUT, __resetAuditDedupMemoForTests } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { annotate } from "@/lib/logging/context";
@@ -66,13 +72,10 @@ import {
DASHBOARD_WIDGET_CATALOGUE_IDS,
DEFAULT_DASHBOARD_LAYOUT,
serializeDashboardLayout,
// v1.32.1 — the exact payload builder `ringMutation.mutationFn` calls
// (see the settings component). Importing the shipped function rather
// than re-implementing its shape means this test exercises the REAL
// client contract against the REAL route, not a hand-rolled stand-in.
buildRingMutationPayload,
type DashboardLayout,
type DashboardLayoutWithToken,
} from "@/lib/dashboard-layout";
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -85,6 +88,7 @@ const SESSION_OK = {
};
const callPut = PUT as unknown as (req: NextRequest) => Promise<Response>;
const callDelete = DELETE as unknown as () => Promise<Response>;
function makeReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/dashboard/widgets", {
@@ -100,6 +104,9 @@ beforeEach(() => {
__resetAuditDedupMemoForTests();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(prisma.$transaction).mockImplementation((async (
callback: (tx: { user: typeof transactionUser }) => Promise<unknown>,
) => callback({ user: transactionUser })) as never);
});
describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)", () => {
@@ -145,6 +152,22 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
expect(paths).toEqual(["comparisonBaseline", "version", "widgets"]);
});
it("rejects layouts above the documented 50-widget runtime cap", async () => {
const widgets = Array.from({ length: 51 }, (_, order) => ({
id: "weight",
visible: true,
tileVisible: true,
order,
}));
const res = await callPut(makeReq({ version: 1, widgets }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string }> };
};
expect(body.details.issues.map((issue) => issue.path)).toContain("widgets");
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("writes one audit-ledger row keyed dashboard.widgets.validation-failed", async () => {
const res = await callPut(makeReq({ version: 2, widgets: [] }));
expect(res.status).toBe(422);
@@ -487,6 +510,23 @@ describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => {
expect(ids.sort()).toEqual([...DASHBOARD_WIDGET_CATALOGUE_IDS].sort());
});
it("GET preserves an explicit all-off hero-content choice", async () => {
const stored = serializeDashboardLayout({
...DEFAULT_DASHBOARD_LAYOUT,
enabledHeroItemKinds: [],
});
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: stored,
} as never);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { enabledHeroItemKinds: string[] };
};
expect(body.data.enabledHeroItemKinds).toEqual([]);
});
it("an id genuinely outside the 27-catalogue still drops on PUT", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: null,
@@ -645,86 +685,66 @@ describe("dashboard widgets — preserve-when-absent on PUT", () => {
});
});
/**
* v1.32.1 — regression for issue #581: dashboard layout changes silently
* overwritten by a concurrent score-ring save.
*
* The scenario from the report: the user edits tile/chart visibility
* (local draft B), then reorders/toggles a hero score ring before hitting
* Save. The ring PUT (`ringMutation`) fires immediately, built from
* whatever layout the client had cached (layout A — the state BEFORE the
* draft edit). The normal Save button then PUTs the full draft (layout B)
* and commits first. If the earlier-fired ring request resolves AFTER
* Save, its body — built from the stale A snapshot — used to carry
* `widgets: A` explicitly, and an explicitly-present field always wins on
* write. Save's B silently reverted to A even though the ring PUT reported
* 200 and only meant to touch the ring selection.
*
* The fix is `buildRingMutationPayload` (imported from the real component
* module, not re-implemented here): it never includes `widgets` at all.
* This test proves the SERVER half of the fix — a PUT shaped exactly like
* the shipped ring mutation's body preserves whatever layout is CURRENTLY
* stored, so the request that resolves last can no longer matter.
*/
describe("dashboard widgets — ring-only PUT cannot race a concurrent full-layout Save (regression #581)", () => {
it("preserves the widgets a concurrent Save already committed, even though the ring request started from a stale snapshot", async () => {
// The state AFTER the normal Save committed layout B — a tile turned
// off that was on in the stale snapshot A the ring mutation started
// from. Starts from the FULL default widget catalogue (rather than a
// single-widget array) so `resolveDashboardLayout`'s auto-upgrade
// append (missing catalogue ids get seeded invisible) is a no-op here
// and the persisted array can be compared for exact equality below.
const savedLayoutB: DashboardLayout = serializeDashboardLayout({
version: 1,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) =>
w.id === "weight" ? { ...w, visible: false, tileVisible: false } : w,
),
comparisonBaseline: "lastYear",
selectedScoreRings: ["MED_COMPLIANCE"],
heroRingOrder: ["HEALTH_SCORE", "MED_COMPLIANCE"],
describe("dashboard widgets — hero-content visibility persistence", () => {
it("preserves the stored choice when a stale client omits the field", async () => {
const stored = serializeDashboardLayout({
...DEFAULT_DASHBOARD_LAYOUT,
enabledHeroItemKinds: ["preventive_care", "milestone"],
});
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: savedLayoutB,
dashboardWidgetsJson: stored,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
// The ring mutation's real, shipped payload — built without any
// knowledge of layout B. If this were still `{...staleA, ...}` the
// stale `widgets`/`comparisonBaseline` would land here explicitly and
// overwrite B; `buildRingMutationPayload` never carries them.
const ringOnlyBody = buildRingMutationPayload({
selectedScoreRings: ["READINESS"],
heroRingOrder: ["HEALTH_SCORE", "READINESS"],
});
const res = await callPut(makeReq(ringOnlyBody));
const res = await callPut(
makeReq({
version: 1,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets,
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: DashboardLayout };
};
// B's widgets (and comparisonBaseline) survive — the ring PUT never
// touched them, so nothing it carries can outrace the Save that
// already committed.
expect(updateArg.data.dashboardWidgetsJson.widgets).toEqual(
savedLayoutB.widgets,
);
expect(updateArg.data.dashboardWidgetsJson.comparisonBaseline).toBe(
"lastYear",
);
// The ring selection itself DID apply.
expect(updateArg.data.dashboardWidgetsJson.selectedScoreRings).toEqual([
"READINESS",
expect(updateArg.data.dashboardWidgetsJson.enabledHeroItemKinds).toEqual([
"preventive_care",
"milestone",
]);
const body = (await res.json()) as { data: DashboardLayout };
expect(body.data.widgets[0].visible).toBe(false);
expect(body.data.widgets[0].tileVisible).toBe(false);
expect(body.data.selectedScoreRings).toEqual(["READINESS"]);
});
it("still accepts a normal full-layout Save (widgets present + replace semantics unchanged)", async () => {
it("round-trips an explicit all-off PUT without writing notification preferences", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: DEFAULT_DASHBOARD_LAYOUT,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets,
enabledHeroItemKinds: [],
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: Record<string, unknown> & {
dashboardWidgetsJson: DashboardLayout;
};
};
expect(updateArg.data.dashboardWidgetsJson.enabledHeroItemKinds).toEqual(
[],
);
expect(Object.keys(updateArg.data)).toEqual(["dashboardWidgetsJson"]);
const body = (await res.json()) as { data: DashboardLayout };
expect(body.data.enabledHeroItemKinds).toEqual([]);
});
it("omits an all-enabled choice from stored and returned serialization", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: null,
} as never);
@@ -733,14 +753,39 @@ describe("dashboard widgets — ring-only PUT cannot race a concurrent full-layo
const res = await callPut(
makeReq({
version: 1,
widgets: [
{ id: "weight", visible: false, tileVisible: false, order: 0 },
],
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets,
enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS].reverse(),
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: DashboardLayout };
};
expect(
Object.prototype.hasOwnProperty.call(
updateArg.data.dashboardWidgetsJson,
"enabledHeroItemKinds",
),
).toBe(false);
const body = (await res.json()) as { data: DashboardLayout };
expect(body.data.widgets[0].visible).toBe(false);
expect(
Object.prototype.hasOwnProperty.call(body.data, "enabledHeroItemKinds"),
).toBe(false);
});
it("rejects values outside the current priority-item catalogue", async () => {
const res = await callPut(
makeReq({
version: 1,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets,
enabledHeroItemKinds: ["future_item"],
}),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
@@ -763,6 +808,7 @@ describe("dashboard widgets — optimistic concurrency (issue #581)", () => {
chartOverlayPrefs: {},
selectedScoreRings: ["MED_COMPLIANCE"],
heroRingOrder: ["HEALTH_SCORE", "MED_COMPLIANCE"],
enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS],
...(baseUpdatedAt !== undefined ? { baseUpdatedAt } : {}),
};
}
@@ -841,3 +887,83 @@ describe("dashboard widgets — optimistic concurrency (issue #581)", () => {
expect(body.data.updatedAt).toBe("2026-07-24T11:00:00.000Z");
});
});
describe("DELETE /api/dashboard/widgets — web-owned reset scope", () => {
it("resets tiles and Today highlights while preserving stored advanced preferences", async () => {
const stored = serializeDashboardLayout({
...DEFAULT_DASHBOARD_LAYOUT,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets.map((widget, order) => ({
...widget,
visible: false,
tileVisible: false,
order: DEFAULT_DASHBOARD_LAYOUT.widgets.length - order,
})),
comparisonBaseline: "lastYear",
chartOverlayPrefs: {
weight: {
showTrendIndicator: true,
showTrendArrow: true,
showTargetRange: false,
comparisonBaseline: "lastMonth",
rangePoints: 90,
},
},
selectedScoreRings: ["READINESS", "SLEEP_SCORE"],
heroRingOrder: ["SLEEP_SCORE", "HEALTH_SCORE", "READINESS"],
enabledHeroItemKinds: ["milestone"],
});
const advanced = new Date("2026-07-24T12:00:00.000Z");
transactionUser.findUnique.mockResolvedValue({
dashboardWidgetsJson: stored,
});
transactionUser.update.mockResolvedValue({ updatedAt: advanced });
const res = await callDelete();
expect(res.status).toBe(200);
expect(prisma.$transaction).toHaveBeenCalledOnce();
expect(vi.mocked(prisma.$transaction).mock.calls[0]?.[1]).toEqual({
isolationLevel: "Serializable",
});
expect(prisma.user.findUnique).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
expect(transactionUser.findUnique).toHaveBeenCalledOnce();
expect(transactionUser.update).toHaveBeenCalledOnce();
const updateArg = transactionUser.update.mock.calls[0]?.[0] as {
data: { dashboardWidgetsJson: DashboardLayout };
select: { updatedAt: true };
};
expect(updateArg.select).toEqual({ updatedAt: true });
const persisted = updateArg.data.dashboardWidgetsJson;
expect(persisted.widgets).toEqual(DEFAULT_DASHBOARD_LAYOUT.widgets);
expect(
Object.prototype.hasOwnProperty.call(persisted, "enabledHeroItemKinds"),
).toBe(false);
expect(persisted.comparisonBaseline).toBe("lastYear");
expect(persisted.chartOverlayPrefs).toEqual({
weight: {
showTrendIndicator: true,
showTrendArrow: true,
showTargetRange: false,
comparisonBaseline: "lastMonth",
rangePoints: 90,
},
});
expect(persisted.selectedScoreRings).toEqual(["READINESS", "SLEEP_SCORE"]);
expect(persisted.heroRingOrder).toEqual([
"SLEEP_SCORE",
"HEALTH_SCORE",
"READINESS",
]);
const body = (await res.json()) as { data: DashboardLayoutWithToken };
expect(body.data.widgets).toEqual(DEFAULT_DASHBOARD_LAYOUT.widgets);
expect(body.data.enabledHeroItemKinds).toEqual([...PRIORITY_ITEM_KINDS]);
expect(body.data.comparisonBaseline).toBe("lastYear");
expect(body.data.chartOverlayPrefs).toEqual(persisted.chartOverlayPrefs);
expect(body.data.selectedScoreRings).toEqual(persisted.selectedScoreRings);
expect(body.data.heroRingOrder).toEqual(persisted.heroRingOrder);
expect(body.data.updatedAt).toBe(advanced.toISOString());
});
});
+55 -46
View File
@@ -2,8 +2,8 @@
* GET / PUT / DELETE dashboard widget layout.
*
* GET returns the resolved effective layout (defaults merged in if the user
* hasn't customized yet). PUT replaces the layout atomically. DELETE resets
* to default.
* hasn't customized yet). PUT replaces the supplied layout fields atomically.
* DELETE resets web-controlled choices and preserves the other preferences.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
@@ -36,7 +36,7 @@ import {
type DashboardLayout,
type DashboardLayoutWithToken,
} from "@/lib/dashboard-layout";
import { Prisma } from "@/generated/prisma/client";
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
import { z } from "zod/v4";
import { invalidateUserDashboardWidgets } from "@/lib/cache/invalidate";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
@@ -81,6 +81,7 @@ const layoutSchema = z.object({
}),
)
.min(1)
.max(50)
// v1.11.2 B5 — raised to 40 so the full catalogue PUT (24 writable +
// 11 iOS-only = 35) fits with headroom after the v1.10 additive
// HealthKit signals became pinnable; the enum still bounds each id to
@@ -90,14 +91,9 @@ const layoutSchema = z.object({
// catalogue (42 ids). The cap only has to clear the catalogue size —
// the enum bounds each entry and the id set is closed — so it carries
// headroom rather than tracking the count exactly.
.max(50)
// v1.32.1 — optional + preserve-when-absent (issue #581). The
// Settings page's instant score-ring toggle now PUTs ONLY the ring
// fields; omitting `widgets` lets the server carry forward whatever
// is CURRENTLY stored instead of the ring mutation resending a
// client-held snapshot that can go stale mid-flight and race a
// concurrent full-layout Save. A normal Save still always sends
// `widgets` — this only widens what a PARTIAL PUT can leave out.
// Optional + preserve-when-absent. Partial clients can update one layout
// field without resending a potentially stale widget catalogue; a normal
// dashboard-layout Save still sends `widgets`.
.optional(),
// v1.4.16 phase B8 — comparison baseline (Vormonat / Vorjahr) rides
// on the layout blob per research §7 Q3 (no Prisma migration). Optional
@@ -156,6 +152,12 @@ const layoutSchema = z.object({
.array(z.enum(HERO_RING_IDS))
.max(MAX_HERO_RING_ORDER)
.optional(),
// Today hero item visibility. Empty is an explicit all-off choice; omission
// preserves the stored value for clients that predate this field.
enabledHeroItemKinds: z
.array(z.enum(PRIORITY_ITEM_KINDS))
.max(PRIORITY_ITEM_KINDS.length)
.optional(),
// v1.32.16 (issue #581) — the optimistic-concurrency base token used to
// live here. v1.32.21 (R5a) moved it to the shared `takeBaseToken` helper,
// which strips it BEFORE this parse: the token is transport, not a layout
@@ -313,29 +315,16 @@ export const PUT = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
// Preserve-when-absent: every field the client didn't send is carried
// forward from the stored layout rather than clamped to its default by
// the serializer. The dashboard-layout PUT typically saves widget
// visibility / order, while chart prefs are PUT through their own route
// (`/api/dashboard/chart-overlay-prefs`), the hero rings come from
// Settings, and `comparisonBaseline` is web-only — an omission from any
// one surface must not reset a choice made on another. `widgets` itself
// joined this set in v1.32.1 (issue #581): the Settings page's instant
// score-ring PUT omits it entirely so it always carries forward
// whatever is CURRENTLY stored, rather than a client-held snapshot that
// could be stale relative to a concurrent full-layout Save.
// Preserve every omitted client-owned field from the stored layout instead
// of clamping it to a default. The disposition map is exhaustive over
// `DashboardLayout`, so adding a field requires an explicit merge decision.
// One stored-layout read covers every fallback. The read is skipped only
// when the client supplied every preserved field and every supplied chart
// preference has rangePoints.
//
// The preserve set is not a list maintained here: it is derived from
// `LAYOUT_FIELD_MERGE_DISPOSITION`, which the type system forces to cover
// every field of `DashboardLayout`. Adding a layout field without a
// disposition fails typecheck, so the next field cannot silently miss this
// merge the way `comparisonBaseline` did. One stored-layout read covers
// every fallback, and the read is skipped only when the client sent every
// preserved top-level field and every supplied chart pref has rangePoints.
// The Zod shape leaves the per-chart `comparisonBaseline` optional while
// `ChartOverlayPrefs` requires it; `coerceChartOverlayPrefsMap` inside the
// serializer fills the gap, so the assertion is safe here exactly as it was
// on the previous per-field cast.
// The Zod shape leaves per-chart `comparisonBaseline` optional while
// `ChartOverlayPrefs` requires it. `coerceChartOverlayPrefsMap` inside the
// serializer fills that gap, so the assertion below is safe.
// v1.32.16 (issue #581) — the concurrency token was already split off the
// body pre-Zod (`takeBaseToken`), so `parsed.data` is pure layout and can
// never leak the token into the stored blob.
@@ -387,14 +376,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
}
const normalized = serializeDashboardLayout(toSerialize as DashboardLayout);
// v1.32.16 (issue #581) / v1.32.21 (R5a) — optimistic concurrency through
// the shared `guardedUserUpdate` helper. When the client sends the
// `baseUpdatedAt` it read the layout at, the write is CONDITIONAL on the
// stored row still carrying that token, so a Save that already committed
// can never be silently reverted by a later request (e.g. an instant
// hero-ring PUT) that started from the pre-Save snapshot. A stale base
// returns 409 and writes NOTHING. Clients that omit the token (older web
// builds, the native client) keep the prior unconditional write.
// Optimistic concurrency through the shared `guardedUserUpdate` helper.
// When a client sends the `baseUpdatedAt` token it read with the layout, the
// write succeeds only while the stored row still carries that token. A stale
// base returns 409 without writing. Clients that omit the token keep the
// legacy unconditional write.
const guarded = await guardedUserUpdate({
userId: user.id,
base,
@@ -427,10 +413,30 @@ export const PUT = apiHandler(async (request: NextRequest) => {
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
await prisma.user.update({
where: { id: user.id },
data: { dashboardWidgetsJson: Prisma.JsonNull },
});
const { normalized, updatedAt } = await prisma.$transaction(
async (tx) => {
const existing = await tx.user.findUnique({
where: { id: user.id },
select: { dashboardWidgetsJson: true },
});
const existingLayout = resolveDashboardLayout(
existing?.dashboardWidgetsJson,
);
const normalized = serializeDashboardLayout({
...existingLayout,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets,
enabledHeroItemKinds: DEFAULT_DASHBOARD_LAYOUT.enabledHeroItemKinds,
});
const updated = await tx.user.update({
where: { id: user.id },
data: { dashboardWidgetsJson: toJson(normalized) },
select: { updatedAt: true },
});
return { normalized, updatedAt: updated.updatedAt.toISOString() };
},
{ isolationLevel: "Serializable" },
);
annotate({ action: { name: "dashboard.widgets.reset" } });
@@ -438,5 +444,8 @@ export const DELETE = apiHandler(async () => {
// next dashboard mount paints the reset (default) layout.
invalidateUserDashboardWidgets(user.id);
return apiSuccess(DEFAULT_DASHBOARD_LAYOUT);
return apiSuccess({
...resolveDashboardLayout(normalized),
updatedAt,
} satisfies DashboardLayoutWithToken);
});
+8 -1
View File
@@ -33,6 +33,7 @@ import {
resolveDashboardLayout,
type DashboardLayout,
} from "@/lib/dashboard-layout";
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics";
import dynamic from "next/dynamic";
import Link from "next/link";
@@ -486,6 +487,9 @@ export default function DashboardPageClient({
// Resolve full dashboard layout — controls visibility + order of every widget
const layout = resolveDashboardLayout(layoutData);
const renderFilteredHeroAllClear =
(layout.enabledHeroItemKinds ?? PRIORITY_ITEM_KINDS).length <
PRIORITY_ITEM_KINDS.length;
/**
* v1.4.16 phase B8 — comparison baseline (Vormonat / Vorjahr) read
* from the resolved layout so every chart + tile on the dashboard
@@ -926,7 +930,10 @@ export default function DashboardPageClient({
// post-mount. DO NOT add `["daily", …]` to that allowlist, or render
// the hero from any client-only source, without revisiting this gate.
(digestQuery.data ? (
<TodayHero digest={digestQuery.data} />
<TodayHero
digest={digestQuery.data}
renderFilteredAllClear={renderFilteredHeroAllClear}
/>
) : !mounted || digestQuery.isLoading ? (
<TodayHeroSkeleton />
) : digestQuery.isError ? (
@@ -143,6 +143,29 @@ describe("<TodayHero>", () => {
expect(html).toBe("");
});
it("shows all-clear when layout filtering removes every candidate", () => {
const html = render(
<TodayHero
digest={digest({
score: null,
topSignal: null,
briefingLead: null,
line: "Nothing needs your attention today — everything's tracking normally.",
worthALook: [],
reactionLine: null,
justIn: null,
})}
renderFilteredAllClear
/>,
);
expect(html).toContain('data-slot="today-hero"');
expect(html).toContain('data-slot="today-hero-all-clear"');
expect(html).not.toContain('data-slot="today-hero-rail"');
expect(html).not.toContain('data-slot="today-hero-signal"');
expect(html).not.toContain('data-slot="today-hero-just-in"');
});
// v1.29.1 — the v1.29.0 selected-score-ring cluster was removed from the web
// hero (Marc, live-use: uneven, wasted tile space). Only the main
// health-score ring paints now; the cluster's data-slots are gone.
+10 -1
View File
@@ -90,7 +90,13 @@ function formatJustInTime(iso: string): string | null {
}
}
export function TodayHero({ digest }: { digest: DailyDigest }) {
export function TodayHero({
digest,
renderFilteredAllClear = false,
}: {
digest: DailyDigest;
renderFilteredAllClear?: boolean;
}) {
const { t } = useTranslations();
// Gates ONLY the chip's time string — never the chip's presence. The hero
// itself is deliberately NOT mount-gated (v1.30.9: it is the LCP element and
@@ -134,7 +140,10 @@ export function TodayHero({ digest }: { digest: DailyDigest }) {
// below. A fresh marker counts as content even when line generation was
// unavailable: the deterministic digest line still gives the hero an honest
// lead, and hiding the marker would lose the arrival moment entirely.
// A filtered layout is the one explicit exception: its empty digest means
// "the hidden canonical candidates are quiet here", so retain all-clear.
if (
!renderFilteredAllClear &&
!hasScore &&
!hasItems &&
!digest.briefingLead &&
@@ -1,17 +1,15 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
DEFAULT_DASHBOARD_LAYOUT,
serializeDashboardLayout,
DASHBOARD_IOS_ONLY_WIDGET_IDS,
DASHBOARD_WIDGET_IDS,
IOS_PIN_ONLY_WIDGET_IDS,
buildRingMutationPayload,
widgetWritesBusy,
type DashboardLayout,
} from "@/lib/dashboard-layout";
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
import { NATIVE_ONLY_WIDGET_LABEL_KEYS } from "@/lib/dashboard/widget-modules";
// v1.11.2 HIGH-1 — the web Settings list renders one row per WRITABLE id
@@ -75,6 +73,7 @@ import { I18nProvider } from "@/lib/i18n/context";
import {
DashboardLayoutSection,
reorderWidgets,
setHeroItemKindEnabled,
} from "../dashboard-layout-section";
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
@@ -165,13 +164,8 @@ describe("<DashboardLayoutSection> — drag-and-drop reorder", () => {
it("keeps the arrow buttons present after the drag handle lands (a11y fallback)", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// Move up + Move down translation keys resolve to the English
// strings — counted once per row. Both the widget rows and the
// score-ring reorder rows (health-score anchor + selected rings)
// carry the same arrow pair, so the total is one per row of either
// kind.
const ringRows = (html.match(/data-slot="score-ring-row"/g) ?? []).length;
const expected = WEB_RENDERABLE_ROW_COUNT + ringRows;
// Move up + Move down translation keys resolve once per widget row.
const expected = WEB_RENDERABLE_ROW_COUNT;
const moveUpCount = (html.match(/aria-label="Move up"/g) ?? []).length;
const moveDownCount = (html.match(/aria-label="Move down"/g) ?? []).length;
expect(moveUpCount).toBe(expected);
@@ -495,237 +489,125 @@ describe("<DashboardLayoutSection> — disabled-module widget toggles", () => {
});
});
/**
* v1.27.7 — hero score-ring picker. Renders whenever the layout loads
* (it configures the server-computed score rings the dashboard snapshot
* carries); 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("<DashboardLayoutSection> — hero score-ring picker (v1.27.7)", () => {
it("renders for the default layout", () => {
describe("<DashboardLayoutSection> — hero content", () => {
it("keeps the removed score and comparison controls out of the UI", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(html).toContain('data-slot="score-rings-picker"');
for (const removedLabel of [
"Compare to",
"Last month",
"Last year",
"Readiness",
"Recovery score",
"Sleep score",
]) {
expect(html).not.toContain(removedLabel);
}
});
it("renders one switch per ring (fail-open module map)", () => {
queryState.layout = DEFAULT_DASHBOARD_LAYOUT;
it("renders one switch for every current priority-item kind", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(html).toContain('data-slot="score-rings-picker"');
// v1.27.27 — one switch per score ring (4) plus the always-present
// health-score anchor row = 5.
const switches = html.match(/data-slot="score-ring-switch"/g) ?? [];
expect(switches).toHaveLength(5);
// The default selection (MED_COMPLIANCE) reads checked.
const compliance = html.match(
/<button[^>]*data-slot="score-ring-switch"[^>]*data-ring="MED_COMPLIANCE"[^>]*>/,
);
expect(compliance).not.toBeNull();
expect(compliance![0]).toContain('data-state="checked"');
expect(html).toContain('data-slot="hero-content-settings"');
const switches = html.match(/data-slot="hero-item-switch"/g) ?? [];
expect(switches).toHaveLength(PRIORITY_ITEM_KINDS.length);
for (const kind of PRIORITY_ITEM_KINDS) {
const control = html.match(
new RegExp(
`<button[^>]*data-slot="hero-item-switch"[^>]*data-kind="${kind}"[^>]*>`,
),
);
expect(control, kind).not.toBeNull();
expect(control![0]).toContain('data-state="checked"');
}
});
it("hides rings whose owning module is disabled (anchor stays)", () => {
queryState.layout = DEFAULT_DASHBOARD_LAYOUT;
authState.modules = { recovery: false, sleep: false };
it("treats a missing API field as every current kind enabled", () => {
const layoutWithoutHeroKinds: DashboardLayout = {
...DEFAULT_DASHBOARD_LAYOUT,
};
delete layoutWithoutHeroKinds.enabledHeroItemKinds;
queryState.layout = layoutWithoutHeroKinds;
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const switches = html.match(/data-slot="score-ring-switch"/g) ?? [];
// READINESS + RECOVERY_SCORE (recovery) and SLEEP_SCORE (sleep) are
// gone; the adherence ring plus the health-score anchor remain = 2.
expect(switches).toHaveLength(2);
expect(html).toContain('data-ring="MED_COMPLIANCE"');
expect(html).toContain('data-ring="HEALTH_SCORE"');
const switches =
html.match(/<button[^>]*data-slot="hero-item-switch"[^>]*>/g) ?? [];
expect(switches).toHaveLength(PRIORITY_ITEM_KINDS.length);
for (const control of switches) {
expect(control).toContain('data-state="checked"');
}
});
it("hides the derived rings when the insights module is off (anchor stays)", () => {
queryState.layout = DEFAULT_DASHBOARD_LAYOUT;
authState.modules = { insights: false };
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const switches = html.match(/data-slot="score-ring-switch"/g) ?? [];
// Adherence ring + health-score anchor.
expect(switches).toHaveLength(2);
expect(html).toContain('data-ring="MED_COMPLIANCE"');
});
it("disables the remaining switches at the three-ring cap", () => {
it("permits an explicit all-off layout", () => {
queryState.layout = {
...DEFAULT_DASHBOARD_LAYOUT,
selectedScoreRings: ["READINESS", "RECOVERY_SCORE", "SLEEP_SCORE"],
enabledHeroItemKinds: [],
};
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const compliance = html.match(
/<button[^>]*data-slot="score-ring-switch"[^>]*data-ring="MED_COMPLIANCE"[^>]*>/,
const switches = html.match(
/<button[^>]*data-slot="hero-item-switch"[^>]*>/g,
);
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(
/<button[^>]*data-slot="score-ring-switch"[^>]*data-ring="READINESS"[^>]*>/,
);
expect(readiness).not.toBeNull();
expect(readiness![0]).not.toContain('disabled=""');
});
});
/**
* v1.27.27 — the health-score ring is a first-class, always-present,
* reorderable member of the hero ring row. The picker renders it as a
* pinned row (no on/off toggle — a disabled, checked switch) that leads by
* default and reorders with the same drag-handle + arrow-button pair as the
* score rings and the widget list.
*/
describe("<DashboardLayoutSection> — health-score anchor + reorder (v1.27.27)", () => {
it("renders the health-score anchor as a reorderable row, leading by default", () => {
queryState.layout = DEFAULT_DASHBOARD_LAYOUT;
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// A sortable ring row exists for the health-score anchor.
expect(html).toContain('data-slot="score-ring-row"');
const anchorRow = html.match(
/<div[^>]*data-slot="score-ring-row"[^>]*data-ring="HEALTH_SCORE"[^>]*>/,
);
expect(anchorRow).not.toBeNull();
// Default order leads with the anchor: its row precedes the adherence
// ring's row.
const anchorIdx = html.indexOf('data-ring="HEALTH_SCORE"');
const medIdx = html.indexOf(
'data-slot="score-ring-row" data-ring="MED_COMPLIANCE"',
);
expect(anchorIdx).toBeGreaterThan(-1);
// The anchor row appears before any selected score-ring row.
expect(anchorIdx).toBeLessThan(html.indexOf('data-ring="MED_COMPLIANCE"'));
expect(medIdx === -1 || anchorIdx < medIdx).toBe(true);
expect(switches).toHaveLength(PRIORITY_ITEM_KINDS.length);
for (const control of switches ?? []) {
expect(control).toContain('data-state="unchecked"');
expect(control).not.toContain('disabled=""');
}
expect(html).toContain("Custom layout active");
});
it("the anchor switch is checked and disabled (locked-on, no toggle-off)", () => {
queryState.layout = DEFAULT_DASHBOARD_LAYOUT;
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const anchorSwitch = html.match(
/<button[^>]*data-slot="score-ring-switch"[^>]*data-ring="HEALTH_SCORE"[^>]*>/,
it("switches off the last enabled kind and persists an explicit empty array", () => {
const lastEnabled = ["coach_checkin"] as const;
const enabledHeroItemKinds = setHeroItemKindEnabled(
lastEnabled,
"coach_checkin",
false,
);
expect(anchorSwitch).not.toBeNull();
expect(anchorSwitch![0]).toContain('data-state="checked"');
expect(anchorSwitch![0]).toContain('disabled=""');
});
expect(enabledHeroItemKinds).toEqual([]);
expect(lastEnabled).toEqual(["coach_checkin"]);
it("the anchor carries a drag handle + move arrows (reorderable)", () => {
queryState.layout = DEFAULT_DASHBOARD_LAYOUT;
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// The health-score row is a full sortable row: its drag handle exists.
const handles = html.match(/data-slot="score-ring-drag-handle"/g) ?? [];
// One handle per hero-order row = anchor + default MED_COMPLIANCE = 2.
expect(handles.length).toBeGreaterThanOrEqual(2);
});
it("honours a persisted order that moves the anchor off the lead", () => {
queryState.layout = {
const serialized = serializeDashboardLayout({
...DEFAULT_DASHBOARD_LAYOUT,
selectedScoreRings: ["READINESS", "MED_COMPLIANCE"],
heroRingOrder: ["READINESS", "HEALTH_SCORE", "MED_COMPLIANCE"],
enabledHeroItemKinds,
});
expect(serialized.enabledHeroItemKinds).toEqual([]);
});
it("labels the switch collection and associates both explanatory texts", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const fieldset = html.match(
/<fieldset[^>]*data-slot="hero-content-settings"[^>]*>/,
);
expect(fieldset).not.toBeNull();
expect(fieldset![0]).toContain("rounded-lg");
const describedBy = fieldset![0].match(/aria-describedby="([^"]+)"/);
expect(describedBy).not.toBeNull();
const descriptionIds = describedBy![1].split(" ");
expect(descriptionIds).toHaveLength(2);
for (const descriptionId of descriptionIds) {
expect(html).toContain(`id="${descriptionId}"`);
}
expect(html).toContain("<legend");
expect(html).toContain("Today highlights");
});
it("keeps every kind visible when unrelated modules are disabled", () => {
authState.modules = {
coach: false,
medications: false,
insights: false,
};
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const readinessRow = html.indexOf(
'data-slot="score-ring-row" data-ring="READINESS"',
);
const anchorRow = html.indexOf(
'data-slot="score-ring-row" data-ring="HEALTH_SCORE"',
);
const medRow = html.indexOf(
'data-slot="score-ring-row" data-ring="MED_COMPLIANCE"',
);
expect(readinessRow).toBeGreaterThan(-1);
expect(anchorRow).toBeGreaterThan(readinessRow);
expect(medRow).toBeGreaterThan(anchorRow);
const switches = html.match(/data-slot="hero-item-switch"/g) ?? [];
expect(switches).toHaveLength(PRIORITY_ITEM_KINDS.length);
});
it("the ring mutation PUTs both selectedScoreRings and heroRingOrder (source pin)", () => {
const src = readFileSync(
join(
process.cwd(),
"src/components/settings/dashboard-layout-section.tsx",
),
"utf8",
);
expect(src).toContain("selectedScoreRings: next.selectedScoreRings");
expect(src).toContain("heroRingOrder: next.heroRingOrder");
// The order is the single source of truth: selection is derived from it.
expect(src).toMatch(/id !== HEALTH_SCORE_RING_ID/);
it("renders localized Today-highlight labels", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />, "de");
expect(html).toContain("Highlights für heute");
expect(html).toContain("Medikamentendosen");
expect(html).toContain("Neue EKG-Aufzeichnungen");
});
it("the ring mutation builds its body via buildRingMutationPayload, not a `...remote` spread (regression #581 source pin)", () => {
const src = readFileSync(
join(
process.cwd(),
"src/components/settings/dashboard-layout-section.tsx",
),
"utf8",
);
// v1.32.16 — the ring body is built via `buildRingMutationPayload`,
// now also threading the optimistic-concurrency base token.
expect(src).toContain("buildRingMutationPayload({ ...next, baseUpdatedAt");
// The pre-fix shape must never come back: spreading a query-cache
// snapshot into the ring PUT body is exactly the stale-`widgets`
// race issue #581 reported.
expect(src).not.toMatch(/\.\.\.remote,/);
});
});
/**
* v1.32.16 (issue #581) — a committed Save must stay authoritative against a
* concurrent instant hero-ring write. The full DOM interaction (fire a ring
* write, then a Save, assert the disabled/pending gate) can't run under this
* suite's SSR-only convention, so the load-bearing client contract is pinned
* as pure exported units plus source pins for the wiring the JSX depends on.
*/
describe("dashboard layout — Save cannot be clobbered by a ring write (#581)", () => {
it("buildRingMutationPayload never carries widgets and threads the base token", () => {
const payload = buildRingMutationPayload({
selectedScoreRings: ["READINESS"],
heroRingOrder: ["HEALTH_SCORE", "READINESS"],
baseUpdatedAt: "2026-07-24T10:00:00.000Z",
});
// None of the fields a stale snapshot could carry and use to overwrite
// a fresher Save.
expect(payload).not.toHaveProperty("widgets");
expect(payload).not.toHaveProperty("comparisonBaseline");
expect(payload).not.toHaveProperty("chartOverlayPrefs");
// The ring selection + the guard token DO ride.
expect(payload.selectedScoreRings).toEqual(["READINESS"]);
expect(payload.baseUpdatedAt).toBe("2026-07-24T10:00:00.000Z");
});
it("buildRingMutationPayload omits the base-token key when none is known", () => {
const payload = buildRingMutationPayload({
selectedScoreRings: [],
heroRingOrder: ["HEALTH_SCORE"],
});
expect(payload).not.toHaveProperty("baseUpdatedAt");
});
it("widgetWritesBusy gates off EITHER write being in flight", () => {
expect(widgetWritesBusy(false, false)).toBe(false);
expect(widgetWritesBusy(true, false)).toBe(true); // Save pending
expect(widgetWritesBusy(false, true)).toBe(true); // ring pending
expect(widgetWritesBusy(true, true)).toBe(true);
});
it("the Save + ring controls bind the shared busy gate and serialise via one scope (source pin)", () => {
const src = readFileSync(
join(
process.cwd(),
"src/components/settings/dashboard-layout-section.tsx",
),
"utf8",
);
// Save button + ring controls both read `widgetWritesBusy(...)` so the
// gate is one source of truth — the button can never disable on
// `saveMutation.isPending` alone (the pre-fix hole).
expect(src).toContain("widgetWritesBusy(");
// Both writes share a mutation scope so they run serially at the client.
expect(src).toContain("scope: { id: DASHBOARD_WIDGETS_MUTATION_SCOPE }");
// Each write sends the optimistic-concurrency base token.
expect(src).toContain("baseUpdatedAt: readBaseToken()");
// A 409 is handled (refetch + gentle nudge), not swallowed as a hard error.
expect(src).toContain("err.status === 409");
it("states that notification settings are managed separately", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(html).toContain("Notification settings are managed separately.");
});
});
@@ -131,18 +131,17 @@ describe("dashboard layout save — every layout source is covered (source pins)
"utf8",
);
it("all three snapshot invalidations (save / reset / rings) refetch inactive cells", () => {
const calls =
sectionSrc.match(
/invalidateQueries\(\{\s*queryKey: queryKeys\.dashboardSnapshot\(\),\s*refetchType: "all",\s*\}\)/g,
) ?? [];
expect(calls).toHaveLength(3);
// No leftover default-refetchType snapshot invalidation.
const anySnapshotInvalidation =
sectionSrc.match(
/invalidateQueries\(\{\s*queryKey: queryKeys\.dashboardSnapshot\(\)/g,
) ?? [];
expect(anySnapshotInvalidation).toHaveLength(3);
it("save and reset refetch inactive snapshot and daily-digest cells", () => {
for (const key of ["dashboardSnapshot", "dailyDigest"]) {
const calls =
sectionSrc.match(
new RegExp(
`invalidateQueries\\(\\{\\s*queryKey: queryKeys\\.${key}\\(\\),\\s*refetchType: "all",\\s*\\}\\)`,
"g",
),
) ?? [];
expect(calls).toHaveLength(2);
}
});
it("the legacy dashboardWidgets cell is written directly with the server response", () => {
@@ -30,37 +30,24 @@ import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
type DashboardLayout,
type DashboardWidgetId,
type ComparisonBaseline,
type ScoreRingId,
type HeroRingId,
COMPARISON_BASELINES,
DEFAULT_DASHBOARD_LAYOUT,
DASHBOARD_WIDGET_IDS,
DASHBOARD_IOS_ONLY_WIDGET_IDS,
IOS_PIN_ONLY_WIDGET_IDS,
type DashboardWidgetCatalogueId,
SCORE_RING_IDS,
SCORE_RING_MODULE,
MAX_SELECTED_SCORE_RINGS,
HEALTH_SCORE_RING_ID,
resolveHeroRingOrder,
buildRingMutationPayload,
widgetWritesBusy,
type DashboardLayoutWithToken,
} from "@/lib/dashboard-layout";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
PRIORITY_ITEM_KINDS,
type PriorityItemKind,
} from "@/lib/daily/priority-item";
import { apiDelete, apiGet, apiPut, ApiError } from "@/lib/api/api-fetch";
import { useAuth } from "@/hooks/use-auth";
import {
@@ -98,6 +85,17 @@ export function reorderWidgets(
return next.map((w, i) => ({ ...w, order: i }));
}
export function setHeroItemKindEnabled(
enabledKinds: readonly PriorityItemKind[] | undefined,
kind: PriorityItemKind,
enabled: boolean,
): PriorityItemKind[] {
const next = new Set(enabledKinds ?? PRIORITY_ITEM_KINDS);
if (enabled) next.add(kind);
else next.delete(kind);
return PRIORITY_ITEM_KINDS.filter((itemKind) => next.has(itemKind));
}
/**
* v1.4.48 M6a — merge the `{ id, order }` shape returned by
* `reorderWidgets()` back into the full `DashboardWidgetConfig[]` so
@@ -135,14 +133,7 @@ function mergeReorderIntoLayout(
});
}
/**
* v1.32.16 (issue #581) — the normal layout Save and the instant hero-ring
* PUT both write the whole `/api/dashboard/widgets` layout. Sharing one
* TanStack mutation `scope` serialises them: a second write in this scope
* waits for the first to settle, so the two PUTs can never overlap at the
* server and a committed Save cannot be reverted by a ring write that
* started from the pre-Save snapshot.
*/
/** Serialise layout saves and resets against the same endpoint. */
const DASHBOARD_WIDGETS_MUTATION_SCOPE = "dashboard-widgets" as const;
const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
@@ -185,27 +176,15 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
waterIntake: "nutrients.names.water",
};
/**
* v1.27.7 — option labels for the hero score-ring picker. The three
* derived rings reuse their wellness-strip titles; the dose ring names
* today's tally (the exact thing the ring shows since it moved off the
* 7-day adherence percentage).
*/
const SCORE_RING_LABEL_KEYS: Record<ScoreRingId, string> = {
READINESS: "insights.derived.composite.READINESS.title",
RECOVERY_SCORE: "insights.derived.scores.recovery",
SLEEP_SCORE: "insights.derived.composite.SLEEP_SCORE.title",
MED_COMPLIANCE: "dashboard.hero.ringDoses",
};
/**
* v1.27.27 — label key for any hero ring id, including the always-present
* health-score anchor ring. Reuses the hero's own "Health Score" label so
* the picker row and the hero ring name match verbatim.
*/
const HERO_RING_LABEL_KEYS: Record<HeroRingId, string> = {
[HEALTH_SCORE_RING_ID]: "dashboard.hero.scoreLabel",
...SCORE_RING_LABEL_KEYS,
const HERO_ITEM_LABEL_KEYS: Record<PriorityItemKind, string> = {
coach_checkin: "dashboard.heroItems.coach_checkin",
dose_window: "dashboard.heroItems.dose_window",
preventive_care: "dashboard.heroItems.preventive_care",
sync_issue: "dashboard.heroItems.sync_issue",
milestone: "dashboard.heroItems.milestone",
ecg_new_recording: "dashboard.heroItems.ecg_new_recording",
tension_window: "dashboard.heroItems.tension_window",
same_time_baseline: "dashboard.heroItems.same_time_baseline",
};
export function DashboardLayoutSection({ id }: { id: string }) {
@@ -223,6 +202,8 @@ export function DashboardLayoutSection({ id }: { id: string }) {
// tooltip. One hint paragraph is rendered once at the bottom of the list
// and referenced by every drag handle in this section.
const dragHintId = useId();
const heroDescriptionId = useId();
const heroNotificationNoteId = useId();
// v1.4.47 W4 — sensors: pointer for mouse/touch, keyboard for Tab + Space
// + arrow-key reordering. The KeyboardSensor still works for users who
@@ -280,8 +261,6 @@ export function DashboardLayoutSection({ id }: { id: string }) {
const layout = draft ?? remote ?? null;
const saveMutation = useMutation({
// v1.32.16 (issue #581) — shared scope serialises this against the
// instant ring PUT so they can never overlap at the server.
scope: { id: DASHBOARD_WIDGETS_MUTATION_SCOPE },
mutationFn: async (next: DashboardLayout) => {
return apiPut<DashboardLayoutWithToken>("/api/dashboard/widgets", {
@@ -312,15 +291,17 @@ export function DashboardLayoutSection({ id }: { id: string }) {
queryKey: queryKeys.dashboardSnapshot(),
refetchType: "all",
});
queryClient.invalidateQueries({
queryKey: queryKeys.dailyDigest(),
refetchType: "all",
});
setDraft(null);
toast.success(t("dashboard.layoutSaveSuccess"));
},
onError: (err) => {
// v1.32.16 (issue #581) — a 409 means someone else (another tab, or
// an interleaved ring write) committed since this edit was based.
// Refetch the winning layout so the base token advances, KEEP the
// draft so the user can re-apply, and nudge gently instead of a hard
// error — nothing was lost, the change just needs re-saving.
// A 409 means another client committed since this edit was based.
// Refetch the winning layout so the base token advances, keep the draft,
// and let the user save the change again.
if (err instanceof ApiError && err.status === 409) {
queryClient.invalidateQueries({
queryKey: queryKeys.dashboardWidgets(),
@@ -333,7 +314,6 @@ export function DashboardLayoutSection({ id }: { id: string }) {
});
const resetMutation = useMutation({
// v1.32.16 — same scope so a reset can't race the layout / ring writes.
scope: { id: DASHBOARD_WIDGETS_MUTATION_SCOPE },
mutationFn: async () => {
return apiDelete<DashboardLayoutWithToken>("/api/dashboard/widgets");
@@ -348,6 +328,10 @@ export function DashboardLayoutSection({ id }: { id: string }) {
queryKey: queryKeys.dashboardSnapshot(),
refetchType: "all",
});
queryClient.invalidateQueries({
queryKey: queryKeys.dailyDigest(),
refetchType: "all",
});
setDraft(null);
toast.success(t("dashboard.layoutResetSuccess"));
},
@@ -383,179 +367,16 @@ export function DashboardLayoutSection({ id }: { id: string }) {
});
}
/**
* v1.4.16 phase B8 — comparison baseline picker. The toggle persists
* via the same `/api/dashboard/widgets` PUT the existing layout
* controls already use; saving rides through the same `Save` button.
*/
function setComparisonBaseline(value: ComparisonBaseline) {
function toggleHeroItem(kind: PriorityItemKind, enabled: boolean) {
if (!layout) return;
setDraft({ ...layout, comparisonBaseline: value });
}
/**
* v1.27.8 — the ring choices apply INSTANTLY, outside the draft/Save
* machine the rest of the section keeps. The first cut routed ring
* toggles through the draft, and the combination of an unflushed
* draft, the server snapshot cache, and client staleness read as
* "flipping does nothing, rings appear later". The mutation
* optimistically updates the widgets query and invalidates the
* dashboard snapshot on settle so the hero reflects the choice on the
* next visit.
*
* v1.32.1 — the PUT body used to be the FULL server layout copy,
* spreading this component's `remote` query-cache snapshot in ahead of
* the two ring fields. That snapshot can be stale by the time the
* request actually lands: if the normal
* tile/chart Save button committed a newer layout first, this
* request's explicit — present, not omitted — stale `widgets` (and
* `comparisonBaseline` / `chartOverlayPrefs`) still won on write and
* silently reverted the just-saved layout (issue #581). The payload
* now carries ONLY the ring fields; the server's preserve-when-absent
* merge (`LAYOUT_FIELD_MERGE_DISPOSITION` — `widgets` joined it in the
* same release) carries every omitted field forward from whatever is
* CURRENTLY stored, so this request can no longer race a concurrent
* Save no matter which one lands last.
*/
const ringMutation = useMutation({
// v1.32.16 (issue #581) — shared scope serialises this against the full
// layout Save so the two writes can never overlap at the server.
scope: { id: DASHBOARD_WIDGETS_MUTATION_SCOPE },
mutationFn: async (next: {
selectedScoreRings: ScoreRingId[];
heroRingOrder: HeroRingId[];
}) => {
return apiPut<DashboardLayoutWithToken>(
"/api/dashboard/widgets",
// Carries ONLY the ring fields (never `widgets`) plus the base
// token, so it can neither clobber a saved layout nor win a stale
// race — a conflict 409s instead.
buildRingMutationPayload({ ...next, baseUpdatedAt: readBaseToken() }),
);
},
onMutate: async (next) => {
await queryClient.cancelQueries({
queryKey: queryKeys.dashboardWidgets(),
});
const previous = queryClient.getQueryData<DashboardLayout>(
queryKeys.dashboardWidgets(),
);
if (previous) {
queryClient.setQueryData(queryKeys.dashboardWidgets(), {
...previous,
selectedScoreRings: next.selectedScoreRings,
heroRingOrder: next.heroRingOrder,
});
}
return { previous };
},
onError: (err, _next, context) => {
if (context?.previous) {
queryClient.setQueryData(
queryKeys.dashboardWidgets(),
context.previous,
);
}
// v1.32.16 (issue #581) — a 409 means a concurrent write landed
// first. Roll back the optimistic ring change (above), refetch the
// winning layout so the token advances, drop this now-stale delta,
// and nudge gently so the user can re-toggle against fresh state.
if (err instanceof ApiError && err.status === 409) {
queryClient.invalidateQueries({
queryKey: queryKeys.dashboardWidgets(),
});
toast.message(t("dashboard.layoutConflictReloaded"));
return;
}
toast.error(t("dashboard.layoutSaveError"));
},
onSuccess: (saved) => {
queryClient.setQueryData(queryKeys.dashboardWidgets(), saved);
// `refetchType: "all"` for the same reason as the save path: the
// unmounted snapshot cell must refetch NOW, not on its next poll.
queryClient.invalidateQueries({
queryKey: queryKeys.dashboardSnapshot(),
refetchType: "all",
});
},
});
/**
* v1.27.27 — apply a new hero ring ORDER instantly (see `ringMutation`).
* The order is the single source of truth: `selectedScoreRings` is
* derived from it (order minus the always-present health-score ring) so
* the server resolver and the hero read one consistent sequence.
*/
function applyHeroRingOrder(next: HeroRingId[]) {
// v1.32.16 (issue #581) — gate on the freshest layout (`draft ?? remote`)
// the rest of the section uses, not the `remote` server snapshot alone,
// so an open draft's edits stay the source of truth.
if (!layout) return;
const selectedScoreRings = next.filter(
(id): id is ScoreRingId => id !== HEALTH_SCORE_RING_ID,
);
// Keep an open draft's ring slice in sync so a later Save of the
// OTHER layout edits can't revert the instantly-applied choice.
if (draft) setDraft({ ...draft, selectedScoreRings, heroRingOrder: next });
ringMutation.mutate({ selectedScoreRings, heroRingOrder: next });
}
/** The reconciled hero ring order (health-score anchor + selected rings). */
function currentHeroOrder(l: DashboardLayout): HeroRingId[] {
return resolveHeroRingOrder(l.heroRingOrder, l.selectedScoreRings ?? []);
}
/**
* v1.27.7 — toggle one hero score ring on/off. Enabling appends to the
* order (capped at `MAX_SELECTED_SCORE_RINGS`; the remaining switches
* disable at the cap); disabling drops it from the order. The
* health-score ring has no toggle — it is the always-present anchor.
* v1.27.8 / v1.27.27 — applies instantly via `applyHeroRingOrder`.
*/
function toggleScoreRing(ringId: ScoreRingId, enabled: boolean) {
if (!layout) return;
const order = currentHeroOrder(layout);
if (enabled) {
if (order.includes(ringId)) return;
const selectedCount = order.filter(
(id) => id !== HEALTH_SCORE_RING_ID,
).length;
if (selectedCount >= MAX_SELECTED_SCORE_RINGS) return;
applyHeroRingOrder([...order, ringId]);
} else {
applyHeroRingOrder(order.filter((id) => id !== ringId));
}
}
/**
* v1.27.8 / v1.27.27 — reorder the hero rings (the array order IS the
* render order on the hero; the health-score anchor moves like any other
* ring). Same interaction pair as the widget rows below: drag handle +
* arrow buttons.
*/
function moveHeroRing(ringId: HeroRingId, delta: -1 | 1) {
if (!layout) return;
const order = currentHeroOrder(layout);
const idx = order.indexOf(ringId);
const target = idx + delta;
if (idx < 0 || target < 0 || target >= order.length) return;
const next = [...order];
[next[idx], next[target]] = [next[target], next[idx]];
applyHeroRingOrder(next);
}
function handleRingDragEnd(event: DragEndEvent) {
if (!layout) return;
const { active, over } = event;
if (!over || active.id === over.id) return;
const order = currentHeroOrder(layout);
const from = order.indexOf(active.id as HeroRingId);
const to = order.indexOf(over.id as HeroRingId);
if (from < 0 || to < 0) return;
const next = [...order];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
applyHeroRingOrder(next);
setDraft({
...layout,
enabledHeroItemKinds: setHeroItemKindEnabled(
layout.enabledHeroItemKinds,
kind,
enabled,
),
});
}
/**
@@ -631,170 +452,49 @@ export function DashboardLayoutSection({ id }: { id: string }) {
/>
}
/>
{/*
v1.4.22 D / F-32 — the surrounding `<DashboardSection>` page
already renders a `settings.sections.dashboard.description`
paragraph beneath the H1. The previous in-card help line
repeated the same idea ("Choose which cards appear …
Defaults work out of the box.") right next to the comparison
picker, giving the page three muted-foreground help blocks
stacked on top of each other. Removing this duplicate keeps
the comparison picker as the only thing between the header
and the widget table.
*/}
{/* v1.4.16 phase B8 — comparison baseline picker. Lives at the top
of the section because it changes how every chart + tile below
renders, not just one. v1.4.19 A6: height standardised to the
shared 36-px input contract used by every other Settings input
/ select; the previous `min-h-11` (44 px) made this trigger
look like an outlier next to the 36-px Username/Email/Date-of-
birth fields one card up. */}
{layout && (
<div className="space-y-2">
<label
htmlFor="comparison-baseline"
className="text-foreground text-sm font-medium"
>
{t("comparison.toggleLabel")}
</label>
<Select
value={layout.comparisonBaseline ?? "none"}
onValueChange={(value) =>
setComparisonBaseline(value as ComparisonBaseline)
}
disabled={saveMutation.isPending}
>
<SelectTrigger
id="comparison-baseline"
className="w-full sm:w-72"
data-slot="comparison-baseline-trigger"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{COMPARISON_BASELINES.map((value) => (
<SelectItem
key={value}
value={value}
data-slot={`comparison-baseline-option-${value}`}
>
{t(`comparison.baseline.${value}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
{t("comparison.toggleHint")}
</p>
</div>
)}
{/* v1.27.7 — hero score-ring picker. Configures the score rings the
server computes onto the dashboard snapshot (`selectedScoreRings`
/ `heroRingOrder`), consumed by the native client. 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.
v1.27.8 — choices apply instantly (no Save round) and the
selected rings reorder with the same drag-handle + arrow pair
as the widget rows below; the array order is the ring order. */}
{layout && (
<div
data-slot="score-rings-picker"
className="border-border bg-background/30 space-y-3 rounded-md border px-3 py-2"
<fieldset
aria-describedby={`${heroDescriptionId} ${heroNotificationNoteId}`}
data-slot="hero-content-settings"
className="border-border bg-background/30 space-y-3 rounded-lg border px-3 py-3"
>
<div className="min-w-0">
<p className="text-foreground text-sm font-medium">
{t("dashboard.scoreRingsTitle")}
</p>
<p className="text-muted-foreground text-xs">
{t("dashboard.scoreRingsDescription")}
</p>
</div>
{(() => {
const available = 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;
});
const selected = (layout.selectedScoreRings ?? []).filter(
(ringId) => available.includes(ringId),
);
// v1.27.27 — the reorderable list is the health-score anchor
// ring plus the selected score rings, in the persisted order
// (health-score first by default). The health-score row has no
// on/off switch — it is always present — but drags/moves like
// any other ring.
const heroOrder = resolveHeroRingOrder(
layout.heroRingOrder,
selected,
);
const unselected = available.filter(
(ringId) => !heroOrder.includes(ringId),
);
const atCap = selected.length >= MAX_SELECTED_SCORE_RINGS;
// v1.32.16 (issue #581) — ring controls also lock while a Save
// is in flight, so the two writes can never overlap.
const busy = widgetWritesBusy(
saveMutation.isPending,
ringMutation.isPending,
);
return (
<div className="space-y-2">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleRingDragEnd}
<legend className="text-foreground text-sm font-medium">
{t("dashboard.heroItemsTitle")}
</legend>
<p id={heroDescriptionId} className="text-muted-foreground text-xs">
{t("dashboard.heroItemsDescription")}
</p>
<div className="divide-border divide-y">
{PRIORITY_ITEM_KINDS.map((kind) => {
const inputId = `hero-item-${kind}`;
const checked = (
layout.enabledHeroItemKinds ?? PRIORITY_ITEM_KINDS
).includes(kind);
return (
<div
key={kind}
className="flex min-h-11 items-center justify-between gap-3 py-2"
>
<SortableContext
items={heroOrder}
strategy={verticalListSortingStrategy}
>
{heroOrder.map((ringId, index) => (
<SortableRingRow
key={ringId}
ringId={ringId}
label={t(HERO_RING_LABEL_KEYS[ringId])}
index={index}
total={heroOrder.length}
dragHintId={dragHintId}
disabled={busy}
// The health-score ring is the anchor — no toggle.
pinned={ringId === HEALTH_SCORE_RING_ID}
moveUpLabel={t("dashboard.moveUp")}
moveDownLabel={t("dashboard.moveDown")}
dragHandleLabel={t("dashboard.dragHandle")}
onToggle={toggleScoreRing}
onMove={moveHeroRing}
/>
))}
</SortableContext>
</DndContext>
{unselected.map((ringId) => (
<div
key={ringId}
className="flex min-h-9 items-center justify-between gap-3 px-1"
>
<span className="text-foreground truncate text-sm">
{t(SCORE_RING_LABEL_KEYS[ringId])}
</span>
<Switch
checked={false}
onCheckedChange={(v) => toggleScoreRing(ringId, v)}
disabled={busy || atCap}
aria-label={t(SCORE_RING_LABEL_KEYS[ringId])}
data-slot="score-ring-switch"
data-ring={ringId}
/>
</div>
))}
</div>
);
})()}
</div>
<Label htmlFor={inputId} className="text-sm font-medium">
{t(HERO_ITEM_LABEL_KEYS[kind])}
</Label>
<Switch
id={inputId}
checked={checked}
onCheckedChange={(value) => toggleHeroItem(kind, value)}
disabled={saveMutation.isPending}
aria-label={t(HERO_ITEM_LABEL_KEYS[kind])}
data-slot="hero-item-switch"
data-kind={kind}
/>
</div>
);
})}
</div>
<p id={heroNotificationNoteId} className="text-foreground text-sm">
{t("dashboard.heroItemsNotificationNote")}
</p>
</fieldset>
)}
{isLoading || !layout ? (
@@ -997,10 +697,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setDraft(null)}
disabled={widgetWritesBusy(
saveMutation.isPending,
ringMutation.isPending,
)}
disabled={saveMutation.isPending}
>
{t("common.cancel")}
</Button>
@@ -1008,12 +705,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => layout && saveMutation.mutate(layout)}
// v1.32.16 (issue #581) — disabled while EITHER write is in
// flight so a Save can't be issued mid ring-write and vice versa.
disabled={widgetWritesBusy(
saveMutation.isPending,
ringMutation.isPending,
)}
disabled={saveMutation.isPending}
>
{saveMutation.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
@@ -1027,7 +719,9 @@ export function DashboardLayoutSection({ id }: { id: string }) {
<p className="text-muted-foreground text-xs">
{layout &&
JSON.stringify(layout.widgets) ===
JSON.stringify(DEFAULT_DASHBOARD_LAYOUT.widgets)
JSON.stringify(DEFAULT_DASHBOARD_LAYOUT.widgets) &&
JSON.stringify(layout.enabledHeroItemKinds ?? PRIORITY_ITEM_KINDS) ===
JSON.stringify(DEFAULT_DASHBOARD_LAYOUT.enabledHeroItemKinds)
? t("dashboard.layoutUsingDefaults")
: t("dashboard.layoutCustomized")}
</p>
@@ -1251,121 +945,3 @@ function StaticWidgetRow({
</div>
);
}
/**
* v1.27.8 — sortable row for one SELECTED hero score ring. Mirrors the
* `SortableWidgetRow` interaction exactly (drag handle + arrow buttons +
* switch) so the two lists in this section share one sorting language;
* only the payload differs — the ring order is the `selectedScoreRings`
* array itself, applied instantly through the ring mutation.
*/
function SortableRingRow({
ringId,
label,
index,
total,
dragHintId,
disabled,
pinned = false,
moveUpLabel,
moveDownLabel,
dragHandleLabel,
onToggle,
onMove,
}: {
ringId: HeroRingId;
label: string;
index: number;
total: number;
dragHintId: string;
disabled: boolean;
/**
* v1.27.27 — the health-score anchor ring: always present, no on/off
* switch (a disabled, checked switch communicates the locked-on state),
* but reorderable like any other ring.
*/
pinned?: boolean;
moveUpLabel: string;
moveDownLabel: string;
dragHandleLabel: string;
onToggle: (id: ScoreRingId, value: boolean) => void;
onMove: (id: HeroRingId, delta: -1 | 1) => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: ringId });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: prefersReducedMotion() ? "none" : transition,
};
return (
<div
ref={setNodeRef}
style={style}
data-slot="score-ring-row"
data-ring={ringId}
data-dragging={isDragging ? "true" : undefined}
className={`border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2 ${
isDragging ? "ring-primary z-10 opacity-90 shadow-lg ring-2" : ""
}`}
>
<button
type="button"
{...attributes}
{...listeners}
aria-label={`${dragHandleLabel} — ${label}`}
aria-describedby={dragHintId}
title={dragHandleLabel}
disabled={disabled}
data-slot="score-ring-drag-handle"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background relative -m-1 inline-flex h-7 w-7 cursor-grab touch-none items-center justify-center rounded transition-colors before:absolute before:inset-[-8px] before:content-[''] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none"
>
<GripVertical className="h-4 w-4" />
</button>
<span className="flex-1 truncate text-sm" title={label}>
{label}
</span>
<Switch
checked
onCheckedChange={
pinned ? undefined : (v) => onToggle(ringId as ScoreRingId, v)
}
// The health-score anchor cannot be switched off — a disabled,
// checked switch reads as "always on, locked".
disabled={disabled || pinned}
aria-label={label}
data-slot="score-ring-switch"
data-ring={ringId}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onMove(ringId, -1)}
disabled={index === 0 || disabled}
aria-label={moveUpLabel}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onMove(ringId, 1)}
disabled={index === total - 1 || disabled}
aria-label={moveDownLabel}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
);
}
+76 -53
View File
@@ -13,9 +13,9 @@ import {
PRESERVED_LAYOUT_FIELDS,
layoutNeedsPreserveRead,
mergePreservedLayoutFields,
buildRingMutationPayload,
type DashboardLayout,
} from "@/lib/dashboard-layout";
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
/**
* v1.4.15 Fix 5 — Dashboard layout: top tiles selectable.
@@ -837,6 +837,76 @@ describe("resolveDashboardLayout() — selectedScoreRings", () => {
]);
});
});
describe("resolveDashboardLayout() — enabledHeroItemKinds", () => {
const legacy = {
version: 1,
widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
};
it("enables every current kind for legacy layouts", () => {
expect(resolveDashboardLayout(legacy).enabledHeroItemKinds).toEqual([
...PRIORITY_ITEM_KINDS,
]);
});
it("preserves an explicit all-off choice through resolve and serialize", () => {
const resolved = resolveDashboardLayout({
...legacy,
enabledHeroItemKinds: [],
});
expect(resolved.enabledHeroItemKinds).toEqual([]);
const serialized = serializeDashboardLayout(resolved);
expect(serialized.enabledHeroItemKinds).toEqual([]);
expect(resolveDashboardLayout(serialized).enabledHeroItemKinds).toEqual([]);
});
it("drops unknown values, deduplicates, and restores catalogue order", () => {
const resolved = resolveDashboardLayout({
...legacy,
enabledHeroItemKinds: [
"sync_issue",
"future_item",
"sync_issue",
"dose_window",
],
});
expect(resolved.enabledHeroItemKinds).toEqual([
"dose_window",
"sync_issue",
]);
});
it("omits the all-enabled default from storage", () => {
const serialized = serializeDashboardLayout({
...DEFAULT_DASHBOARD_LAYOUT,
enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS].reverse(),
});
expect(
Object.prototype.hasOwnProperty.call(serialized, "enabledHeroItemKinds"),
).toBe(false);
expect(resolveDashboardLayout(serialized).enabledHeroItemKinds).toEqual([
...PRIORITY_ITEM_KINDS,
]);
});
it("keeps a non-full subset in canonical catalogue order", () => {
const serialized = serializeDashboardLayout({
...DEFAULT_DASHBOARD_LAYOUT,
enabledHeroItemKinds: ["sync_issue", "dose_window"],
});
expect(serialized.enabledHeroItemKinds).toEqual([
"dose_window",
"sync_issue",
]);
});
it("the default layout enables every current kind", () => {
expect(DEFAULT_DASHBOARD_LAYOUT.enabledHeroItemKinds).toEqual([
...PRIORITY_ITEM_KINDS,
]);
});
});
/**
* v1.27.27 — hero ring ORDER (`heroRingOrder`). The health-score ring is a
@@ -989,6 +1059,7 @@ describe("layout field merge disposition", () => {
},
selectedScoreRings: ["MED_COMPLIANCE"],
heroRingOrder: ["HEALTH_SCORE", "MED_COMPLIANCE"],
enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS],
};
it("assigns a disposition to every top-level layout field", () => {
@@ -1007,13 +1078,11 @@ describe("layout field merge disposition", () => {
}
});
it("preserves comparisonBaseline and widgets alongside the other client-owned fields", () => {
// The regression itself: `comparisonBaseline` must be in the preserve
// set, not merely present in the disposition map. `widgets` joined it in
// v1.32.1 (issue #581) — see the dedicated describe block below.
it("preserves every client-owned field", () => {
expect([...PRESERVED_LAYOUT_FIELDS].sort()).toEqual([
"chartOverlayPrefs",
"comparisonBaseline",
"enabledHeroItemKinds",
"heroRingOrder",
"selectedScoreRings",
"widgets",
@@ -1071,11 +1140,13 @@ describe("layout field merge disposition", () => {
chartOverlayPrefs: fullyPopulatedLayout.chartOverlayPrefs,
selectedScoreRings: [],
heroRingOrder: fullyPopulatedLayout.heroRingOrder,
enabledHeroItemKinds: [],
};
const merged = mergePreservedLayoutFields(incoming, stored);
// An explicit "none" is a real choice — clearing must survive the merge.
expect(merged.comparisonBaseline).toBe("none");
expect(merged.selectedScoreRings).toEqual([]);
expect(merged.enabledHeroItemKinds).toEqual([]);
});
it("leaves the replace-fields untouched by the merge", () => {
@@ -1091,51 +1162,3 @@ describe("layout field merge disposition", () => {
expect(merged.widgets).toEqual(fullyPopulatedLayout.widgets);
});
});
/**
* v1.32.1 — regression for issue #581 (dashboard layout race). The
* Settings page's instant score-ring PUT used to resend the FULL layout,
* built from the `remote` query-cache snapshot
* (`{...remote, selectedScoreRings, heroRingOrder}`). That snapshot can be
* stale relative to an in-flight or already-committed tile/chart Save;
* because `widgets` was a `"replace"`-disposition field on the server, an
* explicitly-present stale copy always won on write and silently reverted
* the just-saved layout.
*
* `buildRingMutationPayload` is the exact function
* `ringMutation.mutationFn` (in `dashboard-layout-section.tsx`) calls to
* build its request body. Pinning its shape here — no `widgets`, no
* `comparisonBaseline`, no `chartOverlayPrefs` — is what makes the race
* structurally impossible: a payload that never carries those fields
* cannot overwrite them no matter which request lands last. See
* `src/app/api/dashboard/widgets/__tests__/route.test.ts` for the
* server-side half — a PUT shaped like this preserves whatever layout is
* CURRENTLY stored, not a client-held snapshot.
*/
describe("buildRingMutationPayload — instant score-ring PUT payload (regression #581)", () => {
it("carries only version + the two ring fields", () => {
const payload = buildRingMutationPayload({
selectedScoreRings: ["READINESS"],
heroRingOrder: ["HEALTH_SCORE", "READINESS"],
});
expect(Object.keys(payload).sort()).toEqual([
"heroRingOrder",
"selectedScoreRings",
"version",
]);
expect(payload.version).toBe(1);
expect(payload.selectedScoreRings).toEqual(["READINESS"]);
expect(payload.heroRingOrder).toEqual(["HEALTH_SCORE", "READINESS"]);
});
it("never includes widgets, comparisonBaseline, or chartOverlayPrefs — the fields a concurrent Save owns", () => {
const payload = buildRingMutationPayload({
selectedScoreRings: ["READINESS"],
heroRingOrder: ["HEALTH_SCORE", "READINESS"],
});
expect(payload.widgets).toBeUndefined();
expect(payload.comparisonBaseline).toBeUndefined();
expect(payload.chartOverlayPrefs).toBeUndefined();
expect("widgets" in payload).toBe(false);
});
});
+2 -3
View File
@@ -40,9 +40,8 @@ export function readUpdatedAtToken(
}
/**
* Attach the base token to a write payload — but only when it is known, so a
* tokenless client transparently takes the server's unconditional write
* (mirrors `buildRingMutationPayload`'s conditional spread).
* Attach the base token to a write payload only when it is known. A tokenless
* client transparently takes the server's unconditional write.
*/
export function withBaseToken<T extends object>(
payload: T,
@@ -8,6 +8,8 @@
import { describe, it, expect, vi } from "vitest";
import type { PrismaClient } from "@/generated/prisma/client";
vi.mock("@/lib/daily/load-digest", () => ({ loadDailyDigest: vi.fn() }));
import {
maybeDispatchDailyBriefing,
buildDailyBriefingPush,
@@ -17,6 +19,8 @@ import {
import type { DailyDigest } from "@/lib/daily/digest";
import { isUrgentPayload } from "@/lib/notifications/types";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { loadDailyDigest } from "@/lib/daily/load-digest";
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
// 06:00Z → 08:00 in Europe/Berlin (summer) → inside the morning window AND the
// fixed fallback hour. The tz-window cases move only this instant.
@@ -155,6 +159,51 @@ describe("maybeDispatchDailyBriefing", () => {
expect(payload.metadata?.url).toBe("/");
});
it("uses canonical candidates when dashboard hero visibility hides the only item", async () => {
vi.mocked(loadDailyDigest).mockReset();
vi.mocked(loadDailyDigest).mockImplementation(
async (_user, _now, options) =>
makeDigest({
score: null,
briefingLead: null,
topSignal: null,
worthALook: options?.enabledItemKinds?.includes("dose_window")
? [
{
kind: "dose_window",
title: "Medication due",
body: "Morning dose is overdue.",
status: "warning",
actions: [
{
labelKey: "daily.action.logDose",
intent: "dose.log",
},
],
},
]
: [],
}),
);
const deps = makeDeps();
delete deps.loadDigest;
const result = await maybeDispatchDailyBriefing(
makePrisma({}),
"u1",
IN_WINDOW,
deps,
);
expect(result).toBe("sent");
expect(loadDailyDigest).toHaveBeenCalledWith(
expect.objectContaining({ id: "u1" }),
IN_WINDOW,
{ enabledItemKinds: PRIORITY_ITEM_KINDS },
);
expect(deps.dispatch).toHaveBeenCalledOnce();
});
it("never routes as an alert: the payload is non-urgent", async () => {
const prisma = makePrisma({});
const deps = makeDeps();
@@ -17,6 +17,7 @@ import {
type DailyDigestArrival,
type DailyDigestInput,
} from "@/lib/daily/digest";
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
const t = getServerTranslator("en").t;
const NOW = new Date("2026-07-16T09:00:00.000Z");
@@ -38,6 +39,7 @@ function input(over: Partial<DailyDigestInput> = {}): DailyDigestInput {
return {
now: NOW,
modules: {},
enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS],
score: { value: 82, band: "good", delta: 3 },
briefing: null,
medsToday: meds(),
+39 -1
View File
@@ -18,7 +18,10 @@ import {
COACH_CHECKIN_REVIEW_DAYS,
} from "@/lib/daily/coach-checkin-intents";
import type { Milestone } from "@/lib/daily/milestones";
import type { PriorityItem } from "@/lib/daily/priority-item";
import {
PRIORITY_ITEM_KINDS,
type PriorityItem,
} from "@/lib/daily/priority-item";
import {
ecgItemKey,
milestoneItemKey,
@@ -62,6 +65,7 @@ function input(over: Partial<DailyDigestInput> = {}): DailyDigestInput {
return {
now: NOW,
modules: {},
enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS],
score: { value: 82, band: "good", delta: 3 },
briefing,
medsToday: meds(),
@@ -521,6 +525,40 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => {
expect(d.worthALook.length).toBe(3);
});
it("filters disabled kinds before the rail cap", () => {
const d = buildDailyDigest(
input({
enabledHeroItemKinds: ["preventive_care"],
medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }),
syncIssues: [
{ integration: "withings", state: "error_reauth" },
{ integration: "nightscout", state: "parked" },
{ integration: "fitbit", state: "error_reauth" },
],
preventiveDue: [{ label: "Blood panel" }],
}),
t,
);
expect(d.worthALook.map((item) => item.kind)).toEqual(["preventive_care"]);
});
it("uses the existing all-clear treatment when every kind is off", () => {
const d = buildDailyDigest(
input({
enabledHeroItemKinds: [],
briefing: null,
score: null,
medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }),
syncIssues: [{ integration: "withings", state: "error_reauth" }],
}),
t,
);
expect(d.worthALook).toEqual([]);
expect(d.line).toContain("Nothing needs your attention today");
});
it("returns an empty rail when nothing needs attention", () => {
const d = buildDailyDigest(input(), t);
expect(d.worthALook).toEqual([]);
@@ -55,9 +55,11 @@ import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
const SNAPSHOT = {
body: {
layout: { enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS] },
tiles: { lastSeenByType: {} },
medsToday: {
activeCount: 0,
@@ -150,4 +152,41 @@ describe("loadDailyDigest — S11/S12 extras cache", () => {
expect(probeRollupCoverage).not.toHaveBeenCalled();
expect(loadIntradayPulse).not.toHaveBeenCalled();
});
it("threads the resolved hero-item visibility into composition", async () => {
vi.mocked(readDashboardSnapshotCached).mockResolvedValueOnce({
...SNAPSHOT,
body: {
...SNAPSHOT.body,
layout: { enabledHeroItemKinds: [] },
medsToday: {
...SNAPSHOT.body.medsToday,
nextDueOverdue: true,
nextDueMedicationName: "Morning dose",
},
},
} as never);
const digest = await loadDailyDigest(USER, NOW);
expect(digest.worthALook).toEqual([]);
});
it("lets non-hero consumers consider a candidate hidden by the dashboard", async () => {
vi.mocked(readDashboardSnapshotCached).mockResolvedValueOnce({
...SNAPSHOT,
body: {
...SNAPSHOT.body,
layout: { enabledHeroItemKinds: [] },
medsToday: {
...SNAPSHOT.body.medsToday,
nextDueOverdue: true,
nextDueMedicationName: "Morning dose",
},
},
} as never);
const digest = await loadDailyDigest(USER, NOW, {
enabledItemKinds: PRIORITY_ITEM_KINDS,
});
expect(digest.worthALook.map((item) => item.kind)).toContain("dose_window");
});
});
+8 -1
View File
@@ -37,6 +37,7 @@ import { userDayKey } from "@/lib/tz/format";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import { loadDailyDigest } from "@/lib/daily/load-digest";
import type { DailyDigest } from "@/lib/daily/digest";
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
/**
* The local-morning window this nudge may fire in, [earliest, latest). Both the
@@ -82,6 +83,12 @@ export interface DailyBriefingDispatchDeps {
isModuleEnabled?: typeof isModuleEnabled;
}
function loadDailyBriefingDigest(user: User, now: Date): Promise<DailyDigest> {
return loadDailyDigest(user, now, {
enabledItemKinds: PRIORITY_ITEM_KINDS,
});
}
function resolveLocale(locale: string | null | undefined): Locale {
return locales.includes(locale as Locale)
? (locale as Locale)
@@ -142,7 +149,7 @@ export async function maybeDispatchDailyBriefing(
deps: DailyBriefingDispatchDeps = {},
): Promise<DailyBriefingDispatchResult> {
const dispatch = deps.dispatch ?? dispatchNotification;
const loadDigest = deps.loadDigest ?? loadDailyDigest;
const loadDigest = deps.loadDigest ?? loadDailyBriefingDigest;
const moduleGate = deps.isModuleEnabled ?? isModuleEnabled;
try {
+15 -7
View File
@@ -27,6 +27,7 @@ import type { ServerTranslator } from "@/lib/i18n/server-translator";
import {
MAX_PRIORITY_ACTIONS,
type PriorityItem,
type PriorityItemKind,
} from "@/lib/daily/priority-item";
import {
ecgItemKey,
@@ -216,6 +217,8 @@ export const JUST_IN_WINDOW_MS = 3 * 60 * 60 * 1000;
export interface DailyDigestInput {
now: Date;
modules: DigestModuleMap;
/** Item kinds allowed to appear in the Today hero rail. */
enabledHeroItemKinds: readonly PriorityItemKind[];
score: DailyDigestScore | null;
/** The cached daily briefing (paragraph + signals-of-day), or null. */
briefing: DailyBriefing | null;
@@ -876,19 +879,24 @@ export function buildDailyDigest(
);
if (sameTime) worthALook.push(sameTime);
// Apply the account's hero-content visibility before the rail cap so a
// hidden high-priority item cannot crowd out an enabled later candidate.
const enabled = worthALook.filter((item) =>
input.enabledHeroItemKinds.includes(item.kind),
);
// Defence-in-depth: no card ever exceeds the P1 action cap.
for (const item of worthALook) {
for (const item of enabled) {
item.actions = item.actions.slice(0, MAX_PRIORITY_ACTIONS);
}
// Drop anything the user already dismissed (observational kinds only — an
// actionable item never carries an `itemKey`, so it can never match here).
// Filtered BEFORE the bounded slice, so a dismissal reliably makes room for
// the next candidate rather than leaving a gap.
// Drop anything the user already dismissed (observational kinds only).
// This also runs before the bounded slice so a dismissal makes room for the
// next enabled candidate.
const visible =
input.dismissedItemKeys.size === 0
? worthALook
: worthALook.filter(
? enabled
: enabled.filter(
(item) => !item.itemKey || !input.dismissedItemKeys.has(item.itemKey),
);
+18
View File
@@ -55,6 +55,10 @@ import {
} from "@/lib/daily/milestones";
import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
import { computeSameTimeBaseline } from "@/lib/insights/derived/same-time-baseline";
import {
PRIORITY_ITEM_KINDS,
type PriorityItemKind,
} from "@/lib/daily/priority-item";
/** Integration states that mean "your action is needed to keep data flowing". */
const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const;
@@ -325,9 +329,19 @@ function toDigestArrival(row: {
};
}
export interface DailyDigestLoadOptions {
/**
* Overrides the dashboard's hero visibility for non-hero consumers.
* Passing `PRIORITY_ITEM_KINDS` keeps notification eligibility independent
* from which cards the user chose to display in the Today hero.
*/
enabledItemKinds?: readonly PriorityItemKind[];
}
export async function loadDailyDigest(
user: User,
now: Date = new Date(),
options: DailyDigestLoadOptions = {},
): Promise<DailyDigest> {
// Computed before the fan-out because the arrival read is keyed on it. Pure
// (a tz format of `now`), so hoisting it costs nothing.
@@ -527,6 +541,10 @@ export async function loadDailyDigest(
{
now,
modules,
enabledHeroItemKinds:
options.enabledItemKinds ??
snapshot.layout.enabledHeroItemKinds ??
PRIORITY_ITEM_KINDS,
score,
briefing: snapshot.briefing,
medsToday: snapshot.medsToday,
+11 -6
View File
@@ -21,12 +21,10 @@
import type { ModuleKey } from "@/lib/modules/registry";
/**
* Closed set of rail-item kinds. It GROWS BY PR — a new rail item type is a new
* entry here + a new server builder, nothing else. S1 ships builders for
* `dose_window`, `preventive_care`, and `sync_issue`; the remaining kinds are
* reserved for the slices that own them (coach check-in S3, milestone S12,
* new-ECG S10, tension window S11) so their consumers can already type against
* the union.
* Closed set of rail-item kinds. A new kind also needs its server builder and
* dashboard-layout label. Typed consumers derive from this tuple so stored
* visibility settings and API validation cannot accept vocabulary the current
* build does not understand.
*/
export const PRIORITY_ITEM_KINDS = [
"coach_checkin",
@@ -41,6 +39,13 @@ export const PRIORITY_ITEM_KINDS = [
export type PriorityItemKind = (typeof PRIORITY_ITEM_KINDS)[number];
export function isPriorityItemKind(value: unknown): value is PriorityItemKind {
return (
typeof value === "string" &&
(PRIORITY_ITEM_KINDS as readonly string[]).includes(value)
);
}
/**
* The OBSERVATIONAL kinds — a durable-state reward, a new-ECG pointer, an
* elevated-at-rest window, a same-time activity read. These carry no pending
+46 -99
View File
@@ -5,6 +5,11 @@
* what the default layout is for new users. Null / missing = default.
*/
import type { ModuleKey } from "@/lib/modules/registry";
import {
PRIORITY_ITEM_KINDS,
isPriorityItemKind,
type PriorityItemKind,
} from "@/lib/daily/priority-item";
/**
* Every widget the dashboard layout knows about. The order of
@@ -546,6 +551,20 @@ function coerceSelectedScoreRings(value: unknown): ScoreRingId[] {
return out;
}
/**
* Resolve the Today hero's item-kind visibility against the current closed
* catalogue. Legacy or malformed values enable every current kind. Arrays are
* explicit choices, including an empty array, and resolve in catalogue order.
*/
function coerceEnabledHeroItemKinds(value: unknown): PriorityItemKind[] {
if (!Array.isArray(value)) return [...PRIORITY_ITEM_KINDS];
const enabled = new Set<PriorityItemKind>();
for (const entry of value) {
if (isPriorityItemKind(entry)) enabled.add(entry);
}
return PRIORITY_ITEM_KINDS.filter((kind) => enabled.has(kind));
}
export interface DashboardLayout {
version: number;
widgets: DashboardWidgetConfig[];
@@ -567,6 +586,11 @@ export interface DashboardLayout {
* selected set on every read.
*/
heroRingOrder?: HeroRingId[];
/**
* Item kinds allowed in the Today hero rail. Missing legacy data enables
* every current kind; an explicit empty array keeps the rail off.
*/
enabledHeroItemKinds?: PriorityItemKind[];
}
/**
@@ -581,27 +605,11 @@ export interface DashboardLayout {
* "clear it". The route reads the stored layout and carries the existing
* value forward.
*
* The preserve set exists because clients legitimately don't know every
* field. The per-chart overlay popover PUTs through
* `/api/dashboard/chart-overlay-prefs`; the hero rings are a Settings-only
* control; `comparisonBaseline` is web-only and the native client documents
* that it never sends it. Without preserve-when-absent, a tile reorder on one
* surface silently resets a choice made on another — which is exactly what
* happened to `comparisonBaseline` before it joined this map: the serializer
* clamps a missing value to `"none"`, so every native layout save wiped the
* baseline the user had picked on the web.
*
* `widgets` moved from `"replace"` to `"preserve"` in v1.32.1 (issue #581).
* The Settings page's instant score-ring toggle used to resend the FULL
* layout — including `widgets` — built from the query cache's `remote`
* snapshot. That snapshot can be stale by the time the ring request lands:
* if a normal tile/chart Save committed a newer layout first, the
* ring request's explicit (present, not omitted) stale `widgets` value
* still won on write and silently reverted the just-saved layout. Making
* `widgets` optional + preserve-when-absent lets the ring mutation omit it
* entirely, so the field it never touches can no longer race a concurrent
* Save — the route always carries forward whatever is CURRENTLY stored,
* not a client-held copy.
* The preserve set exists because clients legitimately do not know every
* field. Chart overlays also write through their own endpoint. An omitted
* field must therefore keep the stored choice instead of falling through to
* a serializer default. `widgets` is preserved too so a partial client can
* update one preference without resending a potentially stale widget list.
*
* `satisfies Record<keyof DashboardLayout, …>` is the load-bearing part.
* Adding a field to `DashboardLayout` without adding it here is a TYPE error,
@@ -615,6 +623,7 @@ export const LAYOUT_FIELD_MERGE_DISPOSITION = {
chartOverlayPrefs: "preserve",
selectedScoreRings: "preserve",
heroRingOrder: "preserve",
enabledHeroItemKinds: "preserve",
} as const satisfies Record<keyof DashboardLayout, "replace" | "preserve">;
/**
@@ -660,85 +669,13 @@ export function mergePreservedLayoutFields(
}
/**
* v1.32.1 — pure payload builder for the Settings page's instant score-ring
* PUT (`ringMutation` in `dashboard-layout-section.tsx`). Lives in this
* client-safe module (imported by both the client component and the server
* route already) rather than the "use client" component file, so a
* server-side regression test can import it without pulling the component's
* UI dependency graph across the client/server boundary — see
* `src/app/api/dashboard/widgets/__tests__/route.test.ts`.
*
* Deliberately returns ONLY the ring fields (plus `version`) — no
* `widgets`, `comparisonBaseline`, or `chartOverlayPrefs`. Before v1.32.1 the
* ring mutation resent the FULL layout built from the client's `remote`
* query-cache snapshot, which can be stale by the time the request lands: if
* a normal tile/chart Save committed a newer layout first, the ring
* request's explicit — present, not omitted — stale `widgets` still won on
* write and silently reverted the just-saved layout (issue #581). Those
* three fields are all `"preserve"`-disposition here
* (`LAYOUT_FIELD_MERGE_DISPOSITION`): omitting them means the route always
* carries forward whatever is CURRENTLY stored, so this payload can no
* longer race a concurrent Save no matter which request lands last.
*/
/**
* v1.32.16 (issue #581) — a `/api/dashboard/widgets` write payload plus the
* optimistic-concurrency base token. `baseUpdatedAt` is the ISO
* `User.updatedAt` the client read the layout at; the route writes only when
* the stored row still carries it (else 409). It is request-only and never
* lands in the persisted `dashboardWidgetsJson` blob.
*/
export type DashboardLayoutWritePayload = Partial<DashboardLayout> & {
baseUpdatedAt?: string;
};
/**
* v1.32.16 — the shape every GET / PUT `/api/dashboard/widgets` response
* carries: the resolved layout plus the server-stamped `updatedAt`
* concurrency token (mirrors `User.updatedAt`, never persisted inside the
* blob). The client sends it back as `baseUpdatedAt` on the next write.
* The shape every GET / PUT `/api/dashboard/widgets` response returns.
* `updatedAt` is the optional optimistic-concurrency token.
*/
export type DashboardLayoutWithToken = DashboardLayout & {
updatedAt?: string;
};
export function buildRingMutationPayload(next: {
selectedScoreRings: ScoreRingId[];
heroRingOrder: HeroRingId[];
/**
* v1.32.16 (issue #581) — the base token the ring change was made
* against. Included when known so the guarded write can 409 rather than
* silently overwrite a Save that committed first. Omitted (not sent as
* `undefined`) when the client hasn't got a token yet.
*/
baseUpdatedAt?: string;
}): DashboardLayoutWritePayload {
return {
version: DASHBOARD_LAYOUT_VERSION,
selectedScoreRings: next.selectedScoreRings,
heroRingOrder: next.heroRingOrder,
...(next.baseUpdatedAt !== undefined
? { baseUpdatedAt: next.baseUpdatedAt }
: {}),
};
}
/**
* v1.32.16 (issue #581) — the Save button and the instant hero-ring controls
* both PUT the whole `/api/dashboard/widgets` layout. While EITHER write is in
* flight the other must be disabled, so the two PUTs can never overlap at the
* server and a committed Save cannot be reverted by a ring write that started
* from the pre-Save snapshot. Belt-and-braces with the shared TanStack
* mutation `scope` (which already serialises the two) and the server's
* optimistic-concurrency token. Exported as a pure predicate so the JSX binds
* one source of truth and a unit test can pin the gate.
*/
export function widgetWritesBusy(
saving: boolean,
ringPending: boolean,
): boolean {
return saving || ringPending;
}
const DASHBOARD_LAYOUT_VERSION = 1;
/**
@@ -748,12 +685,11 @@ const DASHBOARD_LAYOUT_VERSION = 1;
*/
export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
version: DASHBOARD_LAYOUT_VERSION,
// One medication-adherence ring next to the health score by default —
// the successor of the hero dose row's information role.
// The stored score-ring contracts remain available to snapshot and native
// consumers even though the web layout settings do not edit them.
selectedScoreRings: [...DEFAULT_SELECTED_SCORE_RINGS],
// v1.27.27 — the health-score ring leads the row by default; the user
// can reorder (see `resolveHeroRingOrder`).
heroRingOrder: [...DEFAULT_HERO_RING_ORDER],
enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS],
widgets: [
{ id: "weight", visible: true, tileVisible: true, order: 0 },
{ id: "bp", visible: true, tileVisible: true, order: 1 },
@@ -921,6 +857,9 @@ export function resolveDashboardLayout(raw: unknown): DashboardLayout {
candidate.heroRingOrder,
resolvedSelectedScoreRings,
),
enabledHeroItemKinds: coerceEnabledHeroItemKinds(
candidate.enabledHeroItemKinds,
),
};
}
@@ -930,6 +869,9 @@ export function serializeDashboardLayout(
const resolvedSelectedScoreRings = coerceSelectedScoreRings(
layout.selectedScoreRings,
);
const enabledHeroItemKinds = coerceEnabledHeroItemKinds(
layout.enabledHeroItemKinds,
);
return {
version: DASHBOARD_LAYOUT_VERSION,
widgets: layout.widgets
@@ -961,5 +903,10 @@ export function serializeDashboardLayout(
layout.heroRingOrder,
resolvedSelectedScoreRings,
),
// Every kind enabled is the forward-compatible default, so omit it from
// storage. Empty and partial arrays remain explicit user choices.
...(enabledHeroItemKinds.length === PRIORITY_ITEM_KINDS.length
? {}
: { enabledHeroItemKinds }),
};
}
@@ -18,6 +18,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { buildDailyDigest, type DailyDigestInput } from "@/lib/daily/digest";
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
const findUnique = vi.fn();
const update = vi.fn();
@@ -121,6 +122,7 @@ function degradedDigest() {
const input: DailyDigestInput = {
now: NOW,
modules: {},
enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS],
score: { value: 82, band: "good", delta: 3 },
briefing: null,
medsToday: {
+14 -7
View File
@@ -23,6 +23,7 @@ import {
SCORE_RING_IDS,
HERO_RING_IDS,
} from "@/lib/dashboard-layout";
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
import {
dataEnvelope,
stdResponses,
@@ -47,10 +48,8 @@ const dashboardChartOverlayPrefs = z.object({
.optional(),
});
// The layout shape shared by GET/PUT/DELETE. `widgets` (and the other
// preserve-when-absent fields) are optional on the wire: the Settings
// instant score-ring PUT omits `widgets` entirely so the server carries
// forward whatever is currently stored (issue #581).
// The layout shape shared by GET/PUT/DELETE. Every field except `version` is
// optional so a partial client can preserve fields it does not own.
const dashboardLayoutSchema = z
.object({
version: z.literal(1),
@@ -61,11 +60,18 @@ const dashboardLayoutSchema = z
.optional(),
selectedScoreRings: z.array(z.enum(SCORE_RING_IDS)).max(3).optional(),
heroRingOrder: z.array(z.enum(HERO_RING_IDS)).max(4).optional(),
enabledHeroItemKinds: z
.array(z.enum(PRIORITY_ITEM_KINDS))
.max(PRIORITY_ITEM_KINDS.length)
.optional()
.describe(
"Today-highlight item kinds in catalogue order. Missing means every current kind is enabled; an empty array disables all kinds.",
),
})
.meta({
id: "DashboardLayoutBody",
description:
"Per-user dashboard widget layout. `widgets` is the ordered tile/chart list (each with a visibility flag plus the independent `tileVisible` strip-tile flag); `comparisonBaseline`, `chartOverlayPrefs`, `selectedScoreRings`, and `heroRingOrder` are the additive dashboard-preference fields. Every field except `version` is optional on input and preserve-when-absent: an omitted field is carried forward from the stored layout rather than reset, so a single-surface PUT (e.g. the instant hero-ring toggle, which omits `widgets`) can never clobber a choice made on another surface. Widget ids are the closed 27-id catalogue (16 web + 11 iOS-only); unknown ids are dropped before validation.",
"Per-user dashboard widget layout. `widgets` is the ordered tile and chart list. `comparisonBaseline`, `chartOverlayPrefs`, `selectedScoreRings`, `heroRingOrder`, and `enabledHeroItemKinds` are additive dashboard preferences. Every field except `version` is optional on input and preserved when absent from PUT. Missing `enabledHeroItemKinds` resolves to every current kind, while an empty array hides every Today highlight. Widget ids use the closed dashboard catalogue; unknown ids are dropped before validation.",
});
const dashboardLayoutPutBody = dashboardLayoutSchema
@@ -143,10 +149,11 @@ export const dashboardWidgetPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Dashboard"],
summary: "Reset the calling user's dashboard widget layout",
description:
"Clears the persisted layout and returns the default layout. Idempotent.",
"Resets the tile layout and Today-highlight visibility. It preserves the stored comparison baseline, chart overlays, selected score rings, and hero ring order. Idempotent.",
responses: {
"200": {
description: "Layout reset; the default layout is returned.",
description:
"Web-controlled layout settings reset; preserved dashboard preferences are returned with the defaults.",
content: {
"application/json": {
schema: dataEnvelope(