diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index def1180f5..70b56c432 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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." diff --git a/messages/de.json b/messages/de.json index 340969ccb..136d4c18c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index df7fa6515..07f428218 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index c4f599020..5a50e6930 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 5120603c8..69c70e56c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index e4f2e2566..7ce1cc0b3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index c696e7113..1218192da 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index 297ff8aa0..c2fe27177 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -102,7 +102,6 @@ const LEGIT_IDENTICAL = new Set([ "admin.overview.snapshotOfflineGeoOff", "admin.overview.snapshotOfflineGeoOn", "admin.webPushVapidTitle", - "dashboard.hero.scoreLabel", "insights.healthScore.provenance.sources.appleHealth", "measurements.sourceAppleHealth", "onboarding.source.more.appleHealth", diff --git a/src/app/__tests__/dashboard-hero-wiring.test.ts b/src/app/__tests__/dashboard-hero-wiring.test.ts index 8a1bd9016..5242915c0 100644 --- a/src/app/__tests__/dashboard-hero-wiring.test.ts +++ b/src/app/__tests__/dashboard-hero-wiring.test.ts @@ -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"); diff --git a/src/app/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts index 9c2cf56a5..095282ea7 100644 --- a/src/app/api/dashboard/widgets/__tests__/route.test.ts +++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts @@ -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; +const callDelete = DELETE as unknown as () => Promise; 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, + ) => 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 & { + 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()); + }); +}); diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 72a41be8d..f292bd1ae 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -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); }); diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 9efb468b7..87f8e9a70 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -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 ? ( - + ) : !mounted || digestQuery.isLoading ? ( ) : digestQuery.isError ? ( diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index 9d5949ab6..4bb403aea 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -143,6 +143,29 @@ describe("", () => { expect(html).toBe(""); }); + it("shows all-clear when layout filtering removes every candidate", () => { + const html = render( + , + ); + + 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. diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index ae9806668..b31693c83 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -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 && diff --git a/src/components/settings/__tests__/dashboard-layout-section.test.tsx b/src/components/settings/__tests__/dashboard-layout-section.test.tsx index 4c72b5014..65a90d3d1 100644 --- a/src/components/settings/__tests__/dashboard-layout-section.test.tsx +++ b/src/components/settings/__tests__/dashboard-layout-section.test.tsx @@ -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(" — drag-and-drop reorder", () => { it("keeps the arrow buttons present after the drag handle lands (a11y fallback)", () => { const html = render(); - // 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(" — 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(" — hero score-ring picker (v1.27.7)", () => { - it("renders for the default layout", () => { +describe(" — hero content", () => { + it("keeps the removed score and comparison controls out of the UI", () => { const html = render(); - 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(); - 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( - /]*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( + `]*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(); - 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(/]*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(); - 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(); - const compliance = html.match( - /]*data-slot="score-ring-switch"[^>]*data-ring="MED_COMPLIANCE"[^>]*>/, + const switches = html.match( + /]*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( - /]*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(" — 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(); - // A sortable ring row exists for the health-score anchor. - expect(html).toContain('data-slot="score-ring-row"'); - const anchorRow = html.match( - /]*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(); - const anchorSwitch = html.match( - /]*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(); - // 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(); + const fieldset = html.match( + /]*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(" { + authState.modules = { + coach: false, + medications: false, + insights: false, }; const html = render(); - 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(, "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(); + expect(html).toContain("Notification settings are managed separately."); }); }); diff --git a/src/components/settings/__tests__/dashboard-layout-snapshot-invalidation.test.ts b/src/components/settings/__tests__/dashboard-layout-snapshot-invalidation.test.ts index e670f8eef..5241c11c9 100644 --- a/src/components/settings/__tests__/dashboard-layout-snapshot-invalidation.test.ts +++ b/src/components/settings/__tests__/dashboard-layout-snapshot-invalidation.test.ts @@ -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", () => { diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index d3e2907d1..e1b2c4ad5 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -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 = { @@ -185,27 +176,15 @@ const WIDGET_LABEL_KEYS: Record = { 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 = { - 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 = { - [HEALTH_SCORE_RING_ID]: "dashboard.hero.scoreLabel", - ...SCORE_RING_LABEL_KEYS, +const HERO_ITEM_LABEL_KEYS: Record = { + 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("/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("/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( - "/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( - 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 `` 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 && ( -
- - -

- {t("comparison.toggleHint")} -

-
- )} - - {/* 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 && ( -
-
-

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

-

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

-
- {(() => { - 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 ( -
- + {t("dashboard.heroItemsTitle")} + +

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

+
+ {PRIORITY_ITEM_KINDS.map((kind) => { + const inputId = `hero-item-${kind}`; + const checked = ( + layout.enabledHeroItemKinds ?? PRIORITY_ITEM_KINDS + ).includes(kind); + return ( +
- - {heroOrder.map((ringId, index) => ( - - ))} - - - {unselected.map((ringId) => ( -
- - {t(SCORE_RING_LABEL_KEYS[ringId])} - - toggleScoreRing(ringId, v)} - disabled={busy || atCap} - aria-label={t(SCORE_RING_LABEL_KEYS[ringId])} - data-slot="score-ring-switch" - data-ring={ringId} - /> -
- ))} -
- ); - })()} -
+ + toggleHeroItem(kind, value)} + disabled={saveMutation.isPending} + aria-label={t(HERO_ITEM_LABEL_KEYS[kind])} + data-slot="hero-item-switch" + data-kind={kind} + /> +
+ ); + })} +
+

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

+ )} {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")} @@ -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 && ( @@ -1027,7 +719,9 @@ export function DashboardLayoutSection({ id }: { id: string }) {

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

@@ -1251,121 +945,3 @@ function StaticWidgetRow({ ); } - -/** - * 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 ( -
- - - {label} - - 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} - /> - - -
- ); -} diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index 0e091ce05..b52d0cec9 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -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); - }); -}); diff --git a/src/lib/api/optimistic-token.ts b/src/lib/api/optimistic-token.ts index b315cf679..18219dc1d 100644 --- a/src/lib/api/optimistic-token.ts +++ b/src/lib/api/optimistic-token.ts @@ -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( payload: T, diff --git a/src/lib/daily/__tests__/daily-briefing-push.test.ts b/src/lib/daily/__tests__/daily-briefing-push.test.ts index 8ef31e2b4..321c2136a 100644 --- a/src/lib/daily/__tests__/daily-briefing-push.test.ts +++ b/src/lib/daily/__tests__/daily-briefing-push.test.ts @@ -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(); diff --git a/src/lib/daily/__tests__/digest-just-in.test.ts b/src/lib/daily/__tests__/digest-just-in.test.ts index eee177a28..845596a45 100644 --- a/src/lib/daily/__tests__/digest-just-in.test.ts +++ b/src/lib/daily/__tests__/digest-just-in.test.ts @@ -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 { return { now: NOW, modules: {}, + enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS], score: { value: 82, band: "good", delta: 3 }, briefing: null, medsToday: meds(), diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index 992b74ec7..239b8f55c 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -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 { 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([]); diff --git a/src/lib/daily/__tests__/load-digest.test.ts b/src/lib/daily/__tests__/load-digest.test.ts index f6c2df61a..421935e26 100644 --- a/src/lib/daily/__tests__/load-digest.test.ts +++ b/src/lib/daily/__tests__/load-digest.test.ts @@ -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"); + }); }); diff --git a/src/lib/daily/daily-briefing-push.ts b/src/lib/daily/daily-briefing-push.ts index 3f9e12408..d2042409d 100644 --- a/src/lib/daily/daily-briefing-push.ts +++ b/src/lib/daily/daily-briefing-push.ts @@ -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 { + 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 { const dispatch = deps.dispatch ?? dispatchNotification; - const loadDigest = deps.loadDigest ?? loadDailyDigest; + const loadDigest = deps.loadDigest ?? loadDailyBriefingDigest; const moduleGate = deps.isModuleEnabled ?? isModuleEnabled; try { diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 3d550ce8e..e60800816 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -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), ); diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index 45e98373b..5843cc774 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -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 { // 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, diff --git a/src/lib/daily/priority-item.ts b/src/lib/daily/priority-item.ts index 3957ecada..d20f97db1 100644 --- a/src/lib/daily/priority-item.ts +++ b/src/lib/daily/priority-item.ts @@ -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 diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 1b6849bd3..59df602fb 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -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(); + 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` 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; /** @@ -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 & { - 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 }), }; } diff --git a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts index 037c5b78b..14d55984f 100644 --- a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts +++ b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts @@ -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: { diff --git a/src/lib/openapi/routes/dashboard.ts b/src/lib/openapi/routes/dashboard.ts index 4a7c82632..cc2375cf8 100644 --- a/src/lib/openapi/routes/dashboard.ts +++ b/src/lib/openapi/routes/dashboard.ts @@ -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 = { 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(