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