Merge pull request #315 from MBombeck/release/v1.16.9

Release v1.16.9 — the day opens with a verdict, and every dose path tells the truth
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-12 12:10:38 +02:00
commit dacdcccfd2
85 files changed
+6552 -437

No files matched your search

+15
View File
@@ -2,6 +2,21 @@
## [Unreleased]
## [1.16.9] — 2026-06-12 — the day opens with a verdict, and every dose path tells the truth
### Added
- **The dashboard opens with a daily band.** Greeting, the one item that matters right now — a fresh crisis-level blood-pressure reading, an overdue or upcoming dose, weight drifting from its range, a run of short nights, a quiet stretch, a score drop, or the briefing's key finding — with a single action, today's dose tally beside the next due time, and the health score as a ring. The verdict resolves from a fixed ladder with crisis floors that ignore personal target ranges, it never invites a dose the server has not called due, and the band can be turned off under dashboard settings, where the plain greeting then returns to the header.
### Fixed
- **An intake recorded through the external API now credits the due slot.** The ingest endpoint was the one intake path that stored a bare row beside the pending slot — the card showed the intake as the last dose while still demanding it, and the ledger later counted a miss. It attributes to the dose band like every other path now, the Telegram confirm and skip buttons converge onto the reminder's own slot, and a nightly pass heals the pairs this has already produced — both days' history and compliance recompute, no manual repair needed.
- **A cached page can no longer contradict an action you just took.** Invalidation now fences in-flight cache builds, so a read can neither join a build that started before your write nor have a background refresh re-store the pre-write state as fresh.
- **Dose prompts err on the safe side everywhere.** A take shortly before the window credits the slot instead of floating loose, an off-schedule intake can no longer silently resolve a different slot and hide a genuinely due dose, a long-interval dose taken on an earlier day downgrades the slot-day prompt to "last dose n days ago" instead of inviting a double dose, and the overdue badge no longer disappears because the day's pending entries were counted as actions.
- Intake times entered retroactively carry the same plausibility bounds as edits, moving a dose across a slot boundary keeps its recorded injection site and dose, both affected days recompute, and schedule corrections, data wipes and backup restores refresh the caches they feed.
- The medication cards, the quick-entry preselection and the dose pills compute their clock in the profile timezone instead of a fixed one.
- WHOOP credentials are trimmed on save — a stray space from the portal's copy button read as an unknown client — and an empty redirect override falls back to the derived callback URL.
## [1.16.8] — 2026-06-12 — texts that follow your data, charts that never strand
### Added
+78 -3
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.16.8
version: 1.16.9
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -2118,7 +2118,7 @@ paths:
summary: List medications for the calling user
description: Returns every medication owned by the caller (active + paused), ordered by `createdAt DESC`. Each row
carries its nested `schedules`, the joined clinical `category`, the latest non-skipped `lastTakenAt`, and the
count of intake events scheduled for today (`todayEventCount`). The response is cached server-side for 60 s per
count of today's actioned intake events (`todayEventCount`). The response is cached server-side for 60 s per
user; writes flush the cache.
responses:
"200":
@@ -4890,6 +4890,7 @@ components:
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
takenAt:
description: When the dose was actually taken. NULL when `skipped` is true; defaults to `now()` for non-skipped intakes.
Must not lie in the future (5-minute clock-skew allowance) nor more than 5 years in the past.
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
@@ -8362,7 +8363,9 @@ components:
type: integer
minimum: 0
maximum: 9007199254740991
description: Number of intake events scheduled for today (user-local day window).
description: "Number of ACTIONED intake events for today (user-local day window): rows with a recorded `takenAt` or an
explicit skip. Pending projector-minted rows do not count — the card overdue-pill suppression compares this
against the passed-dose count, and a pending mint must not read as covered."
required:
- id
- name
@@ -11713,6 +11716,76 @@ components:
- glucoseByContext
additionalProperties: false
- type: "null"
medsToday:
type: object
properties:
activeCount:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
scheduledToday:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
takenToday:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
skippedToday:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
nextDueAt:
anyOf:
- type: string
- type: "null"
nextDueOverdue:
type: boolean
nextDueMedicationName:
anyOf:
- type: string
- type: "null"
required:
- activeCount
- scheduledToday
- takenToday
- skippedToday
- nextDueAt
- nextDueOverdue
- nextDueMedicationName
additionalProperties: false
description: "Today's medication block: active-medication count, today-window tally (scheduled / taken / skipped), and
the earliest next-due slot across active medications. `nextDueOverdue: true` marks an OPEN overdue slot
(anchor passed, still inside its catch-up band). Staleness contract: the snapshot is cache-served, so a
`nextDueAt` in the past with `nextDueOverdue: false` means the anchor passed after the snapshot was built —
render the plain day summary, never an overdue state."
healthScore:
anyOf:
- type: object
properties:
score:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
band:
type: string
enum:
- green
- yellow
- red
delta:
anyOf:
- type: number
- type: "null"
required:
- score
- band
- delta
additionalProperties: false
- type: "null"
description: Personal health score summary (0..100 score, traffic-light band, week-over-week delta). Null on a
rollup-coverage miss (it rides the thick phase alongside `extras`) and when no pillar is computable.
Component breakdown is deliberately not serialised here.
briefing:
anyOf:
- type: object
@@ -11745,6 +11818,8 @@ components:
- metricStates
- tiles
- extras
- medsToday
- healthScore
- briefing
- briefingState
- briefingUpdatedAt
+10
View File
@@ -144,6 +144,16 @@ export function buildMockSnapshot(
mood: { summary: null, entries: [] },
},
extras: null,
medsToday: {
activeCount: 0,
scheduledToday: 0,
takenToday: 0,
skippedToday: 0,
nextDueAt: null,
nextDueOverdue: false,
nextDueMedicationName: null,
},
healthScore: null,
briefing: null,
briefingState: "preparing",
briefingUpdatedAt: null,
+34
View File
@@ -274,6 +274,36 @@
},
"welcomeBack": "{greeting}, willkommen zurück.",
"welcomeBackWithName": "{greeting} {name}, willkommen zurück.",
"hero": {
"scoreLabel": "Health Score",
"scoreProvisional": "Noch nicht genug Daten",
"doses": {
"summary": "{taken} von {scheduled} Dosen genommen",
"nextAt": "Nächste um {time}",
"allDone": "Alle Dosen für heute erledigt",
"none": "Heute keine Dosen geplant"
},
"verdict": {
"doseOverdue": "Eine Dosis {name} ist überfällig.",
"doseOverdueNoName": "Eine Dosis ist überfällig.",
"bpHigh": "Dein letzter Blutdruck war sehr hoch ({sys}/{dia}). Miss in Ruhe noch einmal nach.",
"doseUpcoming": "Um {time} steht {name} an.",
"doseUpcomingNoName": "Um {time} steht eine Dosis an.",
"weightDrift": "Dein Gewicht bewegt sich diese Woche vom Zielbereich weg.",
"shortNights": "Deine Nächte waren zuletzt kurz — im Schnitt {hours} h.",
"silence": "Seit {days} Tagen keine Messung. Ein kurzer Eintrag hält dein Bild aktuell.",
"scoreDrop": "Dein Score liegt {points} Punkte unter der Vorwoche.",
"allQuiet": "Alles ruhig — heute gibt es nichts, das deine Aufmerksamkeit braucht."
},
"action": {
"takeDose": "Jetzt eintragen",
"viewBp": "Blutdruck ansehen",
"viewWeight": "Gewicht ansehen",
"viewSleep": "Schlaf ansehen",
"logMeasurement": "Messung erfassen",
"viewInsights": "Insights öffnen"
}
},
"greetingSalutation": "Hallo, {name}",
"streakLabel": "Tage in Folge",
"weight": "Gewicht",
@@ -323,6 +353,8 @@
"compliance7d": "Therapietreue (7 Tage)",
"mood": "Stimmung",
"customizeTitle": "Dashboard-Layout",
"heroToggleLabel": "Tagesüberblick",
"heroToggleDescription": "Zeigt Begrüßung, Score, Tagesfokus und Dosen-Status ganz oben auf dem Dashboard.",
"layoutReset": "Auf Defaults zurücksetzen",
"layoutSaveSuccess": "Dashboard-Layout gespeichert",
"layoutSaveError": "Layout konnte nicht gespeichert werden",
@@ -730,6 +762,8 @@
"withoutNotification": "Ohne Benachrichtigung",
"pausedSince": "Pausiert seit",
"takeNow": "Jetzt einnehmen",
"lastDoseYesterday": "Letzte Dosis gestern",
"lastDoseDaysAgo": "Letzte Dosis vor {count} Tagen",
"overdue": "Überfällig",
"veryOverdue": "Stark überfällig",
"compliance7d": "Therapietreue (7 Tage)",
+34
View File
@@ -274,6 +274,36 @@
},
"welcomeBack": "{greeting}, welcome back.",
"welcomeBackWithName": "{greeting} {name}, welcome back.",
"hero": {
"scoreLabel": "Health Score",
"scoreProvisional": "Not enough data yet",
"doses": {
"summary": "{taken} of {scheduled} doses taken",
"nextAt": "Next at {time}",
"allDone": "All doses done for today",
"none": "No doses scheduled today"
},
"verdict": {
"doseOverdue": "A dose of {name} is overdue.",
"doseOverdueNoName": "A dose is overdue.",
"bpHigh": "Your last blood pressure was very high ({sys}/{dia}). Take another reading at rest.",
"doseUpcoming": "{name} is due at {time}.",
"doseUpcomingNoName": "A dose is due at {time}.",
"weightDrift": "Your weight is moving away from the target range this week.",
"shortNights": "Your nights have been short lately — {hours} h on average.",
"silence": "No measurement for {days} days. A quick entry keeps your picture current.",
"scoreDrop": "Your score sits {points} points below last week.",
"allQuiet": "All quiet — nothing needs your attention today."
},
"action": {
"takeDose": "Log it now",
"viewBp": "View blood pressure",
"viewWeight": "View weight",
"viewSleep": "View sleep",
"logMeasurement": "Log measurement",
"viewInsights": "Open Insights"
}
},
"greetingSalutation": "Hi, {name}",
"streakLabel": "days in a row",
"weight": "Weight",
@@ -323,6 +353,8 @@
"compliance7d": "Adherence (7 days)",
"mood": "Mood",
"customizeTitle": "Dashboard layout",
"heroToggleLabel": "Daily overview",
"heroToggleDescription": "Shows your greeting, score, daily focus and dose status at the top of the dashboard.",
"layoutReset": "Reset to defaults",
"layoutSaveSuccess": "Dashboard layout saved",
"layoutSaveError": "Could not save dashboard layout",
@@ -730,6 +762,8 @@
"withoutNotification": "Without notification",
"pausedSince": "Paused since",
"takeNow": "Take now",
"lastDoseYesterday": "Last dose yesterday",
"lastDoseDaysAgo": "Last dose {count} days ago",
"overdue": "Overdue",
"veryOverdue": "Very overdue",
"compliance7d": "Adherence (7 days)",
+34
View File
@@ -274,6 +274,36 @@
},
"welcomeBack": "{greeting}, te damos la bienvenida de nuevo.",
"welcomeBackWithName": "{greeting} {name}, te damos la bienvenida de nuevo.",
"hero": {
"scoreLabel": "Health Score",
"scoreProvisional": "Aún no hay datos suficientes",
"doses": {
"summary": "{taken} de {scheduled} dosis tomadas",
"nextAt": "Próxima a las {time}",
"allDone": "Todas las dosis de hoy completadas",
"none": "Hoy no hay dosis programadas"
},
"verdict": {
"doseOverdue": "Una dosis de {name} está atrasada.",
"doseOverdueNoName": "Una dosis está atrasada.",
"bpHigh": "Tu última tensión arterial fue muy alta ({sys}/{dia}). Vuelve a medirla con calma.",
"doseUpcoming": "A las {time} toca {name}.",
"doseUpcomingNoName": "A las {time} toca una dosis.",
"weightDrift": "Tu peso se está alejando del rango objetivo esta semana.",
"shortNights": "Tus noches han sido cortas últimamente — {hours} h de media.",
"silence": "Sin mediciones desde hace {days} días. Un registro rápido mantiene tu panorama al día.",
"scoreDrop": "Tu puntuación está {points} puntos por debajo de la semana pasada.",
"allQuiet": "Todo tranquilo — hoy no hay nada que requiera tu atención."
},
"action": {
"takeDose": "Registrar ahora",
"viewBp": "Ver tensión arterial",
"viewWeight": "Ver peso",
"viewSleep": "Ver sueño",
"logMeasurement": "Registrar medición",
"viewInsights": "Abrir Insights"
}
},
"greetingSalutation": "Hola, {name}",
"streakLabel": "días seguidos",
"weight": "Peso",
@@ -323,6 +353,8 @@
"compliance7d": "Cumplimiento (7 días)",
"mood": "Estado de ánimo",
"customizeTitle": "Diseño del panel",
"heroToggleLabel": "Resumen del día",
"heroToggleDescription": "Muestra el saludo, la puntuación, el foco del día y el estado de las dosis en la parte superior del panel.",
"layoutReset": "Restablecer valores por defecto",
"layoutSaveSuccess": "Diseño del panel guardado",
"layoutSaveError": "No se ha podido guardar el diseño",
@@ -730,6 +762,8 @@
"withoutNotification": "Sin notificación",
"pausedSince": "En pausa desde",
"takeNow": "Tomar ahora",
"lastDoseYesterday": "Última dosis ayer",
"lastDoseDaysAgo": "Última dosis hace {count} días",
"overdue": "Vencida",
"veryOverdue": "Muy vencida",
"compliance7d": "Cumplimiento 7 días",
+34
View File
@@ -274,6 +274,36 @@
},
"welcomeBack": "{greeting}, content de te revoir.",
"welcomeBackWithName": "{greeting} {name}, content de te revoir.",
"hero": {
"scoreLabel": "Health Score",
"scoreProvisional": "Pas encore assez de données",
"doses": {
"summary": "{taken} dose(s) prise(s) sur {scheduled}",
"nextAt": "Prochaine à {time}",
"allDone": "Toutes les doses du jour sont prises",
"none": "Aucune dose prévue aujourd’hui"
},
"verdict": {
"doseOverdue": "Une dose de {name} est en retard.",
"doseOverdueNoName": "Une dose est en retard.",
"bpHigh": "Votre dernière tension était très élevée ({sys}/{dia}). Reprenez une mesure au calme.",
"doseUpcoming": "{name} est prévu à {time}.",
"doseUpcomingNoName": "Une dose est prévue à {time}.",
"weightDrift": "Votre poids s’éloigne de la plage cible cette semaine.",
"shortNights": "Vos nuits ont été courtes récemment — {hours} h en moyenne.",
"silence": "Aucune mesure depuis {days} jours. Une saisie rapide garde votre tableau à jour.",
"scoreDrop": "Votre score est {points} points sous la semaine dernière.",
"allQuiet": "Tout est calme — rien ne demande votre attention aujourd’hui."
},
"action": {
"takeDose": "Enregistrer maintenant",
"viewBp": "Voir la tension",
"viewWeight": "Voir le poids",
"viewSleep": "Voir le sommeil",
"logMeasurement": "Saisir une mesure",
"viewInsights": "Ouvrir Insights"
}
},
"greetingSalutation": "Bonjour, {name}",
"streakLabel": "jours d’affilée",
"weight": "Poids",
@@ -323,6 +353,8 @@
"compliance7d": "Observance (7 jours)",
"mood": "Humeur",
"customizeTitle": "Mise en page du tableau",
"heroToggleLabel": "Aperçu du jour",
"heroToggleDescription": "Affiche le message d’accueil, le score, le focus du jour et l’état des doses tout en haut du tableau de bord.",
"layoutReset": "Rétablir les valeurs par défaut",
"layoutSaveSuccess": "Mise en page enregistrée",
"layoutSaveError": "Impossible d’enregistrer la mise en page",
@@ -730,6 +762,8 @@
"withoutNotification": "Sans notification",
"pausedSince": "En pause depuis",
"takeNow": "Prendre maintenant",
"lastDoseYesterday": "Dernière dose hier",
"lastDoseDaysAgo": "Dernière dose il y a {count} jours",
"overdue": "En retard",
"veryOverdue": "Très en retard",
"compliance7d": "Observance 7 jours",
+34
View File
@@ -274,6 +274,36 @@
},
"welcomeBack": "{greeting}, bentornato.",
"welcomeBackWithName": "{greeting} {name}, bentornato.",
"hero": {
"scoreLabel": "Health Score",
"scoreProvisional": "Dati ancora insufficienti",
"doses": {
"summary": "{taken} dosi assunte su {scheduled}",
"nextAt": "Prossima alle {time}",
"allDone": "Tutte le dosi di oggi completate",
"none": "Nessuna dose in programma oggi"
},
"verdict": {
"doseOverdue": "Una dose di {name} è in ritardo.",
"doseOverdueNoName": "Una dose è in ritardo.",
"bpHigh": "L’ultima pressione era molto alta ({sys}/{dia}). Misurala di nuovo con calma.",
"doseUpcoming": "Alle {time} è prevista {name}.",
"doseUpcomingNoName": "Alle {time} è prevista una dose.",
"weightDrift": "Questa settimana il tuo peso si sta allontanando dall’intervallo obiettivo.",
"shortNights": "Le tue notti sono state corte di recente — in media {hours} h.",
"silence": "Nessuna misurazione da {days} giorni. Una voce rapida tiene aggiornato il quadro.",
"scoreDrop": "Il tuo punteggio è {points} punti sotto la settimana scorsa.",
"allQuiet": "Tutto tranquillo — oggi non c’è nulla che richieda la tua attenzione."
},
"action": {
"takeDose": "Registra ora",
"viewBp": "Vedi pressione",
"viewWeight": "Vedi peso",
"viewSleep": "Vedi sonno",
"logMeasurement": "Registra misurazione",
"viewInsights": "Apri Insights"
}
},
"greetingSalutation": "Ciao, {name}",
"streakLabel": "giorni di fila",
"weight": "Peso",
@@ -323,6 +353,8 @@
"compliance7d": "Aderenza (7 giorni)",
"mood": "Umore",
"customizeTitle": "Layout della dashboard",
"heroToggleLabel": "Panoramica del giorno",
"heroToggleDescription": "Mostra saluto, punteggio, focus del giorno e stato delle dosi in cima alla dashboard.",
"layoutReset": "Ripristina i valori predefiniti",
"layoutSaveSuccess": "Layout della dashboard salvato",
"layoutSaveError": "Impossibile salvare il layout",
@@ -730,6 +762,8 @@
"withoutNotification": "Senza notifica",
"pausedSince": "In pausa dal",
"takeNow": "Assumi ora",
"lastDoseYesterday": "Ultima dose ieri",
"lastDoseDaysAgo": "Ultima dose {count} giorni fa",
"overdue": "In ritardo",
"veryOverdue": "Molto in ritardo",
"compliance7d": "Aderenza 7 giorni",
+34
View File
@@ -274,6 +274,36 @@
},
"welcomeBack": "{greeting}, witaj ponownie.",
"welcomeBackWithName": "{greeting} {name}, witaj ponownie.",
"hero": {
"scoreLabel": "Health Score",
"scoreProvisional": "Za mało danych",
"doses": {
"summary": "Przyjęto {taken} z {scheduled} dawek",
"nextAt": "Następna o {time}",
"allDone": "Wszystkie dzisiejsze dawki przyjęte",
"none": "Brak zaplanowanych dawek na dziś"
},
"verdict": {
"doseOverdue": "Dawka {name} jest zaległa.",
"doseOverdueNoName": "Dawka jest zaległa.",
"bpHigh": "Twoje ostatnie ciśnienie było bardzo wysokie ({sys}/{dia}). Zmierz je ponownie w spokoju.",
"doseUpcoming": "O {time} pora na {name}.",
"doseUpcomingNoName": "O {time} pora na dawkę.",
"weightDrift": "Twoja waga oddala się w tym tygodniu od zakresu docelowego.",
"shortNights": "Twoje noce były ostatnio krótkie — średnio {hours} h.",
"silence": "Brak pomiarów od {days} dni. Szybki wpis utrzyma aktualny obraz.",
"scoreDrop": "Twój wynik jest o {points} punktów niższy niż w zeszłym tygodniu.",
"allQuiet": "Spokojnie — dziś nic nie wymaga Twojej uwagi."
},
"action": {
"takeDose": "Zapisz teraz",
"viewBp": "Zobacz ciśnienie",
"viewWeight": "Zobacz wagę",
"viewSleep": "Zobacz sen",
"logMeasurement": "Zapisz pomiar",
"viewInsights": "Otwórz Insights"
}
},
"greetingSalutation": "Cześć, {name}",
"streakLabel": "dni z rzędu",
"weight": "Waga",
@@ -323,6 +353,8 @@
"compliance7d": "Przestrzeganie (7 dni)",
"mood": "Nastrój",
"customizeTitle": "Układ panelu",
"heroToggleLabel": "Przegląd dnia",
"heroToggleDescription": "Pokazuje powitanie, wynik, fokus dnia i status dawek na górze pulpitu.",
"layoutReset": "Przywróć wartości domyślne",
"layoutSaveSuccess": "Układ panelu zapisany",
"layoutSaveError": "Nie udało się zapisać układu",
@@ -730,6 +762,8 @@
"withoutNotification": "Bez powiadomienia",
"pausedSince": "Wstrzymane od",
"takeNow": "Przyjmij teraz",
"lastDoseYesterday": "Ostatnia dawka wczoraj",
"lastDoseDaysAgo": "Ostatnia dawka {count} dni temu",
"overdue": "Po terminie",
"veryOverdue": "Mocno po terminie",
"compliance7d": "Przestrzeganie 7 dni",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.16.8",
"version": "1.16.9",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -0,0 +1,92 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* Dashboard hero — page + header wiring pins.
*
* The hero band mounts between the page header and the tile strip:
*
* - it gates on the layout's `heroVisible` flag (anything but the
* literal `false` renders — the resolver's clamp) AND on snapshot
* mode (the verdict needs the snapshot payload);
* - while `primaryLoading` is true (same `!mounted ||` pin the tile
* silhouettes use) a footprint-identical skeleton holds the band,
* so the hero and the tiles swap to content in the SAME render
* pass;
* - the greeting paragraph lives in the hero when that renders; when
* the hero does NOT render (snapshot flag off, or hidden via the
* layout toggle) the header takes it back behind `showGreeting`,
* fed from the page's hero gate — the greeting never disappears.
*/
const PAGE_PATH = join(process.cwd(), "src/app/page.tsx");
const HEADER_PATH = join(
process.cwd(),
"src/components/dashboard/dashboard-header.tsx",
);
describe("dashboard hero — page wiring", () => {
const src = readFileSync(PAGE_PATH, "utf8");
it("gates the hero on snapshot mode + the layout's heroVisible flag", () => {
expect(src).toMatch(
/const\s+heroVisible\s*=\s*snapshotEnabled\s*&&\s*layout\.heroVisible\s*!==\s*false;/,
);
});
it("holds the skeleton while primaryLoading and swaps the hero in the same pass", () => {
const block = src.match(
/\{heroVisible\s*&&[\s\S]*?<DashboardHero[\s\S]*?\)\}/,
);
expect(block).not.toBeNull();
expect(block![0]).toMatch(
/primaryLoading\s*\|\|\s*!heroSnapshot\s*\?\s*\(\s*<DashboardHeroSkeleton\s*\/>/,
);
expect(block![0]).toContain("onQuickEntry={setQuickEntryDialog}");
});
it("derives primaryLoading once in the component body (shared with the tile gate)", () => {
const matches = src.match(/const\s+primaryLoading\s*=\s*!mounted/g) ?? [];
expect(matches).toHaveLength(1);
});
it("the hero sits between the header and the strip", () => {
const headerIdx = src.indexOf("<DashboardHeader");
const heroIdx = src.indexOf("{heroVisible &&");
const stripIdx = src.indexOf("dashboard-tile-strip");
expect(headerIdx).toBeGreaterThan(-1);
expect(heroIdx).toBeGreaterThan(headerIdx);
expect(stripIdx).toBeGreaterThan(heroIdx);
});
it("the page no longer derives the welcome text (it moved into the hero)", () => {
expect(src).not.toContain("welcomeText");
expect(src).not.toContain("welcomeBackWithName");
expect(src).not.toContain("getHourForTimeZone");
});
});
describe("dashboard hero — header handoff", () => {
const header = readFileSync(HEADER_PATH, "utf8");
it("the header keeps the greeting behind showGreeting (hero-hidden fallback)", () => {
// The greeting renders here ONLY when the hero band does not — the
// prop gates the paragraph, and the `min-h-5` line-box reservation
// keeps the header stable through the post-hydration name swap.
expect(header).toContain("showGreeting");
expect(header).toMatch(/\{showGreeting\s*\?/);
expect(header).toContain("min-h-5");
expect(header).toContain('data-slot="dashboard-header-greeting"');
});
it("the page feeds showGreeting from the inverted hero gate", () => {
const src = readFileSync(PAGE_PATH, "utf8");
expect(src).toMatch(/showGreeting=\{!heroVisible\}/);
});
it("the header keeps the title + customize + add actions", () => {
expect(header).toContain('t("dashboard.title")');
expect(header).toContain("dashboard-customize-shortcut");
expect(header).toContain("dashboard-quick-add");
});
});
@@ -74,15 +74,14 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => {
expect(moduleSlice).toMatch(/const\s+DASHBOARD_QUERY_OPTS\s*=\s*\{/);
});
it("memoises the hour-of-day derivation against user.timezone (audit-H4)", () => {
it("keeps the per-render Intl hour walk off the page (audit-H4 successor)", () => {
const src = load(PAGE_PATH);
// The greeting hour only changes when the user's timezone changes
// — a `useMemo` keyed on the lifted `userTimezone` local keeps the
// `Intl.DateTimeFormat` instantiation off the per-render hot path.
// Post-W-INFRA Thread 2: `user?.timezone` is lifted to a `userTimezone`
// local one line above the `useMemo` so the dep array stays stable.
expect(src).toMatch(
/const\s+hour\s*=\s*useMemo\([\s\S]*?\[\s*userTimezone\s*\][\s\S]*?\);/,
);
// The audit-H4 `useMemo(getHourForTimeZone, [userTimezone])` pin
// retired with the greeting itself: the hero band derives the
// daypart from the snapshot's server-computed `greetingHour`
// (`dashboard-hero.tsx`), so the page no longer instantiates any
// `Intl.DateTimeFormat` for the greeting — per render or otherwise.
expect(src).not.toContain("getHourForTimeZone");
expect(src).not.toContain("welcomeText");
});
});
@@ -50,6 +50,7 @@ import {
} from "@/lib/rollups/medication-compliance-rollups";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
import { restoreCycleData } from "@/lib/cycle/backup";
import { invalidateUserData } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -536,6 +537,10 @@ const handler = apiHandler(
},
});
// v1.16.9 — the restore replaced every row the owner's cached
// payloads were built on; hard-evict all of that user's buckets.
invalidateUserData(ownerId);
const response: RestoreResponse = {
restored: true,
summary,
+5
View File
@@ -5,6 +5,7 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { NextRequest, NextResponse } from "next/server";
import { invalidateAllCaches } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -105,5 +106,9 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
details: result,
});
// v1.16.9 — the wipe touched every user's rows; clear every cache
// bucket so no per-user payload survives the reset.
invalidateAllCaches();
return apiSuccess({ cleared: true, ...result });
});
@@ -14,12 +14,19 @@ vi.mock("@/lib/db", () => ({
// Dashboard compliance tile leaves "Heute nichts geplant" the
// moment a daily med is configured.
createMany: vi.fn(),
// Shared meds-today builder — latest non-skipped intake per
// medication (next-due re-anchor for rolling cadences).
groupBy: vi.fn(),
},
medication: {
// v1.4.39 W-SERVER-FIX-2 — projection source for the today's-
// intakes backfill.
findMany: vi.fn(),
},
// Shared meds-today builder — current-era floor per medication.
medicationScheduleRevision: {
groupBy: vi.fn(),
},
// v1.4.38 W-F — the route now uses `$queryRaw` for the per-type
// latest reading, the 7-day rollup-bucket sparkline, and the
// 365-day distinct-day streak set. Three raw calls per request,
@@ -105,6 +112,14 @@ beforeEach(() => {
vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({
count: 0,
} as never);
// Shared meds-today builder feeder reads — default to empty so the
// legacy tests keep their "no medications" branch.
vi.mocked(prisma.medicationIntakeEvent.groupBy).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medicationScheduleRevision.groupBy).mockResolvedValue(
[] as never,
);
});
// v1.4.48 L13 — restore real timers no matter how a test exits. A failed
@@ -620,25 +635,25 @@ describe("GET /api/dashboard/summary", () => {
],
},
] as never);
// The dashboard route now issues three findMany calls in the
// today path:
// 1. existence probe (no `OR`) → empty (no rows yet)
// 2. final read after backfill (no `OR`) → one minted row
// 3. streak fetch (with `OR`) → empty
// The shared meds-today builder issues four findMany shapes:
// 1. projector existence probe (no `OR`, selects medicationId +
// scheduledFor) → empty (no rows yet)
// 2. today tally read after backfill (no `OR`, selects takenAt +
// skipped) → one minted row
// 3. resolved-slot read + streak fetch (with `OR`) → empty
vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation(((
args: unknown,
) => {
const a = args as {
where: { OR?: unknown };
select?: { id?: boolean };
select?: { medicationId?: boolean };
};
if (a.where.OR) return Promise.resolve([]) as never;
// No `id` field on the existence-probe `select` → the route
// only asked for `(medicationId, scheduledFor)`.
if (!a.select?.id) return Promise.resolve([]) as never;
// The existence probe asks for `(medicationId, scheduledFor)`;
// the tally read asks for `(takenAt, skipped)`.
if (a.select?.medicationId) return Promise.resolve([]) as never;
return Promise.resolve([
{
id: "e-new",
takenAt: null,
skipped: false,
},
@@ -712,14 +727,9 @@ describe("GET /api/dashboard/summary", () => {
],
},
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation(((
args: unknown,
) => {
const a = args as { where: { OR?: unknown }; select?: { id?: boolean } };
if (a.where.OR) return Promise.resolve([]) as never;
if (!a.select?.id) return Promise.resolve([]) as never;
return Promise.resolve([]) as never;
}) as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({
count: 2,
} as never);
@@ -783,18 +793,17 @@ describe("GET /api/dashboard/summary", () => {
) => {
const a = args as {
where: { OR?: unknown };
select?: { id?: boolean };
select?: { medicationId?: boolean };
};
if (a.where.OR) return Promise.resolve([]) as never;
if (!a.select?.id) {
if (a.select?.medicationId) {
// Existence probe — the projected slot already exists.
return Promise.resolve([
{ medicationId: "med-ramipril", scheduledFor },
]) as never;
}
return Promise.resolve([
{ id: "e-existing", takenAt: null, skipped: false },
]) as never;
// Today tally read — the pre-existing pending row.
return Promise.resolve([{ takenAt: null, skipped: false }]) as never;
}) as never);
const res = await callGet(makeReq());
@@ -809,14 +818,16 @@ describe("GET /api/dashboard/summary", () => {
it("computes intake compliance for today", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// No active medications (default) → the projector early-outs, so
// the only no-`OR` read is the meds-today tally over the window.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation(((
args: unknown,
) => {
const a = args as { where: { OR?: unknown } };
if (!a.where.OR) {
return Promise.resolve([
{ id: "e1", takenAt: new Date(), skipped: false },
{ id: "e2", takenAt: null, skipped: false },
{ takenAt: new Date(), skipped: false },
{ takenAt: null, skipped: false },
]) as never;
}
return Promise.resolve([]) as never;
+15 -65
View File
@@ -56,7 +56,7 @@ import { getServerTranslator } from "@/lib/i18n/server-translator";
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today";
import {
summarizeSleepNights,
reconstructSleepNights,
@@ -211,20 +211,6 @@ function trendOf(values: number[]): MetricCard["trend"] {
return delta > 0 ? "up" : "down";
}
function startOfDayInTz(date: Date, tz: string): Date {
// Compute midnight in the user's tz → UTC ms.
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const y = parts.find((p) => p.type === "year")?.value ?? "1970";
const m = parts.find((p) => p.type === "month")?.value ?? "01";
const d = parts.find((p) => p.type === "day")?.value ?? "01";
return new Date(`${y}-${m}-${d}T00:00:00.000Z`);
}
interface StreakInfo {
currentDays: number;
longest: number;
@@ -373,41 +359,6 @@ function buildContext(
return { greetingName, locale };
}
/**
* v1.4.39 W-SERVER-FIX-2 — project today's active schedules, idempotently
* backfill any missing `MedicationIntakeEvent` rows via the shared
* helper, then re-read the today-window so the caller sees both
* pre-existing rows + the freshly minted ones.
*
* Mirrors the behaviour of `/api/medications/intake?scope=today` (same
* helper) so the iOS Dashboard tile (fed by this route) and the iOS
* Erfassen sheet (fed by the intake route) converge on the same row
* set the moment a daily med becomes active.
*/
async function projectAndReadTodaysIntakes(
userId: string,
userTz: string,
todayStart: Date,
todayEnd: Date,
): Promise<Array<{ id: string; takenAt: Date | null; skipped: boolean }>> {
await projectTodayIntakesAndRecompute({
userId,
userTz,
todayStart,
todayEnd,
});
return prisma.medicationIntakeEvent.findMany({
where: {
userId,
// v1.7.0 sync — exclude tombstoned rows from the today tile.
deletedAt: null,
scheduledFor: { gte: todayStart, lt: todayEnd },
},
select: { id: true, takenAt: true, skipped: true },
});
}
async function buildDashboardSummary(
userId: string,
userTz: string,
@@ -421,8 +372,6 @@ async function buildDashboardSummary(
const streakWindowStart = new Date(
now.getTime() - STREAK_WINDOW_DAYS * 86_400_000,
);
const todayStart = startOfDayInTz(now, userTz);
const todayEnd = new Date(todayStart.getTime() + 86_400_000);
// Derived from canonical enum so a new measurement type is auto-included
// (V3 audit: enum drift cousins). Per-kind display blocks below decide
@@ -453,14 +402,15 @@ async function buildDashboardSummary(
// REG-11: taken via ROW_NUMBER window, no calendar
// filter, so a 60-day-old metric still paints a chart)
// - allTimeAggregate: ≤ N metric types (unchanged)
// - todaysIntakes: ≤ daily intake schedule count (unchanged)
// - medsToday: today-window tally via the shared projector
// (≤ daily intake schedule count, unchanged semantics)
// - streakActivity: ≤ daily intake count × 365 (unchanged)
// - measurementStreakDays: ≤ 365 (was: every raw row in 365d)
const [
latestEver,
sparkBuckets,
allTimeAggregate,
todaysIntakes,
medsToday,
streakActivity,
measurementStreakDays,
sleepStageRows,
@@ -513,13 +463,12 @@ async function buildDashboardSummary(
// reminder worker entered the RED phase at the end of the dose
// window, so the iOS Dashboard tile fell to "Heute nichts geplant"
// even when the intake route (post-`expandTodayIntakes` fix) was
// already projecting + backfilling correctly. Re-uses the same
// helper + idempotent `createMany` (with `skipDuplicates: true`
// so a concurrent intake-route hit can't race a duplicate row in
// before the existence probe converges).
time("todaysIntakes", () =>
projectAndReadTodaysIntakes(userId, userTz, todayStart, todayEnd),
),
// already projecting + backfilling correctly. The shared
// `buildMedsTodayBlock` runs the same idempotent projection
// (`skipDuplicates: true` so a concurrent intake-route hit can't
// race a duplicate row in) before its today-window tally, so this
// route and the dashboard snapshot count the identical row set.
time("medsToday", () => buildMedsTodayBlock(prisma, userId, userTz, now)),
time("streakIntakes", () =>
prisma.medicationIntakeEvent.findMany({
where: {
@@ -887,10 +836,11 @@ async function buildDashboardSummary(
});
}
const scheduledToday = todaysIntakes.length;
const takenToday = todaysIntakes.filter(
(e) => e.takenAt !== null && !e.skipped,
).length;
// Shared builder carries the exact tally semantics this tile always
// had (scheduled = every today-window row; taken = `takenAt` set and
// not skipped). The wire shape of `compliance` is locked for iOS.
const scheduledToday = medsToday.scheduledToday;
const takenToday = medsToday.takenToday;
const t = getServerTranslator(ctx.locale).t;
@@ -399,6 +399,132 @@ describe("PUT /api/dashboard/widgets — accept-and-ignore unknown ids (v1.7.0 #
const callGet = GET as unknown as () => Promise<Response>;
describe("PUT /api/dashboard/widgets — heroVisible thread-through", () => {
const knownId = DASHBOARD_WIDGET_IDS[0];
it("persists an explicit heroVisible: false WITHOUT dropping stored chartOverlayPrefs", async () => {
// The regression class this route already guards for
// `chartOverlayPrefs`: a layout PUT that omits a sibling field must
// not wipe it. heroVisible sent + prefs omitted → prefs merge from
// the stored blob, heroVisible persists from the body.
const storedPrefs = {
bp: {
showTrendIndicator: true,
showTrendArrow: false,
showTargetRange: true,
comparisonBaseline: "lastMonth" as const,
},
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: serializeDashboardLayout({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
chartOverlayPrefs: storedPrefs,
}),
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
heroVisible: false,
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: {
dashboardWidgetsJson: {
heroVisible: boolean;
chartOverlayPrefs: Record<string, unknown>;
};
};
};
expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false);
expect(updateArg.data.dashboardWidgetsJson.chartOverlayPrefs).toEqual(
storedPrefs,
);
// The response echoes the persisted value.
const body = (await res.json()) as { data: { heroVisible: boolean } };
expect(body.data.heroVisible).toBe(false);
});
it("preserves a stored heroVisible: false when the PUT omits the field", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: serializeDashboardLayout({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
heroVisible: false,
}),
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { heroVisible: boolean } };
};
expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false);
});
it("defaults heroVisible to true for a first save with no stored layout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { heroVisible: boolean } };
};
expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(true);
});
it("422s on a non-boolean heroVisible", async () => {
const res = await callPut(
makeReq({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
heroVisible: "yes",
}),
);
expect(res.status).toBe(422);
});
it("GET resolves heroVisible alongside the widget layout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: serializeDashboardLayout({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
heroVisible: false,
}),
} as never);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { heroVisible: boolean } };
expect(body.data.heroVisible).toBe(false);
});
});
describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => {
it("PUT of a full 27-id layout persists every id (iOS-only round-trip)", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
+22 -5
View File
@@ -115,6 +115,12 @@ const layoutSchema = z.object({
}),
)
.optional(),
// Dashboard hero (daily verdict) visibility. Optional so clients
// that predate the field can still PUT; when omitted the handler
// preserves the stored value (same preserve-when-absent contract as
// `chartOverlayPrefs` — a Settings save that doesn't know the field
// must not silently reset the user's choice).
heroVisible: z.boolean().optional(),
});
async function buildDashboardLayout(userId: string): Promise<DashboardLayout> {
@@ -258,21 +264,32 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// didn't send. The dashboard-layout PUT typically saves widget
// visibility / order; chart prefs are PUT through their own route
// (`/api/dashboard/chart-overlay-prefs`) and would otherwise be
// wiped here on a subsequent layout save.
// wiped here on a subsequent layout save. `heroVisible` rides the
// same preserve-when-absent contract — an older client's layout
// save must not reset the hero toggle. One stored-layout read
// covers both fallbacks.
let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined = parsed.data
.chartOverlayPrefs as ChartOverlayPrefsMap | undefined;
if (mergedChartOverlayPrefs === undefined) {
let mergedHeroVisible: boolean | undefined = parsed.data.heroVisible;
if (mergedChartOverlayPrefs === undefined || mergedHeroVisible === undefined) {
const existing = await prisma.user.findUnique({
where: { id: user.id },
select: { dashboardWidgetsJson: true },
});
mergedChartOverlayPrefs =
resolveDashboardLayout(existing?.dashboardWidgetsJson)
.chartOverlayPrefs ?? {};
const existingLayout = resolveDashboardLayout(
existing?.dashboardWidgetsJson,
);
if (mergedChartOverlayPrefs === undefined) {
mergedChartOverlayPrefs = existingLayout.chartOverlayPrefs ?? {};
}
if (mergedHeroVisible === undefined) {
mergedHeroVisible = existingLayout.heroVisible !== false;
}
}
const normalized = serializeDashboardLayout({
...parsed.data,
chartOverlayPrefs: mergedChartOverlayPrefs,
heroVisible: mergedHeroVisible,
} as DashboardLayout);
await prisma.user.update({
@@ -12,7 +12,9 @@ vi.mock("@/lib/db", () => ({
},
medicationIntakeEvent: {
findFirst: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
medication: {
findFirst: vi.fn(),
@@ -38,6 +40,9 @@ vi.mock("@/lib/app-settings", () => ({
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
@@ -55,6 +60,7 @@ import { POST } from "../route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/ingest/medication", {
@@ -145,3 +151,156 @@ describe("POST /api/ingest/medication — 422 multi-issue (v1.4.43 W6)", () => {
expect(res.status).toBe(422);
});
});
describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.9)", () => {
// The maintainer's live bug: a daily 09:00 med, the worker pre-minted the
// pending REMINDER row at 09:00 Berlin, the user ingested the take at
// 08:42 Berlin via the external API. The bare create anchored a SECOND
// row at 08:42 and left the 09:00 pending row open — reminder kept
// firing, ledger showed ad-hoc + later missed, today feed said "due".
const SLOT_0900_BERLIN = new Date("2026-06-10T07:00:00.000Z"); // 09:00 CEST
const TAKEN_0842_BERLIN = new Date("2026-06-10T06:42:00.000Z"); // 08:42 CEST
const scheduleRow = {
id: "sched-1",
windowStart: "09:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["09:00"],
reminderGraceMinutes: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
};
const pendingReminderRow = {
id: "evt-pending-0900",
takenAt: null,
skipped: false,
idempotencyKey: null,
scheduledFor: SLOT_0900_BERLIN,
source: "REMINDER",
createdAt: new Date("2026-06-10T05:00:00.000Z"),
};
function wireHappyPath() {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "tok-1",
userId: "user-1",
permissions: ["*"],
revoked: false,
expiresAt: null,
lastUsedAt: null,
} as never);
vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
// First findFirst (by name) returns the bare medication; the slot
// resolver's findFirst (selects schedules) returns the projection.
vi.mocked(prisma.medication.findFirst).mockImplementation(((args: {
where: Record<string, unknown>;
}) => {
if (args.where && "name" in args.where) {
return Promise.resolve({ id: "med-1", name: "Metformin" });
}
return Promise.resolve({
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
schedules: [scheduleRow],
scheduleRevisions: [],
});
}) as never);
// Idempotency probe → no replay.
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(null);
// The slot's live rows: the worker-minted pending REMINDER row.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
pendingReminderRow,
] as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockImplementation(((args: {
where: { id: string };
data: Record<string, unknown>;
}) =>
Promise.resolve({
...pendingReminderRow,
...args.data,
syncVersion: 2,
})) as never);
}
it("converges an 08:42 take onto the pending 09:00 REMINDER row (no second row)", async () => {
wireHappyPath();
const res = await POST(
postReq({
medicationName: "Metformin",
takenAt: TAKEN_0842_BERLIN.toISOString(),
idempotencyKey: "ha-ingest-1",
}),
);
expect(res.status).toBe(201);
const body = (await res.json()) as { data: { id: string } };
// The pending REMINDER row was updated in place — never a bare create.
expect(body.data.id).toBe("evt-pending-0900");
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
const update = vi.mocked(prisma.medicationIntakeEvent.update).mock
.calls[0][0] as {
where: { id: string };
data: { takenAt: Date; skipped: boolean; autoMissed?: boolean };
};
expect(update.where.id).toBe("evt-pending-0900");
expect(update.data.takenAt?.getTime()).toBe(TAKEN_0842_BERLIN.getTime());
expect(update.data.skipped).toBe(false);
// A recorded take clears a prior cron auto-miss.
expect(update.data.autoMissed).toBe(false);
});
it("hard-evicts the user's medication caches after the write", async () => {
wireHappyPath();
await POST(
postReq({
medicationName: "Metformin",
takenAt: TAKEN_0842_BERLIN.toISOString(),
idempotencyKey: "ha-ingest-2",
}),
);
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("keeps the standalone ad-hoc contract for an off-window take", async () => {
wireHappyPath();
// 14:30 Berlin — outside the 09:00 band and its late tail.
const offWindow = new Date("2026-06-10T12:30:00.000Z");
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({
id: "evt-adhoc",
scheduledFor: offWindow,
takenAt: offWindow,
skipped: false,
source: "API",
} as never);
const res = await POST(
postReq({
medicationName: "Metformin",
takenAt: offWindow.toISOString(),
idempotencyKey: "ha-ingest-3",
}),
);
expect(res.status).toBe(201);
expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
const create = vi.mocked(prisma.medicationIntakeEvent.create).mock
.calls[0][0] as { data: { scheduledFor: Date; takenAt: Date } };
// Ad-hoc rows anchor on the intake instant (`scheduledFor = takenAt`).
expect(create.data.scheduledFor.getTime()).toBe(offWindow.getTime());
expect(create.data.takenAt.getTime()).toBe(offWindow.getTime());
});
});
+73 -14
View File
@@ -15,6 +15,12 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { externalIntakeSchema } from "@/lib/validations/medication";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import {
applyCanonicalSlotWrite,
resolveSlotForWriteByBand,
} from "@/lib/medications/scheduling/slot-upsert";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { NextRequest, NextResponse } from "next/server";
/**
@@ -159,17 +165,64 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("API endpoint for this medication is disabled", 403);
}
const event = await prisma.medicationIntakeEvent.create({
data: {
// The external ingest path runs without a User record in scope;
// resolve the timezone once — band attribution and the compliance
// recompute below both anchor on the user's local day.
const ingestUser = await prisma.user.findUnique({
where: { id: apiToken.userId },
select: { timezone: true },
});
const userTz = ingestUser?.timezone || DEFAULT_TIMEZONE;
const effectiveTakenAt = takenAt ?? new Date();
// v1.16.9 — attribute the take to its scheduled slot by window-band
// membership, the SAME engine the per-medication intake route and the
// read ledger consume. The bare create this replaces anchored every
// ingest at `scheduledFor = takenAt`, leaving the slot's pending
// REMINDER row open: the reminder kept firing, the ledger showed an
// ad-hoc take PLUS a missed slot, and the today feed still said "due".
const attribution = await resolveSlotForWriteByBand({
userId: apiToken.userId,
medicationId: medication.id,
userTz,
takenAt: effectiveTakenAt,
});
let event;
if (attribution.slotInstant) {
// Scheduled dose — converge onto the one canonical slot row (the
// pending REMINDER row when one exists) through the shared upsert:
// H1 deterministic selection, C2 no-downgrade, P2002-safe create.
const applied = await applyCanonicalSlotWrite({
client: prisma,
userId: apiToken.userId,
medicationId: medication.id,
scheduledFor: takenAt ?? new Date(),
takenAt: takenAt ?? new Date(),
canonicalSlot: attribution.slotInstant,
takenAt: effectiveTakenAt,
skipped: false,
source: "API",
isExplicitTaken: true,
isExplicitSkip: false,
idempotencyKey,
},
});
createSource: "API",
attributionSource: "AUTO",
});
event = applied.row;
} else {
// Ad-hoc / PRN / off-window — standalone row under the documented
// contract (`scheduledFor = takenAt`).
event = await prisma.medicationIntakeEvent.create({
data: {
userId: apiToken.userId,
medicationId: medication.id,
scheduledFor: effectiveTakenAt,
takenAt: effectiveTakenAt,
skipped: false,
source: "API",
idempotencyKey,
},
});
}
await auditLog("medication.ingest.external", {
userId: apiToken.userId,
@@ -181,15 +234,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
annotate({ meta: { medication_id: medication.id, event_id: event.id } });
annotate({
meta: {
medication_id: medication.id,
event_id: event.id,
slot_resolved: attribution.slotInstant !== null,
},
});
// v1.16.9 — the ingest is an interactive dose record; the next read
// (today feed, card pill, compliance heatmap) must reflect it rather
// than wait out the TTL.
invalidateUserMedications(apiToken.userId, { evict: true });
// v1.4.39 W-MED — refresh the compliance rollup for the ingested
// event's user-day. The external ingest path runs without a User
// record in scope; resolve the tz once for the recompute.
const ingestUser = await prisma.user.findUnique({
where: { id: apiToken.userId },
select: { timezone: true },
});
// event's user-day.
await recomputeMedicationComplianceForEvent({
userId: apiToken.userId,
medicationId: medication.id,
@@ -264,6 +264,11 @@ export const PUT = apiHandler(
idempotencyKey: null,
createSource: "WEB",
attributionSource,
// v1.16.9 — a slot move is a re-binding, not a new dose: the
// original row's recorded injection site and dose override ride
// along onto the converged row instead of being tombstoned away.
injectionSite: event.injectionSite,
doseTaken: event.doseTaken,
});
updated = applied.row;
}
+23 -8
View File
@@ -179,14 +179,29 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
// Skips carry no binding decision (undefined → column untouched/default).
let attributionSource: "AUTO" | "USER_PIN" | undefined;
if (skipped) {
canonicalSlot = await resolveSlotInstantForWrite({
userId: user.id,
medicationId: id,
userTz: user.timezone,
incoming: incomingScheduledFor,
instantIsExplicit: scheduledFor !== undefined,
isTakenWrite: false,
});
if (scheduledFor === undefined) {
// v1.16.9 — a slot-less deliberate skip resolves by band membership
// on the skip moment, like a take does. The defaulted-now snap only
// reached the tight reminder-grace window, so a skip tapped past it
// landed as an orphan ad-hoc row while the slot's pending REMINDER
// row stayed open to auto-miss.
const attribution = await resolveSlotForWriteByBand({
userId: user.id,
medicationId: id,
userTz: user.timezone,
takenAt: incomingScheduledFor,
});
canonicalSlot = attribution.slotInstant;
} else {
canonicalSlot = await resolveSlotInstantForWrite({
userId: user.id,
medicationId: id,
userTz: user.timezone,
incoming: incomingScheduledFor,
instantIsExplicit: true,
isTakenWrite: false,
});
}
} else if (forceSlotInstant !== undefined) {
canonicalSlot = await resolveForcedSlotForWrite({
userId: user.id,
+5 -2
View File
@@ -25,6 +25,7 @@ import type { Prisma } from "@/generated/prisma/client";
import {
computeDisplayDue,
OVERDUE_LOOKBACK_MS,
toResolvedSlotMark,
} from "@/lib/medications/scheduling/next-due";
import {
dayKeyForScheduledFor,
@@ -137,7 +138,9 @@ export const GET = apiHandler(
{ autoMissed: true },
],
},
select: { scheduledFor: true },
// v1.16.9 — `takenAt` rides along so an ad-hoc row (`scheduledFor
// === takenAt`) cannot ±6h-resolve a different slot.
select: { scheduledFor: true, takenAt: true },
});
// v1.16.4 — current-era floor: the open-overdue search mints from
// the LIVE schedule rows, so it must not reach past the newest
@@ -161,7 +164,7 @@ export const GET = apiHandler(
now,
userTz: user.timezone || "Europe/Berlin",
lastIntakeAt: lastIntake?.takenAt ?? null,
resolvedSlots: resolvedRows.map((r) => r.scheduledFor),
resolvedSlots: resolvedRows.map(toResolvedSlotMark),
eraStart: latestRevision?.validUntil ?? null,
});
@@ -39,6 +39,7 @@ import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { scheduleRevisionUpdateSchema } from "@/lib/validations/schedule-revision";
import { toRevisionPayloadEntry } from "@/lib/medications/scheduling/schedule-eras";
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import type { Prisma } from "@/generated/prisma/client";
type RouteParams = { params: Promise<{ id: string; revisionId: string }> };
@@ -249,6 +250,10 @@ export const PATCH = apiHandler(
// The corrected era re-segments history; refresh the pre-aggregated
// compliance rollups asynchronously (best-effort).
// v1.16.9 — an era write re-segments the bands every cached payload
// (list next-due, compliance cells, dashboard tally) was built on;
// hard-evict so the next read reflects the new history immediately.
invalidateUserMedications(user.id, { evict: true });
await enqueueUserMedicationComplianceBackfill(user.id);
return apiSuccess({
@@ -321,6 +326,10 @@ export const DELETE = apiHandler(
// History re-segments without the era; refresh the pre-aggregated
// compliance rollups asynchronously (best-effort).
// v1.16.9 — an era write re-segments the bands every cached payload
// (list next-due, compliance cells, dashboard tally) was built on;
// hard-evict so the next read reflects the new history immediately.
invalidateUserMedications(user.id, { evict: true });
await enqueueUserMedicationComplianceBackfill(user.id);
return apiSuccess({ deleted: true });
@@ -40,6 +40,7 @@ import {
type ScheduleRevisionEntry,
} from "@/lib/medications/scheduling/schedule-eras";
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import type { Prisma } from "@/generated/prisma/client";
type RouteParams = { params: Promise<{ id: string }> };
@@ -280,6 +281,10 @@ export const POST = apiHandler(
// The new era re-segments history the compliance rollups already
// pre-aggregated; refresh them asynchronously. Best-effort like
// every other rollup write-hook.
// v1.16.9 — an era write re-segments the bands every cached payload
// (list next-due, compliance cells, dashboard tally) was built on;
// hard-evict so the next read reflects the new history immediately.
invalidateUserMedications(user.id, { evict: true });
await enqueueUserMedicationComplianceBackfill(user.id);
return apiSuccess(
@@ -10,6 +10,13 @@ vi.mock("@/lib/db", () => ({
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn(),
},
medicationIntakeEvent: {
groupBy: vi.fn().mockResolvedValue([]),
findMany: vi.fn().mockResolvedValue([]),
},
medicationScheduleRevision: {
groupBy: vi.fn().mockResolvedValue([]),
},
medicationCategoryAssignment: {
findMany: vi.fn().mockResolvedValue([]),
},
@@ -59,9 +66,11 @@ vi.mock("next/headers", () => ({
})),
}));
import { POST } from "../route";
import { GET, POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { cachedSwr } from "@/lib/cache/server-cache";
import { getUserTodayBounds } from "@/lib/tz/local-day";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -353,3 +362,52 @@ describe("POST /api/medications — v1.5.4 clinical category extension", () => {
expect(res.status).toBe(422);
});
});
describe("GET /api/medications — todayEventCount counts only actioned rows (v1.16.9)", () => {
it("filters the today-count groupBy to taken-or-skipped rows", async () => {
// The dashboard projector mints a pending row per slot of the day, so
// an unfiltered count covered every passed dose after any dashboard
// visit and the cards' overdue pill went dark. The count read must
// exclude pending rows.
// (`vi.resetAllMocks()` in beforeEach clears the factory defaults, so
// re-wire the pass-through + empty result sets here.)
vi.mocked(cachedSwr).mockImplementation((async (
_c: unknown,
_k: string,
f: () => Promise<unknown>,
) => f()) as never);
vi.mocked(getUserTodayBounds).mockReturnValue({
start: new Date("2026-06-10T00:00:00Z"),
end: new Date("2026-06-10T23:59:59Z"),
} as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.groupBy).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medicationScheduleRevision.groupBy).mockResolvedValue(
[] as never,
);
// The handler body takes no parameters; `apiHandler` still receives
// the request through the wrapper's rest args.
const res = await (
GET as unknown as (req: NextRequest) => Promise<Response>
)(new NextRequest("http://localhost/api/medications"));
expect(res.status).toBe(200);
const groupByCalls = vi.mocked(prisma.medicationIntakeEvent.groupBy).mock
.calls;
const countCall = groupByCalls.find(
(c) => (c[0] as { _count?: unknown })._count !== undefined,
);
expect(countCall).toBeDefined();
const where = (
countCall![0] as {
where: { OR?: unknown };
}
).where;
expect(where.OR).toEqual([{ takenAt: { not: null } }, { skipped: true }]);
});
});
+19 -4
View File
@@ -59,6 +59,7 @@ import {
} from "@/lib/medications/scheduling/slot-upsert";
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
import {
boundedTakenAtSchema,
injectionSiteEnum,
type InjectionSiteValue,
} from "@/lib/validations/medication";
@@ -74,10 +75,9 @@ const bulkEntrySchema = z.object({
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
takenAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
// v1.16.9 — shared plausibility bounds (no future beyond clock skew,
// 5-year floor), matching the single-intake create + edit paths.
takenAt: boundedTakenAtSchema.optional(),
skipped: z.boolean().optional().default(false),
idempotencyKey: z.string().min(1).max(128).optional(),
// v1.8.5 — optional per-entry injection site. Validated against the
@@ -313,6 +313,21 @@ async function postBulk(request: NextRequest): Promise<Response> {
});
canonicalSlot = attribution.slotInstant;
attributionSource = "AUTO";
} else if (isExplicitSkip && entry.scheduledFor === undefined) {
// v1.16.9 — a slot-less deliberate skip resolves by band
// membership on the skip moment, exactly like a take does. The
// defaulted-now snap below only reaches the tight reminder-grace
// window, so a skip tapped 20 minutes past the dose time inserted
// an orphan ad-hoc skip row while the slot's pending REMINDER row
// stayed open and later auto-missed — the user's deliberate skip
// recorded as a miss.
const attribution = await resolveSlotForWriteByBand({
userId: user.id,
medicationId: entry.medicationId,
userTz: user.timezone,
takenAt: incomingScheduledFor,
});
canonicalSlot = attribution.slotInstant;
} else {
// v1.8.2 — source-agnostic anchor snap for pending echoes + skips.
// iOS posts the reminder actions here (source API); without this
+20 -1
View File
@@ -560,7 +560,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
isExplicitSkip: false,
idempotencyKey: null,
createSource: "WEB",
injectionSite: resolvedInjectionSite,
// v1.16.9 — carry the recorded site / dose override across the
// tombstone + re-insert: a slot move is a re-binding, not a new
// dose, so the original row's documentation must survive when the
// toggle write itself carries none.
injectionSite:
resolvedInjectionSite ??
(existing.injectionSite as InjectionSiteValue | null),
doseTaken: existing.doseTaken,
});
updated = applied.row;
await prisma.medication.update({
@@ -611,6 +618,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
scheduledFor: existing.scheduledFor,
tz: userTzForHook,
});
// v1.16.9 — a slot move re-binds the dose onto a DIFFERENT instant,
// possibly a different local day. Recompute the target day too, or the
// source day self-corrects while the destination keeps its stale tuple.
const movedTo = updated?.scheduledFor;
if (movedTo && movedTo.getTime() !== existing.scheduledFor.getTime()) {
await recomputeMedicationComplianceForEvent({
userId: user.id,
medicationId: existing.medicationId,
scheduledFor: movedTo,
tz: userTzForHook,
});
}
return apiSuccess(updated);
});
+18 -4
View File
@@ -18,6 +18,8 @@ import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
import {
computeDisplayDue,
OVERDUE_LOOKBACK_MS,
toResolvedSlotMark,
type ResolvedSlotMark,
} from "@/lib/medications/scheduling/next-due";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
@@ -73,10 +75,17 @@ async function buildMedicationsList(
prisma.medicationIntakeEvent.groupBy({
by: ["medicationId"],
// v1.7.0 sync — exclude tombstoned rows from the today-count map.
// v1.16.9 — count only ACTIONED rows (taken or skipped). The
// dashboard projector mints pending rows for every slot of the
// day, so counting all rows made `todayEventCount` cover every
// passed dose after any dashboard visit — and the cards'
// overdue-pill suppression (`todayEventCount < passedDoseCount`)
// went dark nondeterministically for genuinely overdue doses.
where: {
userId,
deletedAt: null,
scheduledFor: { gte: todayStartUtc, lte: todayEndUtc },
OR: [{ takenAt: { not: null } }, { skipped: true }],
},
_count: { id: true },
}),
@@ -91,7 +100,11 @@ async function buildMedicationsList(
{ autoMissed: true },
],
},
select: { medicationId: true, scheduledFor: true },
// v1.16.9 — `takenAt` rides along so the ad-hoc shape
// (`scheduledFor === takenAt`) is detectable: such a row must not
// ±6h-resolve a DIFFERENT slot (a 14:30 ad-hoc take hid tonight's
// genuinely-due 20:00 dose).
select: { medicationId: true, scheduledFor: true, takenAt: true },
}),
// v1.16.4 — current-era floor per medication: the newest revision's
// `validUntil` is where the LIVE schedule rows became valid. The
@@ -106,11 +119,12 @@ async function buildMedicationsList(
}),
]);
const resolvedSlotsByMedId = new Map<string, Date[]>();
const resolvedSlotsByMedId = new Map<string, ResolvedSlotMark[]>();
for (const e of resolvedEvents) {
const mark = toResolvedSlotMark(e);
const list = resolvedSlotsByMedId.get(e.medicationId);
if (list) list.push(e.scheduledFor);
else resolvedSlotsByMedId.set(e.medicationId, [e.scheduledFor]);
if (list) list.push(mark);
else resolvedSlotsByMedId.set(e.medicationId, [mark]);
}
const eraStartByMedId = new Map<string, Date>();
+5
View File
@@ -4,6 +4,7 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { invalidateUserData } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -119,5 +120,9 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "settings.data.clear" }, meta: result });
// v1.16.9 — every cached payload was built on the rows just deleted;
// hard-evict the user's buckets so no surface serves pre-wipe data.
invalidateUserData(userId);
return apiSuccess({ cleared: true, ...result });
});
@@ -18,9 +18,12 @@ vi.mock("@/lib/db", () => ({
medicationIntakeEvent: {
create: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
telegramReminderMessage: {
deleteMany: vi.fn(),
findFirst: vi.fn(),
},
telegramScheduledDeletion: {
createMany: vi.fn(),
@@ -52,8 +55,17 @@ vi.mock("@/lib/logging/context", () => ({
})),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
}));
import { POST, GET } from "../route";
import { prisma } from "@/lib/db";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import {
answerTelegramCallbackQuery,
deleteMessage,
@@ -67,10 +79,40 @@ const TG_USER = {
id: "user-1",
telegramBotToken: "ENC(token-blob)",
locale: "en",
timezone: "Europe/Berlin",
};
const MEDICATION = { id: "med-1", name: "Ramipril" };
/**
* The slot-attribution load (`loadAttributeMedication`) selects the full
* schedule projection (distinguishable by `select.schedules`); every other
* `medication.findFirst` in the webhook wants the bare id/name shape. The
* default projection carries no schedules, so the generic dispatch tests
* exercise the ad-hoc (standalone create) path unchanged.
*/
function wireMedicationFindFirst(
schedules: unknown[] = [],
scheduleRevisions: unknown[] = [],
) {
vi.mocked(prisma.medication.findFirst).mockImplementation(((args: {
select?: Record<string, unknown>;
}) => {
if (args?.select && "schedules" in args.select) {
return Promise.resolve({
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
schedules,
scheduleRevisions,
});
}
return Promise.resolve(MEDICATION);
}) as never);
}
beforeEach(() => {
vi.resetAllMocks();
process.env.TELEGRAM_WEBHOOK_SECRET = "sekret";
@@ -80,18 +122,28 @@ beforeEach(() => {
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(prisma.user.findFirst).mockResolvedValue(TG_USER as never);
vi.mocked(prisma.medication.findFirst).mockResolvedValue(MEDICATION as never);
wireMedicationFindFirst();
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medication.update).mockResolvedValue({} as never);
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(null);
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.telegramReminderMessage.deleteMany).mockResolvedValue({
count: 0,
} as never);
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue(null);
vi.mocked(prisma.telegramScheduledDeletion.createMany).mockResolvedValue({
count: 0,
} as never);
vi.mocked(prisma.$transaction).mockResolvedValue([] as never);
// Interactive transactions run their callback against the same mock
// client (the route converges the intake write + snooze update inside
// one transaction); batch arrays resolve like Promise.all.
vi.mocked(prisma.$transaction).mockImplementation(((arg: unknown) =>
typeof arg === "function"
? (arg as (tx: unknown) => unknown)(prisma)
: Promise.all(arg as Promise<unknown>[])) as never);
vi.mocked(answerTelegramCallbackQuery).mockResolvedValue(undefined as never);
vi.mocked(deleteMessage).mockResolvedValue(undefined as never);
vi.mocked(sendTelegramMessage).mockResolvedValue({
@@ -185,15 +237,7 @@ describe("Telegram webhook — callback dispatch", () => {
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(res.status).toBe(200);
// $transaction is invoked with [create-intake, update-medication]. Inspect
// its argument list for both writes.
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
const txOps = vi.mocked(prisma.$transaction).mock.calls[0][0] as unknown;
expect(Array.isArray(txOps)).toBe(true);
// The route calls prisma.medicationIntakeEvent.create + medication.update
// synchronously while building the transaction array, so these mocks
// record the arguments the way it built them.
// No schedules wired → the take is ad-hoc and inserts standalone.
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
const intakeArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock
.calls[0][0];
@@ -211,6 +255,8 @@ describe("Telegram webhook — callback dispatch", () => {
where: { id: "med-1" },
data: { snoozedUntil: null },
});
// Intake write + snooze reset commit atomically.
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(answerTelegramCallbackQuery).toHaveBeenCalledTimes(1);
expect(deleteMessage).toHaveBeenCalledWith(
@@ -226,7 +272,7 @@ describe("Telegram webhook — callback dispatch", () => {
null,
);
await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
// Second call: existing-row lookup hits → no second write.
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce({
@@ -234,7 +280,7 @@ describe("Telegram webhook — callback dispatch", () => {
} as never);
const res2 = await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(res2.status).toBe(200);
expect(prisma.$transaction).toHaveBeenCalledTimes(1); // unchanged
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); // unchanged
});
it("'snooze:<medId>:60' updates the medication's snoozedUntil and answers with the locale-translated message", async () => {
@@ -281,6 +327,8 @@ describe("Telegram webhook — callback dispatch", () => {
.snoozedUntil;
expect(snoozedUntil.getHours()).toBe(23);
expect(snoozedUntil.getMinutes()).toBe(59);
// Skip row + rest-of-day snooze commit atomically.
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
});
it("'ack:<medId>' answers the callback without creating an intake event", async () => {
@@ -599,6 +647,159 @@ describe("Telegram webhook — text-message dispatch", () => {
});
});
describe("Telegram webhook — slot convergence (v1.16.9)", () => {
// Daily 09:00 Berlin med; the worker pre-minted the pending REMINDER row
// at the canonical slot instant. A Telegram take/skip must converge onto
// that row instead of inserting a second row anchored at `now` — the old
// shape left the pending row open to auto-miss, punishing compliance for
// a dose the user explicitly confirmed.
const SLOT_0900_BERLIN = new Date("2026-06-10T07:00:00.000Z"); // 09:00 CEST
const NOW_0903_BERLIN = new Date("2026-06-10T07:03:00.000Z"); // 09:03 CEST
const scheduleRow = {
id: "sched-1",
windowStart: "09:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["09:00"],
reminderGraceMinutes: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
};
const pendingReminderRow = {
id: "evt-pending-0900",
takenAt: null,
skipped: false,
idempotencyKey: null,
scheduledFor: SLOT_0900_BERLIN,
source: "REMINDER",
createdAt: new Date("2026-06-10T05:00:00.000Z"),
};
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW_0903_BERLIN);
wireMedicationFindFirst([scheduleRow]);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
pendingReminderRow,
] as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockImplementation(((args: {
data: Record<string, unknown>;
}) =>
Promise.resolve({
...pendingReminderRow,
...args.data,
syncVersion: 2,
})) as never);
});
afterEach(() => {
vi.useRealTimers();
});
it("a take converges onto the pending REMINDER row via the reminder's own slot", async () => {
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({
date: "2026-06-10",
timeOfDay: "09:00",
} as never);
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(res.status).toBe(200);
// The pending row was updated in place — never a duplicate insert.
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
const update = vi.mocked(prisma.medicationIntakeEvent.update).mock
.calls[0][0] as {
where: { id: string };
data: { takenAt: Date; skipped: boolean; autoMissed?: boolean };
};
expect(update.where.id).toBe("evt-pending-0900");
expect(update.data.takenAt?.getTime()).toBe(NOW_0903_BERLIN.getTime());
expect(update.data.skipped).toBe(false);
expect(update.data.autoMissed).toBe(false);
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("a take converges via band attribution when no reminder row matches", async () => {
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue(null);
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(res.status).toBe(200);
// 09:03 sits inside the 09:00 band → same convergence, no second row.
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
expect(
(
vi.mocked(prisma.medicationIntakeEvent.update).mock.calls[0][0] as {
where: { id: string };
}
).where.id,
).toBe("evt-pending-0900");
});
it("a skip converges onto the pending REMINDER row as skipped (no orphan + no miss)", async () => {
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({
date: "2026-06-10",
timeOfDay: "09:00",
} as never);
const res = await POST(tgRequest(callbackUpdate("skip:med-1")));
expect(res.status).toBe(200);
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
const update = vi.mocked(prisma.medicationIntakeEvent.update).mock
.calls[0][0] as {
where: { id: string };
data: { takenAt: Date | null; skipped: boolean };
};
expect(update.where.id).toBe("evt-pending-0900");
expect(update.data.takenAt).toBeNull();
expect(update.data.skipped).toBe(true);
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("an exotic-zone reminder slot reconstructs on the correct local day", async () => {
// Pacific/Kiritimati (UTC+14): 09:00 on 2026-06-10 local is
// 2026-06-09T19:00Z — a naive UTC-noon reference would land a day off.
const slotKiritimati = new Date("2026-06-09T19:00:00.000Z");
const nowKiritimati = new Date("2026-06-09T19:05:00.000Z");
vi.setSystemTime(nowKiritimati);
vi.mocked(prisma.user.findFirst).mockResolvedValue({
...TG_USER,
timezone: "Pacific/Kiritimati",
} as never);
const pendingRow = {
...pendingReminderRow,
scheduledFor: slotKiritimati,
};
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
pendingRow,
] as never);
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({
date: "2026-06-10",
timeOfDay: "09:00",
} as never);
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(res.status).toBe(200);
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
const update = vi.mocked(prisma.medicationIntakeEvent.update).mock
.calls[0][0] as { where: { id: string } };
expect(update.where.id).toBe("evt-pending-0900");
});
});
describe("Telegram webhook — GET verification", () => {
it("returns 200 with a valid header secret", async () => {
const req = new NextRequest("http://localhost/api/telegram/webhook", {
+223 -24
View File
@@ -14,6 +14,15 @@ import { annotate, getEvent } from "@/lib/logging/context";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { locales, type Locale } from "@/lib/i18n/config";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import {
applyCanonicalSlotWrite,
resolveForcedSlotForWrite,
resolveSlotForWriteByBand,
} from "@/lib/medications/scheduling/slot-upsert";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { localHmAsUtc } from "@/lib/tz/local-day";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
interface TelegramUpdate {
update_id: number;
@@ -123,12 +132,88 @@ async function findTelegramUser(chatId: string) {
});
}
/**
* v1.16.9 — the slot a Telegram action should converge onto.
*
* The reminder context knows its own slot: the dispatcher records every
* reminder message as a `TelegramReminderMessage` row carrying the
* local `date` + `timeOfDay` of the dose it fired for. When the action
* callback carries the chat/message ids, that row names the EXACT slot
* — preferred over band attribution, which would orphan a confirmation
* tapped hours after the reminder (band closed → ad-hoc) even though
* the user is answering a specific dose prompt.
*
* Returns the canonical band anchor (validated against the schedule's
* real slots), or `null` when no reminder row matches / the instant is
* not a real slot — the caller then falls back to band attribution.
*/
async function resolveReminderSlot(input: {
userId: string;
medicationId: string;
userTz: string;
chatId: string;
messageId: number;
}): Promise<Date | null> {
const reminder = await prisma.telegramReminderMessage.findFirst({
where: {
medicationId: input.medicationId,
chatId: input.chatId,
messageId: input.messageId,
},
select: { date: true, timeOfDay: true },
});
if (!reminder) return null;
const instant = reminderLocalInstant(
reminder.date,
reminder.timeOfDay,
input.userTz,
);
if (!instant) return null;
// Validate against the medication's real band anchors so a stale or
// malformed reminder row can never park a dose on a non-slot instant.
return resolveForcedSlotForWrite({
userId: input.userId,
medicationId: input.medicationId,
userTz: input.userTz,
slotInstant: instant,
});
}
/**
* Mint the UTC instant of local `timeOfDay` on the local calendar day
* `date` (YYYY-MM-DD) in `tz`. Probes a reference instant a day either
* side of UTC noon so the result is correct in every zone offset, then
* derives the DST-correct instant via `localHmAsUtc`.
*/
function reminderLocalInstant(
date: string,
timeOfDay: string,
tz: string,
): Date | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !/^\d{2}:\d{2}$/.test(timeOfDay)) {
return null;
}
const [hour, minute] = timeOfDay.split(":").map(Number);
const base = new Date(`${date}T12:00:00.000Z`);
if (Number.isNaN(base.getTime())) return null;
for (const shiftDays of [0, -1, 1]) {
const ref = new Date(base.getTime() + shiftDays * 24 * 60 * 60 * 1000);
const parts = wallClockInTz(ref, tz);
const localDay = `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(
parts.day,
).padStart(2, "0")}`;
if (localDay === date) return localHmAsUtc(ref, tz, hour, minute);
}
return null;
}
async function markMedicationTaken(
userId: string,
medicationId: string,
idempotencyKey: string,
locale: Locale,
userTz: string | null,
reminderRef?: { chatId: string; messageId: number },
): Promise<{ ok: boolean; message: string; medicationName?: string }> {
const { t } = getServerTranslator(locale);
const medication = await prisma.medication.findFirst({
@@ -146,29 +231,90 @@ async function markMedicationTaken(
let scheduledFor: Date | null = null;
if (!existing) {
scheduledFor = new Date();
await prisma.$transaction([
prisma.medicationIntakeEvent.create({
data: {
const takenAt = new Date();
const tz = userTz || DEFAULT_TIMEZONE;
// v1.16.9 — converge onto the slot's canonical row instead of bare-
// creating a second row anchored at `now`. The old shape left the
// worker-minted pending REMINDER row open: the slot later auto-missed
// and compliance punished a dose the user explicitly confirmed.
// Prefer the reminder's own slot (the message names the dose it fired
// for); fall back to band attribution on the tap instant.
let canonicalSlot: Date | null = reminderRef
? await resolveReminderSlot({
userId,
medicationId: medication.id,
scheduledFor,
takenAt: scheduledFor,
userTz: tz,
chatId: reminderRef.chatId,
messageId: reminderRef.messageId,
})
: null;
if (canonicalSlot === null) {
const attribution = await resolveSlotForWriteByBand({
userId,
medicationId: medication.id,
userTz: tz,
takenAt,
});
canonicalSlot = attribution.slotInstant;
}
// The intake write and the snooze reset commit atomically — a
// recorded dose must never leave the medication snoozed-out of its
// own reminders, and a snooze reset must never land without the dose
// (the pre-band shape used a batch transaction here; the slot
// convergence dropped it). The canonical slot write accepts the
// transaction client; its P2002 race-recovery cannot continue inside
// an aborted Postgres transaction, so the rare create race rolls the
// whole write back instead of converging in place — Telegram
// redelivers the non-200 update and the retry finds the raced row.
scheduledFor = await prisma.$transaction(async (tx) => {
let slotScheduledFor: Date;
if (canonicalSlot) {
const applied = await applyCanonicalSlotWrite({
client: tx,
userId,
medicationId: medication.id,
canonicalSlot,
takenAt,
skipped: false,
source: "REMINDER",
isExplicitTaken: true,
isExplicitSkip: false,
idempotencyKey,
},
}),
prisma.medication.update({
createSource: "REMINDER",
attributionSource: "AUTO",
});
slotScheduledFor = applied.row.scheduledFor;
} else {
// Ad-hoc / PRN — standalone row under the documented contract
// (`scheduledFor = takenAt`).
const created = await tx.medicationIntakeEvent.create({
data: {
userId,
medicationId: medication.id,
scheduledFor: takenAt,
takenAt,
skipped: false,
source: "REMINDER",
idempotencyKey,
},
});
slotScheduledFor = created.scheduledFor;
}
await tx.medication.update({
where: { id: medication.id },
data: { snoozedUntil: null },
}),
]);
});
return slotScheduledFor;
});
// The confirmation must reflect on the next read (today feed, card
// pill, compliance) rather than wait out the cache TTL.
invalidateUserMedications(userId, { evict: true });
}
// v1.4.39 W-MED — refresh the compliance rollup for the
// affected day so the next read after the cache miss reflects the
// new dose. Only fires when the create-path landed a new row.
// new dose. Only fires when the write landed a row.
if (scheduledFor) {
await recomputeMedicationComplianceForEvent({
userId,
@@ -225,6 +371,7 @@ async function handleCallback(update: TelegramUpdate) {
idempotencyKey,
locale,
user.timezone,
messageId !== undefined ? { chatId, messageId } : undefined,
);
await answerTelegramCallbackQuery(botToken, callback.id, result.message);
if (messageId) {
@@ -313,25 +460,77 @@ async function handleCallback(update: TelegramUpdate) {
if (!existing) {
const endOfDay = new Date();
endOfDay.setHours(23, 59, 59, 999);
skippedScheduledFor = new Date();
const skipMoment = new Date();
const tz = user.timezone || DEFAULT_TIMEZONE;
await prisma.$transaction([
prisma.medicationIntakeEvent.create({
data: {
// v1.16.9 — a Telegram skip answers a specific dose prompt, so it
// must converge onto the slot's pending row like a take does. The
// old shape inserted a second row anchored at `now` and left the
// pending REMINDER row open to auto-miss — the user's deliberate
// skip recorded as a miss AND an orphan. Prefer the reminder's own
// slot; fall back to the band the skip moment falls in.
let canonicalSlot: Date | null =
messageId !== undefined
? await resolveReminderSlot({
userId: user.id,
medicationId: medication.id,
userTz: tz,
chatId,
messageId,
})
: null;
if (canonicalSlot === null) {
const attribution = await resolveSlotForWriteByBand({
userId: user.id,
medicationId: medication.id,
userTz: tz,
takenAt: skipMoment,
});
canonicalSlot = attribution.slotInstant;
}
// Atomic like the take path: the skip row and the rest-of-day
// snooze commit together, so a failure can never leave the user
// re-reminded for a recorded skip or silenced without one. Same
// P2002 trade-off as the take path — the rare create race rolls
// back and Telegram's redelivery converges on the raced row.
skippedScheduledFor = await prisma.$transaction(async (tx) => {
let slotScheduledFor: Date;
if (canonicalSlot) {
const applied = await applyCanonicalSlotWrite({
client: tx,
userId: user.id,
medicationId: medication.id,
scheduledFor: skippedScheduledFor,
canonicalSlot,
takenAt: null,
skipped: true,
source: "REMINDER",
isExplicitTaken: false,
isExplicitSkip: true,
idempotencyKey,
},
}),
prisma.medication.update({
createSource: "REMINDER",
});
slotScheduledFor = applied.row.scheduledFor;
} else {
const created = await tx.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: medication.id,
scheduledFor: skipMoment,
takenAt: null,
skipped: true,
source: "REMINDER",
idempotencyKey,
},
});
slotScheduledFor = created.scheduledFor;
}
await tx.medication.update({
where: { id: medication.id },
data: { snoozedUntil: endOfDay },
}),
]);
});
return slotScheduledFor;
});
invalidateUserMedications(user.id, { evict: true });
}
// v1.4.39 W-MED — refresh the compliance rollup for the skipped
+48 -49
View File
@@ -36,12 +36,13 @@ import {
useDashboardChartReveal,
} from "@/components/dashboard/chart-reveal";
import { DashboardHeader } from "@/components/dashboard/dashboard-header";
import { DashboardHero } from "@/components/dashboard/hero/dashboard-hero";
import { DashboardHeroSkeleton } from "@/components/dashboard/hero/dashboard-hero-skeleton";
import {
QuickEntrySheets,
type QuickEntryDialog,
} from "@/components/dashboard/quick-entry-sheets";
import {
getHourForTimeZone,
getRangeColorClass,
getRangeHint,
toHoursSummary,
@@ -684,43 +685,56 @@ export default function DashboardPage() {
upperBound: 55,
},
);
// v1.4.40 W-RSC — memoise the per-render `Intl.DateTimeFormat`
// instantiation (audit-H4). The greeting derived from the hour only
// changes when the user's timezone changes; recomputing it every
// render churned the parts-array and walked the format ICU twice per
// render across a 1 400-line component body.
// Gated on `mounted`: `user` comes from the auth query, which can
// resolve before this boundary hydrates — the greeting text must
// match the name-less SSR output during hydration (React #418) and
// may only personalise from the first client re-render.
const userTimezone = mounted ? user?.timezone : undefined;
const hour = useMemo(
() => (userTimezone ? getHourForTimeZone(userTimezone) : null),
[userTimezone],
);
const timeGreeting =
hour == null
? t("dashboard.greeting.day")
: hour >= 5 && hour < 12
? t("dashboard.greeting.morning")
: hour >= 12 && hour < 18
? t("dashboard.greeting.day")
: t("dashboard.greeting.evening");
const welcomeText =
mounted && user?.username && user.username.trim().length > 0
? t("dashboard.welcomeBackWithName", {
greeting: timeGreeting,
name: user.username,
})
: t("dashboard.welcomeBack", { greeting: timeGreeting });
// v1.7.0 — the primary data source differs by flag. In snapshot
// mode `analyticsSlimQuery` is `enabled: false`, and a disabled
// TanStack query reports `fetchStatus: "idle"` → `isLoading` is
// always `false`. Gating the skeleton on the slim query then
// never fires under snapshot mode, so the empty-state branch
// below flashes for the whole snapshot fetch. Key the loading
// flag off whichever query is actually driving the tiles.
// v1.16.4 — `!mounted` pins the SSR pass AND the hydration
// render to the skeleton branch. Server-side the data queries
// are `enabled: false` (no auth) → `isLoading: false`, which
// used to select the empty state; a late-hydrating boundary
// then saw `isAuthenticated` already resolved and rendered the
// skeleton instead — React #418. See `useMounted`.
// v1.16.9 — hoisted out of the strip IIFE: the hero band below
// shares the same gate so its skeleton and the tile silhouettes
// swap to content in the same render pass.
const primaryLoading =
!mounted ||
(snapshotEnabled ? snapshotQuery.isLoading : analyticsSlimQuery.isLoading);
// v1.16.9 — the greeting paragraph moved off the header into the
// hero band (it derives from the snapshot's server-computed
// `greetingHour`, so the old client-side Intl hour walk retired
// with it). The hero gates on the layout's `heroVisible` flag
// (default on; anything but the literal `false` renders) and holds
// a footprint-identical skeleton while the snapshot is in flight,
// so the band swaps in place — no growth, no greeting flash. When
// the hero does NOT render (toggle off, or snapshot flag off) the
// header takes the greeting back — it must never disappear with the
// band.
const heroVisible = snapshotEnabled && layout.heroVisible !== false;
const heroSnapshot = snapshotQuery.data;
return (
<div className="space-y-6">
<DashboardHeader
welcomeText={welcomeText}
onQuickEntry={setQuickEntryDialog}
showGreeting={!heroVisible}
/>
{heroVisible &&
(primaryLoading || !heroSnapshot ? (
<DashboardHeroSkeleton />
) : (
<DashboardHero
snapshot={heroSnapshot}
onQuickEntry={setQuickEntryDialog}
/>
))}
{/* v1.4.15 Phase B5 — spotlight tour for first-time users.
* Self-gates on `user.onboardingTourCompleted` (DB flag) plus
* a session-storage dismiss guard. We pass `ready=true` only
@@ -1403,24 +1417,9 @@ export default function DashboardPage() {
// strip tile) and undercounted `bp` (one widget id, two tiles),
// so the silhouettes reshuffled when the data landed.
const configuredTileCount = resolveConfiguredTileCount(layout);
// v1.7.0 — the primary data source differs by flag. In snapshot
// mode `analyticsSlimQuery` is `enabled: false`, and a disabled
// TanStack query reports `fetchStatus: "idle"` → `isLoading` is
// always `false`. Gating the skeleton on the slim query then
// never fires under snapshot mode, so the empty-state branch
// below flashes for the whole snapshot fetch. Key the loading
// flag off whichever query is actually driving the tiles.
// v1.16.4 — `!mounted` pins the SSR pass AND the hydration
// render to the skeleton branch. Server-side the data queries
// are `enabled: false` (no auth) → `isLoading: false`, which
// used to select the empty state; a late-hydrating boundary
// then saw `isAuthenticated` already resolved and rendered the
// skeleton instead — React #418. See `useMounted`.
const primaryLoading =
!mounted ||
(snapshotEnabled
? snapshotQuery.isLoading
: analyticsSlimQuery.isLoading);
// `primaryLoading` is derived once in the component body (above
// the return) — the hero band and this strip share the same
// gate so both surfaces swap in the same render pass.
// v1.16.8 — chart-row reservation while the snapshot is in
// flight. The chart visibility gates above need snapshot data
// (`count > 0`), so the cold chart row only carried the
@@ -0,0 +1,57 @@
/**
* `<DashboardHeader>` greeting contract.
*
* The greeting line lives in the hero band when that renders; the hero
* is optional (snapshot flag off, or hidden via the dashboard-layout
* toggle), so the page feeds `showGreeting` from its hero gate and the
* header restores the pre-hero greeting paragraph for exactly those
* mounts. Pinned here:
*
* 1. `showGreeting` renders the greeting line (with the `min-h-5`
* line-box reservation that keeps the header height stable through
* the post-hydration name personalisation);
* 2. without the prop (the hero owns the greeting) the line is absent
* — no duplicate greeting above the hero band;
* 3. the SSR pass renders the name-less fallback (hydration-safe).
*/
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { username: "tester", timezone: "Europe/Berlin" },
}),
}));
import { DashboardHeader } from "../dashboard-header";
function renderSSR(node: React.ReactElement, locale: "en" | "de" = "de") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<DashboardHeader> — greeting", () => {
it("renders the greeting line when the hero is hidden (showGreeting)", () => {
const html = renderSSR(
<DashboardHeader onQuickEntry={() => undefined} showGreeting />,
);
expect(html).toContain('data-slot="dashboard-header-greeting"');
// SSR pass: name-less fallback (personalises post-hydration only).
expect(html).toContain("willkommen zurück.");
// Reserved line box keeps the header height stable through the
// post-hydration personalisation swap.
expect(html).toMatch(/data-slot="dashboard-header-greeting"[^>]*>/);
expect(html).toContain("min-h-5");
});
it("renders NO greeting line when the hero band owns it (default)", () => {
const html = renderSSR(<DashboardHeader onQuickEntry={() => undefined} />);
expect(html).not.toContain('data-slot="dashboard-header-greeting"');
expect(html).not.toContain("willkommen zurück.");
// The title itself stays.
expect(html).toContain("Dashboard");
});
});
@@ -41,6 +41,13 @@ vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
// v1.16.9 — the component reads the profile timezone for the auto-pick.
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { username: "tester", timezone: "Europe/Berlin" },
}),
}));
import {
MedicationIntakeQuickAdd,
pickDefaultMedicationId,
@@ -116,6 +123,95 @@ describe("pickDefaultMedicationId — auto-select heuristic", () => {
expect(result).toBe("b");
});
it("does not pre-select a medication whose server next-due is in the future (v1.16.9)", () => {
// The band alone reads "in window" at 10:00 Berlin, but the server
// says the next unresolved dose is tomorrow (a rolling cadence whose
// dose was taken) — the gate must keep the heuristic off it.
const now = new Date("2026-05-12T08:00:00Z"); // 10:00 Berlin (UTC+2)
const result = pickDefaultMedicationId(
[
makeMed("a", { name: "Aaa" }),
makeMed("b", {
name: "Bbb",
nextDueAt: "2026-05-13T06:00:00.000Z", // tomorrow
nextDueOverdue: false,
schedules: [
{
id: "s1",
windowStart: "08:00",
windowEnd: "11:00",
daysOfWeek: null,
label: null,
dose: null,
},
],
}),
],
now,
);
// Nothing genuinely due → alphabetical fallback ("Aaa").
expect(result).toBe("a");
});
it("does not pre-select a day-scale medication already taken early in its period (v1.16.9)", () => {
// Weekly Tuesday med (2026-05-12 is a Tuesday), taken two days ago.
const now = new Date("2026-05-12T08:00:00Z"); // 10:00 Berlin
const result = pickDefaultMedicationId(
[
makeMed("a", { name: "Aaa" }),
makeMed("b", {
name: "Bbb",
lastTakenAt: "2026-05-10T07:00:00.000Z", // Sunday — early take
nextDueAt: "2026-05-12T07:00:00.000Z", // today's slot (unresolved)
nextDueOverdue: true,
schedules: [
{
id: "s1",
windowStart: "09:00",
windowEnd: "09:00",
daysOfWeek: "2",
timesOfDay: ["09:00"],
label: null,
dose: null,
},
],
}),
],
now,
);
expect(result).toBe("a");
});
it("reasons in the supplied profile timezone, not Berlin (v1.16.9)", () => {
// 08:00 UTC = 10:00 Berlin (inside the 08:00–11:00 window) but only
// 04:00 in New York (hours before it). The same instant must flip
// the pick with the timezone — pinning that the tz threads through
// both the wall-clock conversion and the window reduction.
const now = new Date("2026-05-12T08:00:00Z");
const meds = [
makeMed("a", { name: "Aaa" }),
makeMed("b", {
name: "Bbb",
schedules: [
{
id: "s1",
windowStart: "08:00",
windowEnd: "11:00",
daysOfWeek: null,
label: null,
dose: null,
},
],
}),
];
expect(pickDefaultMedicationId(meds, now, undefined, "Europe/Berlin")).toBe(
"b",
);
expect(
pickDefaultMedicationId(meds, now, undefined, "America/New_York"),
).toBe("a");
});
it("falls back to the alphabetical-first active medication when nothing is due", () => {
// 03:00 Berlin time: no schedules overlap. The helper should
// alphabetically sort and pick the leading id, "ramipril".
+66 -20
View File
@@ -1,12 +1,18 @@
"use client";
/**
* Dashboard page header: title + greeting line, the customize shortcut,
* and the quick-add dropdown that opens the quick-entry sheets.
* Dashboard page header: title, the customize shortcut, and the
* quick-add dropdown that opens the quick-entry sheets. The greeting
* line lives in the hero band (`<DashboardHero>`) when that renders —
* but the hero is optional (snapshot flag off, or hidden via the
* dashboard-layout toggle), and the greeting must never disappear with
* it. `showGreeting` (fed from the page's hero gate) restores the
* pre-hero greeting paragraph under the title for exactly those mounts.
*
* Extracted from the dashboard page; the page owns the open-state the
* dropdown items set via `onQuickEntry`.
*/
import { useMemo } from "react";
import Link from "next/link";
import { Activity, Pill, Plus, Settings2, Waves } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -17,37 +23,77 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { getHourForTimeZone } from "@/components/dashboard/range-display";
import { cn } from "@/lib/utils";
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
export function DashboardHeader({
welcomeText,
onQuickEntry,
showGreeting = false,
}: {
welcomeText: string;
onQuickEntry: (dialog: NonNullable<QuickEntryDialog>) => void;
/** Render the greeting line here because the hero band does not. */
showGreeting?: boolean;
}) {
const { t } = useTranslations();
const { user } = useAuth();
const mounted = useMounted();
// The pre-hero greeting derivation, kept hydration-safe: `user` comes
// from the auth query, which can resolve before this boundary
// hydrates — the text must match the name-less SSR output during
// hydration (React #418) and may only personalise from the first
// client re-render.
const userTimezone = mounted ? user?.timezone : undefined;
const hour = useMemo(
() => (userTimezone ? getHourForTimeZone(userTimezone) : null),
[userTimezone],
);
const timeGreeting =
hour == null
? t("dashboard.greeting.day")
: hour >= 5 && hour < 12
? t("dashboard.greeting.morning")
: hour >= 12 && hour < 18
? t("dashboard.greeting.day")
: t("dashboard.greeting.evening");
const welcomeText =
mounted && user?.username && user.username.trim().length > 0
? t("dashboard.welcomeBackWithName", {
greeting: timeGreeting,
name: user.username,
})
: t("dashboard.welcomeBack", { greeting: timeGreeting });
return (
/* v1.4.37 W4a item 7 — centre-align the Hinzufügen button
against the 2-line title block on mobile (< sm). The
`welcomeText` line wraps under the title at < 380 px so the
button used to float at the top of the row without a
baseline anchor; `items-center sm:items-start` keeps the
mobile vertical centre while preserving the original
top-aligned posture on sm+ (where the title is one line). */
<div className="flex items-center justify-between gap-4 sm:items-start">
/* With the greeting the title block is two lines on narrow
viewports: centre the buttons against it on mobile, top-align on
sm+ (the pre-hero posture). Without the greeting the single-line
centre alignment stands. */
<div
className={cn(
"flex items-center justify-between gap-4",
showGreeting && "sm:items-start",
)}
>
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("dashboard.title")}
</h1>
{/* v1.16.8 — `min-h-5` reserves the greeting's line box from the
SSR pass on: the text personalises (name appended) on the
first client re-render after hydration, and the reserved
line keeps the header from collapsing/growing around that
swap. */}
<p className="text-muted-foreground mt-1 min-h-5 text-sm">
{welcomeText}
</p>
{showGreeting ? (
/* `min-h-5` reserves the greeting's line box from the SSR
pass on: the text personalises (name appended) on the first
client re-render after hydration, and the reserved line
keeps the header from collapsing/growing around that swap. */
<p
data-slot="dashboard-header-greeting"
className="text-muted-foreground mt-1 min-h-5 text-sm"
>
{welcomeText}
</p>
) : null}
</div>
<div className="flex items-center gap-2">
{/* Customize shortcut to the dashboard-customization settings
@@ -0,0 +1,48 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { DashboardHeroSkeleton } from "../dashboard-hero-skeleton";
/**
* Structural pin for the hero loading silhouette: the skeleton must
* occupy the EXACT final footprint of `<DashboardHero>` (same min-h
* floors, same md two-column split, same fixed 120 px ring circle) so
* the loaded band swaps in place with zero layout shift — and it must
* stay invisible to assistive tech with no focusable content.
*/
describe("<DashboardHeroSkeleton>", () => {
const html = renderToStaticMarkup(<DashboardHeroSkeleton />);
it("carries the data-slot and aria-hidden contract", () => {
expect(html).toContain('data-slot="dashboard-hero-skeleton"');
const root = html.match(
/<div[^>]*data-slot="dashboard-hero-skeleton"[^>]*>/,
);
expect(root).not.toBeNull();
expect(root![0]).toContain('aria-hidden="true"');
});
it("reserves the hero's exact footprint classes", () => {
for (const cls of [
"min-h-[8.75rem]",
"md:min-h-[9.5rem]",
"hero-gradient",
"rounded-xl",
]) {
expect(html).toContain(cls);
}
});
it("mirrors the two-column md:flex-row split with the fixed ring circle", () => {
expect(html).toContain("md:flex-row");
expect(html).toContain("size-[120px]");
expect(html).toContain("rounded-full");
});
it("contains no focusable elements", () => {
expect(html).not.toContain("<button");
expect(html).not.toContain("<a ");
expect(html).not.toContain("<input");
expect(html).not.toContain("tabindex");
});
});
@@ -0,0 +1,517 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { I18nProvider } from "@/lib/i18n/context";
import { makeFormatters } from "@/lib/format-locale";
import type { DashboardSnapshot } from "@/lib/dashboard/snapshot";
import type { MedsTodayBlock } from "@/lib/dashboard/meds-today";
import type { DataSummary } from "@/lib/analytics/trends";
import { DashboardHero } from "../dashboard-hero";
/**
* `<DashboardHero>` contract:
*
* - every verdict variant renders its sentence + the single CTA the
* ladder assigns (link variants carry an href, quick-entry variants
* a button; allQuiet carries none);
* - the briefing rung renders the model headline VERBATIM as plain
* text (no i18n key);
* - the dose row prints the day tally with next-at / all-done / none
* detail, and the documented stale-cache state (past `nextDueAt`,
* `nextDueOverdue: false`) renders as the plain summary — never as
* overdue;
* - the greeting derives from the snapshot's server-computed
* `greetingHour` and personalises from the snapshot username;
* - the right column always paints the `sm` ScoreRing — provisional
* (never "0") when the score is null.
*
* The component reads the wall clock (`new Date()`) to age the cached
* snapshot honestly, so the suite pins the system time; the snapshot's
* own timezone (`Europe/Berlin`) keeps every assertion independent of
* the host zone.
*/
const NOW = new Date("2026-06-10T10:00:00.000Z"); // 12:00 Europe/Berlin
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
function summary(partial: Partial<DataSummary>): DataSummary {
return {
count: 0,
latest: null,
min: null,
max: null,
mean: null,
median: null,
avg7: null,
avg30: null,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
...partial,
};
}
function medsToday(partial: Partial<MedsTodayBlock> = {}): MedsTodayBlock {
return {
activeCount: 0,
scheduledToday: 0,
takenToday: 0,
skippedToday: 0,
nextDueAt: null,
nextDueOverdue: false,
nextDueMedicationName: null,
...partial,
};
}
function baseSnapshot(
overrides: Partial<DashboardSnapshot> = {},
): DashboardSnapshot {
return {
user: {
username: "tester",
timezone: "Europe/Berlin",
heightCm: null,
dateOfBirth: null,
gender: null,
glucoseUnit: null,
onboardingTourCompleted: true,
greetingHour: 12,
},
layout: { version: 1, widgets: [] },
layoutCatalogue: [],
metricStates: {},
tiles: {
summaries: {},
lastSeenByType: {},
mood: { summary: null, entries: [] },
},
extras: null,
medsToday: medsToday(),
healthScore: null,
briefing: null,
briefingState: "preparing",
briefingUpdatedAt: null,
briefingStale: false,
generatedAt: NOW.toISOString(),
...overrides,
};
}
function isoHoursFromNow(hours: number): string {
return new Date(NOW.getTime() + hours * 60 * 60 * 1000).toISOString();
}
function render(
snapshot: DashboardSnapshot,
opts: { locale?: "en" | "de"; onQuickEntry?: () => void } = {},
) {
return renderToStaticMarkup(
<I18nProvider initialLocale={opts.locale ?? "de"}>
<DashboardHero
snapshot={snapshot}
onQuickEntry={opts.onQuickEntry ?? (() => undefined)}
/>
</I18nProvider>,
);
}
describe("<DashboardHero> — verdict variants", () => {
it("doseOverdue: sentence with the medication name + quick-entry CTA button", () => {
const html = render(
baseSnapshot({
medsToday: medsToday({
scheduledToday: 2,
nextDueAt: isoHoursFromNow(-1),
nextDueOverdue: true,
nextDueMedicationName: "Metformin",
}),
}),
);
expect(html).toContain('data-verdict-variant="doseOverdue"');
expect(html).toContain("Eine Dosis Metformin ist überfällig.");
expect(html).toContain('data-slot="dashboard-hero-cta"');
expect(html).toContain("Jetzt eintragen");
// Quick-entry CTA is a real button, not a link.
expect(html).toMatch(/<button[^>]*data-slot="dashboard-hero-cta"/);
});
it("doseOverdue: a null medication name renders the name-less sentence (no hole)", () => {
const html = render(
baseSnapshot({
medsToday: medsToday({
scheduledToday: 2,
nextDueAt: isoHoursFromNow(-1),
nextDueOverdue: true,
nextDueMedicationName: null,
}),
}),
);
expect(html).toContain('data-verdict-variant="doseOverdue"');
expect(html).toContain("Eine Dosis ist überfällig.");
// The named template with an empty interpolation leaves a double
// space — it must never render.
expect(html).not.toContain("Eine Dosis ist überfällig.");
});
it("doseUpcoming: a null medication name renders the name-less sentence (no hole)", () => {
const nextDueAt = isoHoursFromNow(1.5); // 13:30 Berlin
const html = render(
baseSnapshot({
medsToday: medsToday({
scheduledToday: 2,
takenToday: 1,
nextDueAt,
nextDueMedicationName: null,
}),
}),
);
const expectedTime = makeFormatters("de", "Europe/Berlin", "AUTO").time(
nextDueAt,
);
expect(html).toContain('data-verdict-variant="doseUpcoming"');
expect(html).toContain(`Um ${expectedTime} steht eine Dosis an.`);
expect(html).not.toContain(`steht an`);
});
it("bpCritical: fixed-floor sentence with the reading + link to the BP insight", () => {
const html = render(
baseSnapshot({
tiles: {
summaries: {
BLOOD_PRESSURE_SYS: summary({ latest: 184, count: 5 }),
BLOOD_PRESSURE_DIA: summary({ latest: 96, count: 5 }),
},
lastSeenByType: {
BLOOD_PRESSURE_SYS: {
lastSeenAt: isoHoursFromNow(-2),
daysAgo: 0,
},
},
mood: { summary: null, entries: [] },
},
}),
);
expect(html).toContain('data-verdict-variant="bpCritical"');
expect(html).toContain("sehr hoch (184/96)");
expect(html).toContain('href="/insights/blood-pressure"');
expect(html).toContain("Blutdruck ansehen");
});
it("doseUpcoming: preference-aware wall-clock time + name + quick-entry CTA", () => {
const nextDueAt = isoHoursFromNow(1.5); // 13:30 Berlin
const html = render(
baseSnapshot({
medsToday: medsToday({
scheduledToday: 2,
takenToday: 1,
nextDueAt,
nextDueMedicationName: "Jardiance",
}),
}),
);
const expectedTime = makeFormatters("de", "Europe/Berlin", "AUTO").time(
nextDueAt,
);
expect(html).toContain('data-verdict-variant="doseUpcoming"');
expect(html).toContain(`Um ${expectedTime} steht Jardiance an.`);
expect(html).toContain("Jetzt eintragen");
// The dose row repeats the same instant as "Nächste um …".
expect(html).toContain(`Nächste um ${expectedTime}`);
});
it("weightDrift: sentence + link to the weight insight", () => {
const html = render(
baseSnapshot({
user: { ...baseSnapshot().user, heightCm: 180 },
tiles: {
summaries: {
// Green range for 180 cm ≈ 59.9–80.7 kg; 7d mean sits 1 kg
// further outside than the 30d mean.
WEIGHT: summary({ avg7: 86, avg30: 85, count: 10 }),
},
lastSeenByType: {
WEIGHT: { lastSeenAt: isoHoursFromNow(-3), daysAgo: 0 },
},
mood: { summary: null, entries: [] },
},
}),
);
expect(html).toContain('data-verdict-variant="weightDrift"');
expect(html).toContain(
"Dein Gewicht bewegt sich diese Woche vom Zielbereich weg.",
);
expect(html).toContain('href="/insights/weight"');
expect(html).toContain("Gewicht ansehen");
});
it("shortNights: locale-formatted hours + link to the sleep insight", () => {
const html = render(
baseSnapshot({
tiles: {
summaries: {
// 372 min = 6.2 h — clearly under the 6.5 h fire line.
SLEEP_DURATION: summary({ avg7: 372, count: 7 }),
},
lastSeenByType: {
SLEEP_DURATION: { lastSeenAt: isoHoursFromNow(-5), daysAgo: 0 },
},
mood: { summary: null, entries: [] },
},
}),
);
expect(html).toContain('data-verdict-variant="shortNights"');
// German decimal comma via the locale number formatter.
expect(html).toContain("im Schnitt 6,2 h.");
expect(html).toContain('href="/insights/sleep"');
expect(html).toContain("Schlaf ansehen");
});
it("silence: day count + quick-entry measurement CTA", () => {
const html = render(
baseSnapshot({
tiles: {
summaries: {},
lastSeenByType: {
WEIGHT: { lastSeenAt: isoHoursFromNow(-9 * 24), daysAgo: 9 },
},
mood: { summary: null, entries: [] },
},
}),
);
expect(html).toContain('data-verdict-variant="silence"');
expect(html).toContain("Seit 9 Tagen keine Messung.");
expect(html).toContain("Messung erfassen");
expect(html).toMatch(/<button[^>]*data-slot="dashboard-hero-cta"/);
});
it("scoreDrop: point delta + link to /insights", () => {
const html = render(
baseSnapshot({
healthScore: { score: 58, band: "yellow", delta: -14 },
}),
);
expect(html).toContain('data-verdict-variant="scoreDrop"');
expect(html).toContain("liegt 14 Punkte unter der Vorwoche.");
expect(html).toContain('href="/insights"');
expect(html).toContain("Insights öffnen");
});
it("briefing: renders the model headline verbatim as plain text + Insights link", () => {
const headline = "Systolic crept up over seven days.";
const html = render(
baseSnapshot({
briefingState: "ready",
briefingStale: false,
briefingUpdatedAt: NOW.toISOString(),
briefing: {
paragraph: "p",
keyFindings: [
{
tone: "watch",
headline,
detail: "detail",
delta: null,
sourceWindow: "7d",
sourceMetric: "bp",
},
],
} as DashboardSnapshot["briefing"],
}),
);
expect(html).toContain('data-verdict-variant="briefing"');
expect(html).toContain(headline);
expect(html).toContain('href="/insights"');
expect(html).toContain("Insights öffnen");
});
it("allQuiet: sentence with NO CTA in the band", () => {
const html = render(baseSnapshot());
expect(html).toContain('data-verdict-variant="allQuiet"');
expect(html).toContain("Alles ruhig");
expect(html).not.toContain('data-slot="dashboard-hero-cta"');
});
it("exactly one CTA renders for an actionable verdict", () => {
const html = render(
baseSnapshot({
medsToday: medsToday({
scheduledToday: 1,
nextDueOverdue: true,
nextDueAt: isoHoursFromNow(-1),
nextDueMedicationName: "Metformin",
}),
healthScore: { score: 50, band: "yellow", delta: -20 },
}),
);
const ctas = html.match(/data-slot="dashboard-hero-cta"/g) ?? [];
expect(ctas).toHaveLength(1);
});
});
describe("<DashboardHero> — dose row", () => {
it("prints the day tally with the v1.16.6 card chrome and the Pill icon", () => {
const html = render(
baseSnapshot({
medsToday: medsToday({ scheduledToday: 3, takenToday: 1 }),
}),
);
expect(html).toContain("1 von 3 Dosen genommen");
const doseRow = html.match(
/<div[^>]*data-slot="dashboard-hero-doses"[^>]*>/,
);
expect(doseRow).not.toBeNull();
for (const cls of [
"bg-card/65",
"border-border/60",
"rounded-xl",
"shadow-sm",
"backdrop-blur-sm",
]) {
expect(doseRow![0]).toContain(cls);
}
});
it("all doses resolved (taken + skipped) → 'Alle Dosen für heute erledigt'", () => {
const html = render(
baseSnapshot({
medsToday: medsToday({
scheduledToday: 2,
takenToday: 1,
skippedToday: 1,
}),
}),
);
expect(html).toContain("1 von 2 Dosen genommen");
expect(html).toContain("Alle Dosen für heute erledigt");
expect(html).not.toContain("Nächste um");
});
it("no doses scheduled → 'Heute keine Dosen geplant'", () => {
const html = render(baseSnapshot());
expect(html).toContain("Heute keine Dosen geplant");
expect(html).not.toContain("Dosen genommen");
});
it("DEFENSIVE: a stale past nextDueAt with nextDueOverdue false renders the plain summary, never overdue", () => {
const html = render(
baseSnapshot({
medsToday: medsToday({
scheduledToday: 2,
takenToday: 1,
nextDueAt: isoHoursFromNow(-0.5), // anchor passed AFTER build
nextDueOverdue: false,
nextDueMedicationName: "Metformin",
}),
}),
);
expect(html).toContain("1 von 2 Dosen genommen");
expect(html).not.toContain("Nächste um");
expect(html).not.toContain("überfällig");
expect(html).toContain('data-verdict-variant="allQuiet"');
});
it("a next-due anchor on TOMORROW's local day stays off the dose row", () => {
const html = render(
baseSnapshot({
medsToday: medsToday({
scheduledToday: 2,
takenToday: 1,
nextDueAt: isoHoursFromNow(20), // 08:00 Berlin tomorrow
nextDueMedicationName: "Metformin",
}),
}),
);
expect(html).toContain("1 von 2 Dosen genommen");
expect(html).not.toContain("Nächste um");
});
});
describe("<DashboardHero> — greeting", () => {
it("personalises from the snapshot username with the server greeting hour", () => {
const html = render(
baseSnapshot({
user: { ...baseSnapshot().user, username: "Sam", greetingHour: 8 },
}),
);
expect(html).toContain("Guten Morgen Sam, willkommen zurück.");
});
it("falls back to the name-less line when the username is blank", () => {
const html = render(
baseSnapshot({
user: { ...baseSnapshot().user, username: " ", greetingHour: 20 },
}),
);
expect(html).toContain("Guten Abend, willkommen zurück.");
});
it("derives the daypart from greetingHour without any client Intl walk", () => {
const day = render(
baseSnapshot({
user: { ...baseSnapshot().user, greetingHour: 13 },
}),
);
expect(day).toContain("Hallo tester, willkommen zurück.");
});
});
describe("<DashboardHero> — score column", () => {
it("renders the sm ScoreRing with the score + band", () => {
const html = render(
baseSnapshot({
healthScore: { score: 82, band: "green", delta: 2 },
}),
);
expect(html).toContain('data-slot="score-ring"');
expect(html).toContain('data-band="green"');
expect(html).toContain("Health Score");
});
it("null score renders the provisional ring (not 0) at identical geometry", () => {
const html = render(baseSnapshot({ healthScore: null }));
expect(html).toContain('data-slot="score-ring"');
expect(html).toContain('data-provisional="true"');
expect(html).toContain("Noch nicht genug Daten");
// The ring announces "not enough data", never a zero value.
const aria = html.match(
/<div[^>]*data-slot="score-ring"[^>]*aria-label="([^"]+)"/,
);
expect(aria).not.toBeNull();
expect(aria![1]).not.toMatch(/\b0\b/);
// Same fixed footprint as the scored state — the column never
// collapses (the 120 px sm geometry rides an inline style).
expect(html).toContain("width:120px");
});
});
describe("<DashboardHero> — source pins", () => {
const src = readFileSync(
join(process.cwd(), "src/components/dashboard/hero/dashboard-hero.tsx"),
"utf8",
);
it("quick-entry CTA wires the verdict's target into onQuickEntry", () => {
expect(src).toMatch(/onClick=\{\(\) => onQuickEntry\(cta\.target\)\}/);
});
it("re-derives the verdict with a fresh now per snapshot", () => {
expect(src).toMatch(/resolveDashboardVerdict\(snapshot,\s*new Date\(\)\)/);
});
it("uses the hero-gradient chrome WITHOUT glow-purple", () => {
expect(src).toContain("hero-gradient");
expect(src).not.toContain("glow-purple");
});
});
@@ -0,0 +1,45 @@
import { Skeleton } from "@/components/ui/skeleton";
/**
* Loading silhouette for the dashboard hero band.
*
* Mirrors the real `<DashboardHero>` footprint exactly — same
* `.hero-gradient` chrome, same `min-h-[8.75rem] md:min-h-[9.5rem]`
* floor, same two-column `md:flex-row` split with the fixed 120 px ring
* circle on the right — so the swap to the loaded hero happens in place
* with zero layout shift. The left column reserves the greeting line,
* the verdict sentence + CTA row, and the dose-row pill.
*
* Always `aria-hidden` with no focusable content: the tile-strip
* skeleton alongside carries the page's loading semantics, and a second
* announcement here would double up for screen readers. Reduced motion
* is honoured inside the `<Skeleton>` primitive
* (`motion-reduce:animate-none`).
*/
export function DashboardHeroSkeleton() {
return (
<div
aria-hidden="true"
data-slot="dashboard-hero-skeleton"
className="hero-gradient relative isolate min-h-[8.75rem] overflow-hidden rounded-xl px-4 py-5 sm:px-6 md:min-h-[9.5rem]"
>
<div className="flex h-full flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0 flex-1 space-y-3">
{/* Greeting line. */}
<Skeleton className="h-4 w-48 max-w-full" />
{/* Verdict sentence + CTA slot. */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<Skeleton className="h-5 w-72 max-w-full" />
<Skeleton className="h-8 w-32" />
</div>
{/* Dose-row pill. */}
<Skeleton className="h-9 w-64 max-w-full rounded-xl" />
</div>
{/* Fixed right column — the ScoreRing's `sm` circle (120 px). */}
<div className="flex shrink-0 items-center justify-center md:justify-end">
<Skeleton className="size-[120px] rounded-full" />
</div>
</div>
</div>
);
}
@@ -0,0 +1,286 @@
"use client";
/**
* Dashboard hero — the daily-verdict band between the page header and
* the tile strip.
*
* Purely presentational: every input rides in on the already-resolved
* `DashboardSnapshot`, the verdict is re-derived on render with a fresh
* `now` (so a cached snapshot ages honestly), and the only callback is
* the quick-entry opener the page owns. Three blocks:
*
* - left column: greeting line (absorbed from the old header
* paragraph — the snapshot's server-computed `greetingHour` keeps
* the daypart free of any client `Intl` work), the verdict sentence
* with its single CTA button, and the dose row;
* - right column: the shared `<ScoreRing>` at `sm` geometry. A null
* score renders the ring's provisional state at the identical
* 120 px footprint, so the column never collapses.
*
* Defensive contract (cached snapshot): `medsToday.nextDueAt` in the
* past with `nextDueOverdue: false` means the slot's anchor passed
* after the snapshot was built. The verdict resolver already falls
* through (rung 2 keys on the flag, rung 3 requires a future anchor),
* and the dose row below only prints "next at" for a FUTURE anchor on
* the user's local today — the stale state renders as the plain day
* summary, never as overdue.
*
* Rung 8 (`briefing`) renders the model-authored headline VERBATIM as a
* plain React text child — no i18n key, no HTML, no markdown — per the
* repo-wide no-markdown-renderer rule.
*
* The page h1 stays in `<DashboardHeader>`; this band is supporting
* content, so it renders non-heading text only (matching the Insights
* hero strip, which also carries no heading element).
*/
import { useMemo } from "react";
import Link from "next/link";
import { Pill } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScoreRing } from "@/components/insights/derived/score-ring";
import {
resolveDashboardVerdict,
type DashboardVerdictVariant,
} from "@/lib/dashboard/verdict";
import type { DashboardSnapshot } from "@/lib/dashboard/snapshot";
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
import { makeFormatters } from "@/lib/format-locale";
import { cn } from "@/lib/utils";
/**
* Variant → message key. `briefing` is deliberately absent — its
* sentence is the model headline rendered verbatim, not a translation.
* The bpCritical rung surfaces under the `bpHigh` copy key.
*/
const VERDICT_MESSAGE_KEY: Record<
Exclude<DashboardVerdictVariant, "briefing">,
string
> = {
doseOverdue: "dashboard.hero.verdict.doseOverdue",
bpCritical: "dashboard.hero.verdict.bpHigh",
doseUpcoming: "dashboard.hero.verdict.doseUpcoming",
weightDrift: "dashboard.hero.verdict.weightDrift",
shortNights: "dashboard.hero.verdict.shortNights",
silence: "dashboard.hero.verdict.silence",
scoreDrop: "dashboard.hero.verdict.scoreDrop",
allQuiet: "dashboard.hero.verdict.allQuiet",
};
/** Variant → CTA label key. `allQuiet` carries no CTA (cta: null). */
const CTA_LABEL_KEY: Partial<Record<DashboardVerdictVariant, string>> = {
doseOverdue: "dashboard.hero.action.takeDose",
doseUpcoming: "dashboard.hero.action.takeDose",
bpCritical: "dashboard.hero.action.viewBp",
weightDrift: "dashboard.hero.action.viewWeight",
shortNights: "dashboard.hero.action.viewSleep",
silence: "dashboard.hero.action.logMeasurement",
scoreDrop: "dashboard.hero.action.viewInsights",
briefing: "dashboard.hero.action.viewInsights",
};
/** YYYY-MM-DD key of `d`'s calendar day in `tz` (mirrors the resolver). */
function dayKeyInTz(d: Date, tz: string): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(d);
}
export function DashboardHero({
snapshot,
onQuickEntry,
}: {
snapshot: DashboardSnapshot;
onQuickEntry: (dialog: NonNullable<QuickEntryDialog>) => void;
}) {
const { t, locale } = useTranslations();
const timeFormat = useTimeFormatPreference();
// Times format in the USER's timezone from the snapshot (the shared
// `useFormatters()` hook falls back to the display default), honouring
// the per-user hour-cycle preference like the medication cards do.
// Only ever invoked post-snapshot, so no pre-paint Intl work.
const fmt = useMemo(
() => makeFormatters(locale, snapshot.user.timezone, timeFormat),
[locale, snapshot.user.timezone, timeFormat],
);
// Fresh `now` per snapshot evaluation: the resolver re-derives every
// freshness window against the current instant, so a snapshot served
// from cache ages honestly between refetches.
const verdict = useMemo(
() => resolveDashboardVerdict(snapshot, new Date()),
[snapshot],
);
// ── Greeting — absorbed from the old header paragraph ──────────────
// The server-computed wall-clock hour keeps the daypart stable and
// Intl-free; the hero only mounts once the snapshot resolved (the
// skeleton carries the footprint before that), so the personalised
// line can never disagree with SSR output.
const hour = snapshot.user.greetingHour;
const timeGreeting =
hour >= 5 && hour < 12
? t("dashboard.greeting.morning")
: hour >= 12 && hour < 18
? t("dashboard.greeting.day")
: t("dashboard.greeting.evening");
const name = snapshot.user.username.trim();
const welcomeText =
name.length > 0
? t("dashboard.welcomeBackWithName", { greeting: timeGreeting, name })
: t("dashboard.welcomeBack", { greeting: timeGreeting });
// ── Verdict sentence + CTA ──────────────────────────────────────────
// The dose variants interpolate a medication name; a snapshot can carry
// none (the resolver emits `""`), and the named sentence would render
// with a hole ("Eine Dosis ist überfällig."). Swap to the name-less
// sibling key instead of patching a generic word into the gap.
const verdictName =
typeof verdict.values.name === "string" ? verdict.values.name.trim() : "";
const messageKey = (variant: Exclude<DashboardVerdictVariant, "briefing">) =>
variant === "doseOverdue" && verdictName.length === 0
? "dashboard.hero.verdict.doseOverdueNoName"
: variant === "doseUpcoming" && verdictName.length === 0
? "dashboard.hero.verdict.doseUpcomingNoName"
: VERDICT_MESSAGE_KEY[variant];
const sentence =
verdict.variant === "briefing"
? // Model headline VERBATIM as a plain text child — no key.
String(verdict.values.headline ?? "")
: t(messageKey(verdict.variant), {
...verdict.values,
// The resolver emits a fixed 24 h wall-clock string; re-format
// the underlying instant through the user's hour-cycle
// preference when we still hold it.
...(verdict.variant === "doseUpcoming" &&
snapshot.medsToday.nextDueAt !== null
? { time: fmt.time(snapshot.medsToday.nextDueAt) }
: {}),
...(verdict.variant === "shortNights" &&
typeof verdict.values.hours === "number"
? { hours: fmt.number(verdict.values.hours, 1) }
: {}),
});
const cta = verdict.cta;
const ctaLabelKey = CTA_LABEL_KEY[verdict.variant];
// ── Dose row ────────────────────────────────────────────────────────
const meds = snapshot.medsToday;
const now = new Date();
const tz = snapshot.user.timezone;
const unresolvedRemain =
meds.scheduledToday > meds.takenToday + meds.skippedToday;
const nextDueMs = meds.nextDueAt !== null ? Date.parse(meds.nextDueAt) : NaN;
// "Next at" only for a FUTURE anchor on the user's local today. A past
// anchor with `nextDueOverdue: false` is the documented stale-cache
// state and must render as the plain summary — never as overdue.
const showNextAt =
unresolvedRemain &&
Number.isFinite(nextDueMs) &&
nextDueMs >= now.getTime() &&
dayKeyInTz(new Date(nextDueMs), tz) === dayKeyInTz(now, tz);
const doseDetail =
meds.scheduledToday > 0
? !unresolvedRemain
? t("dashboard.hero.doses.allDone")
: showNextAt
? t("dashboard.hero.doses.nextAt", {
time: fmt.time(new Date(nextDueMs)),
})
: null
: null;
return (
<section
data-slot="dashboard-hero"
className={cn(
// `.hero-gradient` carries its own border tint; deliberately
// no glow treatment — the band sits above the tile strip and
// must read calmer than the /insights hero.
"hero-gradient relative isolate overflow-hidden rounded-xl",
"min-h-[8.75rem] px-4 py-5 sm:px-6 md:min-h-[9.5rem]",
)}
>
<div className="flex h-full flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0 flex-1 space-y-3">
<p
data-slot="dashboard-hero-greeting"
className="text-muted-foreground text-sm"
>
{welcomeText}
</p>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<p
data-slot="dashboard-hero-verdict"
data-verdict-variant={verdict.variant}
className="text-foreground text-base font-medium"
>
{sentence}
</p>
{cta !== null && ctaLabelKey ? (
cta.kind === "link" ? (
<Button
asChild
size="sm"
variant="outline"
data-slot="dashboard-hero-cta"
>
<Link href={cta.href}>{t(ctaLabelKey)}</Link>
</Button>
) : (
<Button
size="sm"
data-slot="dashboard-hero-cta"
onClick={() => onQuickEntry(cta.target)}
>
{t(ctaLabelKey)}
</Button>
)
) : null}
</div>
<div
data-slot="dashboard-hero-doses"
className="bg-card/65 border-border/60 inline-flex max-w-full items-center gap-2 rounded-xl border px-3 py-2 shadow-sm backdrop-blur-sm"
>
<Pill
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span className="text-foreground truncate text-sm">
{meds.scheduledToday > 0
? t("dashboard.hero.doses.summary", {
taken: meds.takenToday,
scheduled: meds.scheduledToday,
})
: t("dashboard.hero.doses.none")}
</span>
{doseDetail !== null ? (
<span className="text-muted-foreground shrink-0 text-sm">
· {doseDetail}
</span>
) : null}
</div>
</div>
{/* Right column — fixed 120 px ring footprint. A null score
renders the ring's provisional state (aria announces "not
enough data", never 0) at identical geometry, so the column
NEVER collapses. */}
<div className="flex shrink-0 items-center justify-center md:justify-end">
<ScoreRing
score={snapshot.healthScore?.score ?? null}
band={snapshot.healthScore?.band}
size="sm"
label={
snapshot.healthScore
? t("dashboard.hero.scoreLabel")
: t("dashboard.hero.scoreProvisional")
}
/>
</div>
</div>
</section>
);
}
@@ -25,9 +25,10 @@ import {
} from "@/lib/query-keys";
import {
reduceCurrentWindowStatus,
toBerlinDate,
toZonedDate,
type ScheduleWindowInput,
} from "@/lib/medications/window-status";
import { useAuth } from "@/hooks/use-auth";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
/**
@@ -80,6 +81,15 @@ export interface MedicationOption {
schedules: Schedule[];
lastTakenAt: string | null;
todayEventCount: number | null;
/**
* v1.16.9 — the server display-due verdict from `GET /api/medications`
* (`nextDueAt` + `nextDueOverdue`). Threaded into the default pick so
* the quick-add gates its "due now" heuristic exactly like the cards:
* a rolling cadence whose next dose is days away must not pre-select.
* Optional for older mocks; absent keeps the legacy band-only pick.
*/
nextDueAt?: string | null;
nextDueOverdue?: boolean;
}
interface MedicationIntakeQuickAddProps {
@@ -120,23 +130,40 @@ export function pickDefaultMedicationId(
lateMinutes: 120,
missedMinutes: 240,
},
/**
* v1.16.9 — the profile timezone the "due now" heuristic reasons in
* (the same source the cards use). Berlin stays the last-resort
* fallback for logged-out mounts and legacy fixtures.
*/
tz: string = "Europe/Berlin",
): string | null {
const actives = options.filter((m) => m.active);
if (actives.length === 0) return null;
if (actives.length === 1) return actives[0].id;
const nowBerlin = toBerlinDate(now);
const nowLocal = toZonedDate(now, tz);
const due = actives.find((m) => {
// v1.16.9 — the same server display-due gate the cards apply: a
// future (non-overdue) next-due suppresses the overdue tiers and a
// day-scale dose taken early in its period must not pre-select.
const nextDueMs = m.nextDueAt ? new Date(m.nextDueAt).getTime() : NaN;
const status = reduceCurrentWindowStatus({
schedules: m.schedules,
nowBerlin,
nowBerlin: nowLocal,
lateMinutes: thresholds.lateMinutes,
missedMinutes: thresholds.missedMinutes,
active: true,
lastTakenAt: m.lastTakenAt,
todayEventCount: m.todayEventCount ?? 0,
tz,
nextDue:
m.nextDueAt === undefined
? undefined
: Number.isFinite(nextDueMs)
? { at: new Date(nextDueMs), overdue: m.nextDueOverdue === true }
: null,
});
return status.status !== null;
return status.status !== null && status.takenEarlyDaysAgo === null;
});
if (due) return due.id;
@@ -154,6 +181,11 @@ export function MedicationIntakeQuickAdd({
}: MedicationIntakeQuickAddProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
// v1.16.9 — the auto-pick reasons in the PROFILE timezone (the same
// source the cards use); Berlin stays the last-resort fallback so
// logged-out mounts behave unchanged.
const { user } = useAuth();
const userTz = user?.timezone || "Europe/Berlin";
const { data: medicationsRaw, isLoading: medicationsLoading } = useQuery({
queryKey: queryKeys.medications(),
@@ -210,8 +242,8 @@ export function MedicationIntakeQuickAdd({
const formId = useId();
const defaultMedicationId = useMemo(
() => pickDefaultMedicationId(medications),
[medications],
() => pickDefaultMedicationId(medications, new Date(), undefined, userTz),
[medications, userTz],
);
const medicationId = medicationOverride ?? defaultMedicationId ?? "";
const selectedMedication = medications.find((m) => m.id === medicationId);
@@ -71,6 +71,8 @@ export interface MedicationCardBodyProps {
status: Exclude<MedicationWindowStatus, null>;
windowStart: string;
windowEnd: string;
/** v1.16.9 — day-scale last-dose context in whole days (see the pill). */
takenEarlyDaysAgo?: number | null;
} | null;
/**
@@ -178,7 +180,17 @@ export function MedicationCardBody({
overdue tier arrives with the compliance query, and a line
popping in late used to shift every row below it (CLS). */}
<div className="min-h-5">
{overdueLabel ? (
{windowStatus?.takenEarlyDaysAgo != null ? (
// v1.16.9 — last-dose context outranks the overdue line: a
// day-scale dose already taken earlier in its period must never
// escalate into a take prompt (double-dose risk).
<MedicationStatusPill
status={windowStatus.status}
windowStart={windowStatus.windowStart}
windowEnd={windowStatus.windowEnd}
takenEarlyDaysAgo={windowStatus.takenEarlyDaysAgo}
/>
) : overdueLabel ? (
<p className="text-destructive flex items-center gap-1 text-sm font-medium">
<AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />
{overdueLabel}
@@ -9,6 +9,16 @@ interface MedicationStatusPillProps {
status: Exclude<MedicationWindowStatus, null>;
windowStart: string;
windowEnd: string;
/**
* v1.16.9 — day-scale last-dose context. Non-null when a dose was
* already taken earlier in the cadence period (a weekly shot days
* before its slot day, or the previous slot served late); the pill
* then renders a calm factual "last dose {n} days ago" note instead
* of the take-now / overdue prompt — prompting a full take on the
* slot day would be a double-dose prompt. The value is the whole
* local days since that take.
*/
takenEarlyDaysAgo?: number | null;
}
/**
@@ -23,9 +33,27 @@ export function MedicationStatusPill({
status,
windowStart,
windowEnd,
takenEarlyDaysAgo = null,
}: MedicationStatusPillProps) {
const { t, locale } = useTranslations();
if (takenEarlyDaysAgo != null) {
return (
<p className="text-sm">
<span className="text-muted-foreground inline-flex items-center gap-1 font-medium">
<CircleCheck className="size-3.5 shrink-0" aria-hidden="true" />
{takenEarlyDaysAgo === 1
? t("medications.lastDoseYesterday")
: t("medications.lastDoseDaysAgo", { count: takenEarlyDaysAgo })}
</span>
<span className="text-muted-foreground hidden sm:inline">
{" "}
— {formatTimeWindowRange(windowStart, windowEnd, locale)}
</span>
</p>
);
}
return (
<p className="text-sm">
<span
@@ -23,9 +23,10 @@ import { LogInjectionSiteDialog } from "@/components/medications/log-injection-s
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
import {
reduceCurrentWindowStatus,
toBerlinDate,
toZonedDate,
} from "@/lib/medications/window-status";
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
import { useAuth } from "@/hooks/use-auth";
import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary";
/**
@@ -166,6 +167,10 @@ export function Glp1MedicationCard({
}: Glp1MedicationCardProps) {
const queryClient = useQueryClient();
const { t } = useTranslations();
// v1.16.9 — the card reasons in the PROFILE timezone; Berlin stays the
// last-resort fallback for logged-out mounts and legacy fixtures.
const { user } = useAuth();
const userTz = user?.timezone || "Europe/Berlin";
const fmt = useFormatters();
const weekdayLabel = useWeekdayLabel();
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
@@ -288,7 +293,7 @@ export function Glp1MedicationCard({
a.windowStart.localeCompare(b.windowStart) ||
a.windowEnd.localeCompare(b.windowEnd),
);
const nowBerlin = toBerlinDate(now);
const nowBerlin = toZonedDate(now, userTz);
const lateMinutes = thresholds?.lateMinutes ?? 120;
const missedMinutes = thresholds?.missedMinutes ?? 240;
const currentWindowStatus = reduceCurrentWindowStatus({
@@ -299,6 +304,7 @@ export function Glp1MedicationCard({
active: medication.active,
lastTakenAt: medication.lastTakenAt,
todayEventCount: medication.todayEventCount ?? 0,
tz: userTz,
// v1.16.6 — gate the pill on the server display-due so a rolling
// cadence whose next injection is days away can never paint an overdue
// pill today. `undefined` (older payloads / fixtures) keeps legacy
@@ -319,6 +325,7 @@ export function Glp1MedicationCard({
currentWindowStatus,
nextDueAt: medication.nextDueAt,
now,
timeZone: userTz,
});
// v1.15.9 — the recent-injection list now feeds ONLY the post-dose
@@ -439,6 +446,7 @@ export function Glp1MedicationCard({
// legacy schedule window (which may be stale / degenerate).
windowStart: currentWindowStatus.window!.start,
windowEnd: currentWindowStatus.window!.end,
takenEarlyDaysAgo: currentWindowStatus.takenEarlyDaysAgo,
}
: null
}
+20 -6
View File
@@ -13,7 +13,7 @@ import { formatDateTime, formatTime } from "@/lib/format";
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
import {
reduceCurrentWindowStatus,
toBerlinDate,
toZonedDate,
} from "@/lib/medications/window-status";
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
@@ -26,6 +26,7 @@ import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary";
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
import { useAuth } from "@/hooks/use-auth";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
interface Schedule {
@@ -104,6 +105,11 @@ export function MedicationCard({
}: MedicationCardProps) {
const queryClient = useQueryClient();
const { t, locale } = useTranslations();
// v1.16.9 — the card reasons in the PROFILE timezone (the snapshot the
// session carries); Berlin stays the last-resort fallback so logged-out
// mounts and legacy fixtures behave unchanged.
const { user } = useAuth();
const userTz = user?.timezone || "Europe/Berlin";
const fmt = useFormatters();
const weekdayLabel = useWeekdayLabel();
// v1.8.5 — post-dose injection-site prompt state. Holds the intake
@@ -199,7 +205,7 @@ export function MedicationCard({
a.windowStart.localeCompare(b.windowStart) ||
a.windowEnd.localeCompare(b.windowEnd),
);
const nowBerlin = toBerlinDate(new Date());
const nowBerlin = toZonedDate(new Date(), userTz);
// v1.8.4 — the next-due instant comes from the server (`nextDueAt`,
// computed by the canonical recurrence engine anchored on the last
// intake). The day label below derives from it; the window-range /
@@ -224,6 +230,7 @@ export function MedicationCard({
active: medication.active,
lastTakenAt: medication.lastTakenAt,
todayEventCount: medication.todayEventCount ?? 0,
tz: userTz,
// v1.16.6 — gate the pill on the server display-due so a rolling
// cadence whose next dose is tomorrow can never paint an overdue pill
// today. `undefined` (older payloads / fixtures) keeps legacy behaviour.
@@ -231,7 +238,10 @@ export function MedicationCard({
medication.nextDueAt === undefined
? undefined
: nextAt !== undefined
? { at: new Date(nextAt), overdue: medication.nextDueOverdue === true }
? {
at: new Date(nextAt),
overdue: medication.nextDueOverdue === true,
}
: null,
});
@@ -244,6 +254,7 @@ export function MedicationCard({
currentWindowStatus,
nextDueAt: medication.nextDueAt,
now: new Date(),
timeZone: userTz,
});
function formatLastTakenAt(value: string): string {
@@ -251,7 +262,7 @@ export function MedicationCard({
// string-comparable for the today / yesterday / older bucketing below.
// The actual user-facing display goes through formatTime / formatDateTime.
const dayFormatter = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
timeZone: userTz,
year: "numeric",
month: "2-digit",
day: "2-digit",
@@ -314,7 +325,7 @@ export function MedicationCard({
// Format day label relative to today
let dayLabel = "";
if (nextAt) {
const nextDate = toBerlinDate(new Date(nextAt));
const nextDate = toZonedDate(new Date(nextAt), userTz);
const todayStr = `${nowBerlin.getFullYear()}-${nowBerlin.getMonth()}-${nowBerlin.getDate()}`;
const nextStr = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`;
const tomorrow = new Date(nowBerlin);
@@ -381,13 +392,16 @@ export function MedicationCard({
// legacy schedule window (which may be stale / degenerate).
windowStart: currentWindowStatus.window!.start,
windowEnd: currentWindowStatus.window!.end,
takenEarlyDaysAgo: currentWindowStatus.takenEarlyDaysAgo,
}
: null
}
doseStatus={doseStatus}
nextLine={nextLine}
lastLine={
medication.lastTakenAt ? formatLastTakenAt(medication.lastTakenAt) : null
medication.lastTakenAt
? formatLastTakenAt(medication.lastTakenAt)
: null
}
compliance={
compliance ? { rate7, rate30, streak, shortDays, longDays } : null
@@ -1,5 +1,7 @@
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,
@@ -269,6 +271,76 @@ describe("<DashboardLayoutSection> — iOS-only id skip (v1.7.0)", () => {
});
});
/**
* Dashboard hero (Tagesüberblick) visibility switch — one toggle above
* the widget list, persisted as `heroVisible` on the layout blob through
* the SAME PUT mutation the widget rows use (the Save button flushes the
* draft). SSR smoke assertions + source pins, matching the rest of this
* suite (no DOM runtime for state mutation).
*/
describe("<DashboardLayoutSection> — hero visibility switch", () => {
it("renders one hero switch above the widget list", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const switches = html.match(/data-slot="hero-visible-switch"/g) ?? [];
expect(switches).toHaveLength(1);
// Above the list: the hero switch markup precedes the first widget row.
expect(html.indexOf('data-slot="hero-visible-switch"')).toBeLessThan(
html.indexOf('data-slot="widget-row"'),
);
});
it("labels the switch with the localised copy + description", () => {
const de = render(<DashboardLayoutSection id="dashboard-layout" />, "de");
expect(de).toContain("Tagesüberblick");
expect(de).toContain(
"Zeigt Begrüßung, Score, Tagesfokus und Dosen-Status ganz oben auf dem Dashboard.",
);
const en = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(en).toContain("Daily overview");
});
it("reflects heroVisible: default layout → checked, false → unchecked", () => {
const checkedHtml = render(
<DashboardLayoutSection id="dashboard-layout" />,
);
const checked = checkedHtml.match(
/<button[^>]*data-slot="hero-visible-switch"[^>]*>/,
);
expect(checked).not.toBeNull();
expect(checked![0]).toContain('data-state="checked"');
queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: false };
const uncheckedHtml = render(
<DashboardLayoutSection id="dashboard-layout" />,
);
const unchecked = uncheckedHtml.match(
/<button[^>]*data-slot="hero-visible-switch"[^>]*>/,
);
expect(unchecked).not.toBeNull();
expect(unchecked![0]).toContain('data-state="unchecked"');
});
it("persists through the existing PUT draft flow (source pins)", () => {
const src = readFileSync(
join(
process.cwd(),
"src/components/settings/dashboard-layout-section.tsx",
),
"utf8",
);
// The toggle writes the flag onto the layout draft…
expect(src).toMatch(
/setDraft\(\{\s*\.\.\.layout,\s*heroVisible:\s*value\s*\}\)/,
);
// …which the Save button flushes via the one save mutation
// (`apiPut("/api/dashboard/widgets", …)`) every other control uses.
expect(src).toMatch(/onCheckedChange=\{\(v\) => setHeroVisible\(v\)\}/);
expect(src).toMatch(
/apiPut<DashboardLayout>\("\/api\/dashboard\/widgets", next\)/,
);
});
});
/**
* v1.11.2 HIGH-1 — the 8 B5 ids are WRITABLE (in DASHBOARD_WIDGET_IDS so the
* iOS pin PUT validates them) but have NO web render path, so the web
@@ -153,6 +153,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();
// Label ↔ switch hookup for the hero (Tagesüberblick) toggle below.
const heroToggleId = useId();
// v1.4.47 W4 — sensors: pointer for mouse/touch, keyboard for Tab + Space
// + arrow-key reordering. The KeyboardSensor still works for users who
@@ -243,6 +245,16 @@ export function DashboardLayoutSection({ id }: { id: string }) {
setDraft({ ...layout, comparisonBaseline: value });
}
/**
* Dashboard hero (daily verdict) visibility. Rides the layout blob
* (`heroVisible`, default on) and persists through the same PUT the
* widget toggles use — the Save button flushes it.
*/
function setHeroVisible(value: boolean) {
if (!layout) return;
setDraft({ ...layout, heroVisible: value });
}
/**
* v1.4.48 M6a — the arrow buttons now delegate to `reorderWidgets()`
* so the swap-and-renumber logic lives in exactly one place. The
@@ -377,6 +389,33 @@ export function DashboardLayoutSection({ id }: { id: string }) {
</div>
)}
{/* Hero (Tagesüberblick) visibility — one switch above the widget
list because the band sits above every widget on the dashboard.
Persists as `heroVisible` on the layout blob through the same
PUT mutation the rows below use (Save flushes the draft). */}
{layout && (
<div className="border-border bg-background/30 flex min-h-12 items-center justify-between gap-3 rounded-md border px-3 py-2">
<div className="min-w-0">
<label
htmlFor={heroToggleId}
className="text-foreground text-sm font-medium"
>
{t("dashboard.heroToggleLabel")}
</label>
<p className="text-muted-foreground text-xs">
{t("dashboard.heroToggleDescription")}
</p>
</div>
<Switch
id={heroToggleId}
checked={layout.heroVisible !== false}
onCheckedChange={(v) => setHeroVisible(v)}
disabled={saveMutation.isPending}
data-slot="hero-visible-switch"
/>
</div>
)}
{isLoading || !layout ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
@@ -377,6 +377,65 @@ describe("resolveDashboardLayout() — chartOverlayPrefs (v1.4.18)", () => {
});
});
/**
* Dashboard hero (daily verdict) visibility — `heroVisible` piggy-backs
* on the layout blob like the B8 comparison baseline. Resolver contract:
* anything that is not the literal `false` clamps back to `true`
* (default-on for legacy blobs; a stale client cannot poison the field
* with a non-boolean). Serializer persists the resolved boolean
* explicitly so a re-read never has to guess the default.
*/
describe("resolveDashboardLayout() — heroVisible", () => {
it("defaults to true for legacy layouts (no field saved)", () => {
const legacy = {
version: 1,
widgets: [{ id: "weight", visible: true, order: 0 }],
};
expect(resolveDashboardLayout(legacy).heroVisible).toBe(true);
});
it("respects an explicit heroVisible: false", () => {
const saved = {
version: 1,
widgets: [{ id: "weight", visible: true, order: 0 }],
heroVisible: false,
};
expect(resolveDashboardLayout(saved).heroVisible).toBe(false);
});
it("clamps non-boolean values back to true", () => {
for (const poisoned of ["false", 0, null, 1, {}]) {
const saved = {
version: 1,
widgets: [{ id: "weight", visible: true, order: 0 }],
heroVisible: poisoned,
};
expect(resolveDashboardLayout(saved).heroVisible).toBe(true);
}
});
it("defaults heroVisible to true in DEFAULT_DASHBOARD_LAYOUT", () => {
expect(DEFAULT_DASHBOARD_LAYOUT.heroVisible).toBe(true);
});
it("preserves heroVisible: false through serialize → resolve round-trip", () => {
const layout: DashboardLayout = {
...DEFAULT_DASHBOARD_LAYOUT,
heroVisible: false,
};
const resolved = resolveDashboardLayout(serializeDashboardLayout(layout));
expect(resolved.heroVisible).toBe(false);
});
it("serializer derives true when the field is missing on input", () => {
const layout: DashboardLayout = {
version: 1,
widgets: [{ id: "weight", visible: true, order: 0 }],
};
expect(serializeDashboardLayout(layout).heroVisible).toBe(true);
});
});
/**
* v1.7.0 — full 27-id widget catalogue for the iOS cold-launch seed.
* The catalogue is a pure superset of the server-known ids; the
@@ -12,6 +12,7 @@ vi.mock("@/lib/db", () => ({
measurement: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
workout: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
},
@@ -67,6 +68,7 @@ const prismaMock = prisma as unknown as {
measurement: { findMany: ReturnType<typeof vi.fn> };
moodEntry: { findMany: ReturnType<typeof vi.fn> };
medicationIntakeEvent: { findMany: ReturnType<typeof vi.fn> };
medication: { findMany: ReturnType<typeof vi.fn> };
workout: { findMany: ReturnType<typeof vi.fn> };
user: { findUnique: ReturnType<typeof vi.fn> };
};
@@ -170,12 +172,51 @@ describe("buildCoachSnapshot — budgeting + progressive degradation", () => {
score: 3 + (i % 3),
})),
);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue(
Array.from({ length: 360 }, (_, i) => ({
scheduledFor: new Date(Date.now() - i * 24 * 60 * 60 * 1000),
takenAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000),
skipped: false,
})),
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
// v1.16.9 — the compliance timeline derives from the band-engine
// ledger now; the snapshot loads medications (schedules + window
// events) instead of raw intake rows.
// Branch on the caller: the GLP-1 block filters on
// `treatmentClass: "GLP1"` (no GLP-1 therapy in this fixture); the
// compliance loader reads every medication.
const complianceMedRows = [
{
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date(Date.now() - 400 * 24 * 60 * 60 * 1000),
schedules: [
{
id: "sched-1",
windowStart: "09:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["09:00"],
reminderGraceMinutes: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
},
],
scheduleRevisions: [],
intakeEvents: Array.from({ length: 360 }, (_, i) => ({
scheduledFor: new Date(Date.now() - i * 24 * 60 * 60 * 1000),
takenAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000),
skipped: false,
autoMissed: false,
attributionSource: "AUTO",
})),
},
];
prismaMock.medication.findMany.mockImplementation(
(args: { where?: { treatmentClass?: string } }) =>
Promise.resolve(
args?.where?.treatmentClass === "GLP1" ? [] : complianceMedRows,
),
);
prismaMock.workout.findMany.mockResolvedValue(
Array.from({ length: 80 }, (_, i) => ({
@@ -295,6 +336,7 @@ describe("buildCoachSnapshot — budgeting + progressive degradation", () => {
prismaMock.measurement.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
prismaMock.medication.findMany.mockResolvedValue([]);
prismaMock.workout.findMany.mockResolvedValue([]);
prismaMock.user.findUnique.mockResolvedValue({
coachPrefsJson: null,
@@ -10,6 +10,7 @@ vi.mock("@/lib/db", () => ({
measurement: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
workout: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
},
@@ -26,6 +27,7 @@ const prismaMock = prisma as unknown as {
measurement: { findMany: ReturnType<typeof vi.fn> };
moodEntry: { findMany: ReturnType<typeof vi.fn> };
medicationIntakeEvent: { findMany: ReturnType<typeof vi.fn> };
medication: { findMany: ReturnType<typeof vi.fn> };
workout: { findMany: ReturnType<typeof vi.fn> };
user: { findUnique: ReturnType<typeof vi.fn> };
};
@@ -60,6 +62,7 @@ describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () =>
__resetCoachSnapshotCacheForTests();
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
prismaMock.medication.findMany.mockResolvedValue([]);
prismaMock.workout.findMany.mockResolvedValue([]);
// v1.4.23 H4 — snapshot now reads `User.coachPrefsJson`. Default
// null = legacy defaults so the existing test fixtures stay
@@ -13,6 +13,7 @@ vi.mock("@/lib/db", () => ({
measurement: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
},
}));
@@ -36,6 +37,7 @@ const prismaMock = prisma as unknown as {
measurement: { findMany: ReturnType<typeof vi.fn> };
moodEntry: { findMany: ReturnType<typeof vi.fn> };
medicationIntakeEvent: { findMany: ReturnType<typeof vi.fn> };
medication: { findMany: ReturnType<typeof vi.fn> };
user: { findUnique: ReturnType<typeof vi.fn> };
};
const featuresMock = extractFeatures as unknown as ReturnType<typeof vi.fn>;
@@ -68,6 +70,7 @@ describe("buildCoachSnapshot", () => {
prismaMock.measurement.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
prismaMock.medication.findMany.mockResolvedValue([]);
// v1.4.23 H4 — snapshot now reads `User.coachPrefsJson` to apply
// per-user excludeMetrics. Default null = use legacy defaults.
prismaMock.user.findUnique.mockResolvedValue({ coachPrefsJson: null });
+96 -26
View File
@@ -35,6 +35,13 @@ import { buildCoachMemoryBlock } from "./memory-snapshot";
import { buildTrajectorySnapshotBlock } from "./trajectory-snapshot";
import { buildCycleSnapshotBlock } from "./cycle-snapshot";
import { isCycleEnabled } from "@/lib/cycle/gate";
import {
buildComplianceLedgerRows,
buildComplianceMedicationContext,
lastNonSkippedTakenAt,
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history";
import type { BaselineProfile } from "@/lib/insights/derived";
import {
CLUSTER_PRIORITY,
@@ -753,12 +760,46 @@ async function buildCoachSnapshotImpl(
select: { moodLoggedAt: true, score: true },
})
: null;
const intakeRowsPromise = wantsCompliance
? prisma.medicationIntakeEvent.findMany({
// Tombstoned intake rows must never reach the Coach snapshot.
where: { userId, deletedAt: null, scheduledFor: { gte: cutoff } },
orderBy: { scheduledFor: "asc" },
select: { scheduledFor: true, takenAt: true, skipped: true },
// v1.16.9 — the adherence timeline derives from the LEDGER tally (the
// same band engine the compliance % + dose history consume), not from a
// raw intake-row count. The raw count read a worker-minted pending row
// as "not taken" (today's later doses dragged the day's rate down all
// morning) and double-counted cross-source duplicate rows on one slot.
// Load each medication's schedules + eras + window events so the ledger
// can be reconstructed per medication.
const complianceMedsPromise = wantsCompliance
? prisma.medication.findMany({
where: { userId },
select: {
id: true,
startsOn: true,
endsOn: true,
oneShot: true,
createdAt: true,
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
scheduleRevisions: {
orderBy: { validFrom: "asc" },
select: {
id: true,
validFrom: true,
validUntil: true,
payload: true,
supersededByRevisionId: true,
},
},
intakeEvents: {
// Tombstoned intake rows must never reach the Coach snapshot.
where: { deletedAt: null, scheduledFor: { gte: cutoff } },
orderBy: { scheduledFor: "asc" },
select: {
scheduledFor: true,
takenAt: true,
skipped: true,
autoMissed: true,
attributionSource: true,
},
},
},
})
: null;
const sleepRowsPromise = sources.has("sleep")
@@ -826,7 +867,7 @@ async function buildCoachSnapshotImpl(
const [
moodRows,
intakeRows,
complianceMeds,
sleepRows,
workoutRows,
glp1Block,
@@ -836,7 +877,7 @@ async function buildCoachSnapshotImpl(
cycleBlock,
] = await Promise.all([
moodRowsPromise,
intakeRowsPromise,
complianceMedsPromise,
sleepRowsPromise,
workoutRowsPromise,
glp1BlockPromise,
@@ -939,31 +980,60 @@ async function buildCoachSnapshotImpl(
registerBlock("mood", "mood");
}
if (wantsCompliance && intakeRows) {
// Medication compliance lives outside the structured features
// today — the legacy Coach surface labelled it as "general"
// provenance only. v1.4.20.1 ships a per-day adherence row built
// from the intake-event log so the Coach can answer "did I miss
// my dose on Tuesday?" without inventing the schedule. The read is
// issued in parallel above.
if (intakeRows.length > 0) {
// Per-day adherence rate within the recent window. Older days
// collapse into a single weekly bucket.
const recent = intakeRows.filter((r) => r.scheduledFor >= recentCutoff);
const olderRows = intakeRows.filter((r) => r.scheduledFor < recentCutoff);
if (wantsCompliance && complianceMeds) {
// Medication compliance lives outside the structured features today —
// the legacy Coach surface labelled it as "general" provenance only.
// v1.4.20.1 shipped a per-day adherence row; v1.16.9 derives it from
// the band-engine LEDGER (the same expansion the compliance % and the
// dose-history view consume): a slot counts against the rate only once
// it is genuinely missed, a pending/upcoming slot never reads as "not
// taken", deliberate skips and ad-hoc takes stay out of the
// denominator, and cross-source duplicate rows collapse onto one slot.
const ledgerRows: DoseHistoryRow[] = [];
for (const med of complianceMeds) {
if (med.schedules.length === 0) continue;
const ctx = buildComplianceMedicationContext(
med,
lastNonSkippedTakenAt(med.intakeEvents),
userTz,
);
ledgerRows.push(
...buildComplianceLedgerRows(
med.intakeEvents,
med.schedules,
ctx,
cutoff,
now,
now,
),
);
}
// Countable rows: taken (on-time or late) or genuinely missed. The
// pending / upcoming / skipped / ad-hoc rows carry no adherence signal.
const countable = ledgerRows.filter(
(r) =>
r.status === "taken_on_time" ||
r.status === "taken_late" ||
r.status === "missed",
);
if (countable.length > 0) {
const recent = countable.filter((r) => r.at >= recentCutoff);
const olderRows = countable.filter((r) => r.at < recentCutoff);
const isTaken = (r: DoseHistoryRow) =>
r.status === "taken_on_time" || r.status === "taken_late";
const recentByDay = new Map<
string,
{ date: Date; total: number; taken: number }
>();
for (const r of recent) {
const key = tzDayKey(r.scheduledFor, userTz);
const key = tzDayKey(r.at, userTz);
const e = recentByDay.get(key) ?? {
date: r.scheduledFor,
date: r.at,
total: 0,
taken: 0,
};
e.total += 1;
if (r.takenAt && !r.skipped) e.taken += 1;
if (isTaken(r)) e.taken += 1;
recentByDay.set(key, e);
}
const recentRows = Array.from(recentByDay.entries())
@@ -977,10 +1047,10 @@ async function buildCoachSnapshotImpl(
.sort((a, b) => a.date.localeCompare(b.date));
const olderByWeek = new Map<string, { taken: number; total: number }>();
for (const r of olderRows) {
const key = isoWeekKey(r.scheduledFor, userTz);
const key = isoWeekKey(r.at, userTz);
const e = olderByWeek.get(key) ?? { taken: 0, total: 0 };
e.total += 1;
if (r.takenAt && !r.skipped) e.taken += 1;
if (isTaken(r)) e.taken += 1;
olderByWeek.set(key, e);
}
const weeklyRows = Array.from(olderByWeek.entries())
@@ -995,7 +1065,7 @@ async function buildCoachSnapshotImpl(
timeline: { recent: recentRows, weekly: weeklyRows },
};
metrics.add("compliance");
counts.compliance = intakeRows.length;
counts.compliance = countable.length;
registerBlock("compliance", "compliance");
} else {
// v1.7.0 — toggled-on cluster with no rows. Annotate so the
+12 -1
View File
@@ -8,7 +8,10 @@ import { auditLog } from "@/lib/auth/audit";
import { profileSchema } from "@/lib/validations/auth";
import { isValidTimezone } from "@/lib/tz/format";
import { encrypt } from "@/lib/crypto";
import { invalidateUserProfile } from "@/lib/cache/invalidate";
import {
invalidateUserMedications,
invalidateUserProfile,
} from "@/lib/cache/invalidate";
import { z } from "zod/v4";
const extendedProfileSchema = profileSchema.extend({
@@ -168,6 +171,14 @@ export async function applyProfileUpdate(
invalidateUserProfile(userId);
}
// v1.16.9 — the medication payloads (list next-due, today counts, the
// compliance day buckets) are computed in the user's timezone, but the
// list cache key carries only the userId. A timezone change must evict
// the bucket or the next read serves the previous zone's day grid.
if (data.timezone !== undefined) {
invalidateUserMedications(userId, { evict: true });
}
await auditLog("profile.update", {
userId: updatedUser.id,
ipAddress: ipAddress ?? null,
+159
View File
@@ -451,6 +451,165 @@ describe("ServerCache stale-while-revalidate (wrapSwr / cachedSwr)", () => {
});
});
describe("ServerCache invalidation vs in-flight builders", () => {
it("a read right after an evict does NOT join the pre-write builder", async () => {
const cache = new ServerCache<string>({ maxEntries: 4, ttlMs: 60_000 });
let releaseFirst: ((v: string) => void) | null = null;
let calls = 0;
const builder = vi.fn(() => {
calls += 1;
if (calls === 1) {
return new Promise<string>((r) => {
releaseFirst = r;
});
}
return Promise.resolve("post-write");
});
// Builder 1 starts (reads pre-write state) and is still in flight…
const first = cache.wrap("u1|list", builder);
// …when a write evicts the key.
cache.delete("u1|list");
// The next read must start a FRESH build, not join the stale one.
const second = await cache.wrap("u1|list", builder);
expect(second.outcome).toBe("miss");
expect(second.value).toBe("post-write");
expect(builder).toHaveBeenCalledTimes(2);
// The detached pre-write builder resolves AFTER the fresh one — its
// commit must be discarded, never overwriting the post-write entry.
releaseFirst!("pre-write");
const firstResult = await first;
expect(firstResult.value).toBe("pre-write");
expect(cache.get("u1|list")).toBe("post-write");
});
it("deleteByPrefix fences an in-flight builder the same way", async () => {
const cache = new ServerCache<string>({ maxEntries: 4, ttlMs: 60_000 });
let release: ((v: string) => void) | null = null;
const builder = vi.fn(
() =>
new Promise<string>((r) => {
release = r;
}),
);
const inFlight = cache.wrap("u1|compliance", builder);
cache.deleteByPrefix("u1|");
release!("pre-write");
await inFlight;
// The pre-write result never entered the cache.
expect(cache.get("u1|compliance")).toBeNull();
});
it("an SWR refresh that completes after an evict does not re-cache the pre-write body", async () => {
vi.useFakeTimers();
const cache = new ServerCache<string>({
maxEntries: 4,
ttlMs: 1000,
staleTtlMs: 600_000,
});
let releaseRefresh: ((v: string) => void) | null = null;
let calls = 0;
const builder = vi.fn(() => {
calls += 1;
if (calls === 1) return Promise.resolve("v1");
if (calls === 2) {
return new Promise<string>((r) => {
releaseRefresh = r;
});
}
return Promise.resolve("post-write");
});
await cache.wrapSwr("u1|list", builder);
vi.advanceTimersByTime(1500);
// Stale read kicks off the detached background refresh (pre-write).
const stale = await cache.wrapSwr("u1|list", builder);
expect(stale.outcome).toBe("stale");
// A write evicts the bucket while the refresh is still in flight…
cache.deleteByPrefix("u1|");
// …then the pre-write refresh completes. Without the fence it would
// re-insert the pre-write body as FRESH for the full TTL.
releaseRefresh!("pre-write");
await vi.runAllTimersAsync();
expect(cache.get("u1|list")).toBeNull();
const next = await cache.wrapSwr("u1|list", builder);
expect(next.outcome).toBe("miss");
expect(next.value).toBe("post-write");
});
it("markStaleByPrefix discards an in-flight refresh and keeps SWR semantics", async () => {
vi.useFakeTimers();
const cache = new ServerCache<string>({
maxEntries: 4,
ttlMs: 1000,
staleTtlMs: 600_000,
});
let releaseRefresh: ((v: string) => void) | null = null;
let calls = 0;
const builder = vi.fn(() => {
calls += 1;
if (calls === 1) return Promise.resolve("v1");
if (calls === 2) {
return new Promise<string>((r) => {
releaseRefresh = r;
});
}
return Promise.resolve("post-write");
});
await cache.wrapSwr("u1|k", builder);
vi.advanceTimersByTime(1500);
const stale = await cache.wrapSwr("u1|k", builder);
expect(stale.outcome).toBe("stale");
// Background write marks the bucket stale mid-refresh; the pre-write
// refresh result must not land as a fresh entry.
cache.markStaleByPrefix("u1|");
releaseRefresh!("pre-write");
await vi.runAllTimersAsync();
// SWR semantics hold: the prior value still serves stale and a NEW
// (post-write) recompute is kicked off.
const next = await cache.wrapSwr("u1|k", builder);
expect(next.outcome).toBe("stale");
expect(next.value).toBe("v1");
await vi.runAllTimersAsync();
expect(builder).toHaveBeenCalledTimes(3);
const warm = await cache.wrapSwr("u1|k", builder);
expect(warm.outcome).toBe("hit");
expect(warm.value).toBe("post-write");
});
it("invalidation between two stampeding readers gives the later reader fresh data", async () => {
const cache = new ServerCache<string>({ maxEntries: 4, ttlMs: 60_000 });
let release: ((v: string) => void) | null = null;
let calls = 0;
const builder = vi.fn(() => {
calls += 1;
if (calls === 1) {
return new Promise<string>((r) => {
release = r;
});
}
return Promise.resolve("fresh");
});
const a = cache.wrap("k", builder); // pre-write build in flight
cache.delete("k"); // write
const b = cache.wrap("k", builder); // post-write read → fresh build
release!("stale");
const [ra, rb] = await Promise.all([a, b]);
expect(ra.value).toBe("stale"); // joined its own (pre-write) build
expect(rb.value).toBe("fresh");
expect(cache.get("k")).toBe("fresh");
});
});
describe("global cache registry", () => {
it("exposes the eight blueprint caches as module-scope singletons", () => {
expect(caches.analytics).toBeDefined();
+32
View File
@@ -246,6 +246,38 @@ export function invalidateUserInsightsLayout(userId: string): void {
caches.insightsLayout.deleteByPrefix(userId);
}
/**
* v1.16.9 — full per-user cache eviction for the wipe / restore paths.
* A data wipe or a backup restore replaces (or removes) every row the
* cached payloads were built on, so every per-user bucket hard-evicts —
* a marked-stale SWR cell would keep serving the pre-wipe body.
*/
export function invalidateUserData(userId: string): void {
caches.analytics.deleteByPrefix(`${userId}|`);
caches.medications.deleteByPrefix(userId);
caches.medicationCompliance.deleteByPrefix(`${userId}|`);
caches.medicationsIntake.deleteByPrefix(`${userId}|`);
caches.achievements.deleteByPrefix(userId);
caches.insightsTargets.deleteByPrefix(userId);
caches.insightsDerived.deleteByPrefix(`${userId}|`);
caches.moodAnalytics.deleteByPrefix(userId);
caches.moodInsights.deleteByPrefix(userId);
caches.workouts.deleteByPrefix(`${userId}|`);
caches.dashboardWidgets.deleteByPrefix(userId);
caches.insightsLayout.deleteByPrefix(userId);
}
/**
* v1.16.9 — bucket-wide clear for the admin all-user wipe. Every cache
* empties; the next read per user is a cold build over the (now empty /
* restored) tables. Acceptable for an operation this rare.
*/
export function invalidateAllCaches(): void {
for (const cache of Object.values(caches)) {
cache.deleteByPrefix("");
}
}
/**
* Global eviction of the bug-report-status cache.
*
+96 -26
View File
@@ -67,12 +67,30 @@ export interface ServerCacheStats {
readonly stampedes: number;
}
/**
* v1.16.9 — single-flight slot with invalidation fencing. A builder that
* was in flight when an invalidation (`delete` / `deleteByPrefix` /
* `markStaleByPrefix`) hit its key must NOT commit its result: the build
* read pre-write data, and committing it after the eviction would
* re-cache the pre-write body as fresh for the full TTL. The slot is the
* fence — invalidation flips `invalidated` and detaches the slot from
* `pending`, so (a) the builder's `.then` skips the `set()` and (b) the
* next read starts a fresh build instead of joining the stale one.
* Callers already awaiting the detached promise still receive its value
* (equivalent to having read a moment before the write); it just never
* enters the cache.
*/
interface PendingBuild<T> {
promise: Promise<T>;
invalidated: boolean;
}
export class ServerCache<T> {
private readonly map = new Map<
string,
{ expiresAt: number; staleUntil: number; value: T }
>();
private readonly pending = new Map<string, Promise<T>>();
private readonly pending = new Map<string, PendingBuild<T>>();
private hits = 0;
private misses = 0;
private evictions = 0;
@@ -80,6 +98,59 @@ export class ServerCache<T> {
constructor(private readonly opts: ServerCacheOptions) {}
/**
* Fence + detach the in-flight builder for one key (no-op when none).
* Part of every invalidation op — see `PendingBuild`.
*/
private detachPending(key: string): void {
const slot = this.pending.get(key);
if (slot) {
slot.invalidated = true;
this.pending.delete(key);
}
}
/** Fence + detach every in-flight builder under `prefix`. */
private detachPendingByPrefix(prefix: string): void {
for (const [key, slot] of this.pending) {
if (key.startsWith(prefix)) {
slot.invalidated = true;
this.pending.delete(key);
}
}
}
/**
* Register a fenced single-flight build for `key`. The commit only
* lands when no invalidation hit the key while the builder ran; the
* `finally` only clears the slot it owns (an invalidation may already
* have detached it and a newer build may occupy the key).
*/
private startBuild(
key: string,
builder: () => Promise<T>,
ttlMsOverride?: number,
): PendingBuild<T> {
const slot: PendingBuild<T> = {
promise: undefined as unknown as Promise<T>,
invalidated: false,
};
slot.promise = builder()
.then((value) => {
if (!slot.invalidated) {
this.set(key, value, ttlMsOverride);
}
return value;
})
.finally(() => {
if (this.pending.get(key) === slot) {
this.pending.delete(key);
}
});
this.pending.set(key, slot);
return slot;
}
/**
* Read a key. Returns null on miss, expired entry, or absent entry.
* Side effect: an expired entry is removed; a live entry is
@@ -178,6 +249,12 @@ export class ServerCache<T> {
* behaviour).
*/
markStaleByPrefix(prefix: string): number {
// v1.16.9 — fence in-flight builders too: a refresh that started
// before the write would otherwise complete after this mark and
// re-cache the pre-write body as FRESH for the full TTL. Detaching
// keeps the SWR contract — the marked entry still serves stale, and
// the next read kicks off a fresh (post-write) recompute.
this.detachPendingByPrefix(prefix);
let marked = 0;
const now = Date.now();
for (const [key, entry] of this.map) {
@@ -191,6 +268,9 @@ export class ServerCache<T> {
/** Single-key eviction. Returns true when the key was present. */
delete(key: string): boolean {
// v1.16.9 — fence the in-flight builder so a pre-write build can
// neither be joined by post-evict reads nor commit its result.
this.detachPending(key);
return this.map.delete(key);
}
@@ -203,6 +283,8 @@ export class ServerCache<T> {
* possible cache-key variant the user might have warmed.
*/
deleteByPrefix(prefix: string): number {
// v1.16.9 — fence in-flight builders under the prefix (see `delete`).
this.detachPendingByPrefix(prefix);
let removed = 0;
for (const key of this.map.keys()) {
if (key.startsWith(prefix)) {
@@ -238,21 +320,13 @@ export class ServerCache<T> {
const inFlight = this.pending.get(key);
if (inFlight) {
this.stampedes += 1;
const value = await inFlight;
const value = await inFlight.promise;
return { value, outcome: "stampede" };
}
this.misses += 1;
const promise = builder()
.then((value) => {
this.set(key, value, ttlMsOverride);
return value;
})
.finally(() => {
this.pending.delete(key);
});
this.pending.set(key, promise);
const value = await promise;
const slot = this.startBuild(key, builder, ttlMsOverride);
const value = await slot.promise;
return { value, outcome: "miss" };
}
@@ -288,20 +362,11 @@ export class ServerCache<T> {
// single-flight slot so concurrent stale reads share one rebuild.
this.hits += 1;
if (!this.pending.has(key)) {
const refresh = builder()
.then((value) => {
this.set(key, value, ttlMsOverride);
return value;
})
.catch((err) => {
// The stale value already went out; don't crash the worker.
// Re-throw so the join path of any awaiting caller sees it.
throw err;
})
.finally(() => {
this.pending.delete(key);
});
this.pending.set(key, refresh);
// v1.16.9 — the background refresh runs through the same fenced
// single-flight slot as `wrap`, so an invalidation that lands
// while it is in flight discards its (pre-write) result instead
// of re-caching it as fresh.
const refresh = this.startBuild(key, builder, ttlMsOverride).promise;
// Detach: the foreground caller does not await the rebuild.
// Handle the rejection on the detached handle so an
// unhandled-rejection warning never fires — but never silently:
@@ -344,6 +409,11 @@ export class ServerCache<T> {
/** Reset every counter + entry. Test-only escape hatch. */
__resetForTests(): void {
this.map.clear();
// Fence any in-flight builder so a build crossing a test boundary
// cannot commit into the next test's clean cache.
for (const slot of this.pending.values()) {
slot.invalidated = true;
}
this.pending.clear();
this.hits = 0;
this.misses = 0;
+20
View File
@@ -321,6 +321,16 @@ export interface DashboardLayout {
comparisonBaseline?: ComparisonBaseline;
/** v1.4.18 — per-chart overlay-prefs (3 toggles per chart card). */
chartOverlayPrefs?: ChartOverlayPrefsMap;
/**
* Dashboard hero (daily verdict) visibility. Piggy-backs on the
* layout blob like the B8 comparison baseline — a UI affordance, not
* an analytical attribute, so no Prisma migration. The resolver
* clamps anything that is not the literal `false` back to `true`
* (legacy blobs without the field render the hero by default; a
* stale client cannot poison it with a non-boolean), and the
* serializer persists the resolved boolean explicitly.
*/
heroVisible?: boolean;
}
const DASHBOARD_LAYOUT_VERSION = 1;
@@ -332,6 +342,9 @@ const DASHBOARD_LAYOUT_VERSION = 1;
*/
export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
version: DASHBOARD_LAYOUT_VERSION,
// Hero (daily verdict) is on by default; users opt out via
// Settings → Dashboard.
heroVisible: true,
widgets: [
{ id: "weight", visible: true, tileVisible: true, order: 0 },
{ id: "bp", visible: true, tileVisible: true, order: 1 },
@@ -449,6 +462,10 @@ export function resolveDashboardLayout(raw: unknown): DashboardLayout {
// client cannot poison the dashboard with values the renderer
// doesn't know how to draw.
chartOverlayPrefs: coerceChartOverlayPrefsMap(candidate.chartOverlayPrefs),
// Hero visibility — anything that is not the literal `false`
// clamps to `true` (default-on for legacy blobs and malformed
// values alike).
heroVisible: candidate.heroVisible !== false,
};
}
@@ -476,5 +493,8 @@ export function serializeDashboardLayout(
// v1.4.18 — persist per-chart overlay prefs verbatim through the
// same coercion the resolver runs so the wire shape is stable.
chartOverlayPrefs: coerceChartOverlayPrefsMap(layout.chartOverlayPrefs),
// Persist hero visibility explicitly (same clamp as the resolver)
// so a re-read never has to guess the default.
heroVisible: layout.heroVisible !== false,
};
}
@@ -0,0 +1,333 @@
/**
* `buildMedsTodayBlock` — integration-style unit tests with a mocked
* Prisma client and a mocked projector.
*
* Pinned contracts:
* - the idempotent projection runs BEFORE the today-window read (so
* the tally always sees the freshly minted pending rows) with the
* user's local-day bounds;
* - the tally semantics match the summary route's compliance tile
* exactly (taken = `takenAt` set and not skipped);
* - the next-due pick is the EARLIEST display-due across active
* medications (real recurrence engine, no engine mocks);
* - an open overdue slot surfaces with `nextDueOverdue: true` and a
* resolved slot at the same anchor advances to the next future one;
* - the today window is computed in the USER's timezone (asserted on
* the where-clause bounds for a non-European zone).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const projectTodayIntakesAndRecompute = vi.fn();
vi.mock("@/lib/medications/scheduling/project-today-intakes", () => ({
projectTodayIntakesAndRecompute: (...a: unknown[]) =>
projectTodayIntakesAndRecompute(...a),
}));
import { buildMedsTodayBlock } from "../meds-today";
/** Noon Berlin (CEST) on a fixed summer day. */
const NOW = new Date("2026-06-10T10:00:00.000Z");
const BERLIN = "Europe/Berlin";
interface FakeIntakeRow {
medicationId?: string;
scheduledFor?: Date;
takenAt: Date | null;
skipped: boolean;
}
function dailySchedule(
overrides: Record<string, unknown> = {},
): Record<string, unknown> {
return {
id: "sched-1",
medicationId: "med-1",
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["08:00"],
reminderGraceMinutes: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
...overrides,
};
}
function medication(
overrides: Record<string, unknown> = {},
): Record<string, unknown> {
return {
id: "med-1",
name: "Testmittel",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
schedules: [dailySchedule()],
...overrides,
};
}
/** Call-order ledger so the projection-before-read contract is assertable. */
let callOrder: string[];
let medications: Array<Record<string, unknown>>;
let todayRows: FakeIntakeRow[];
let resolvedRows: Array<{
medicationId: string;
scheduledFor: Date;
takenAt: Date | null;
}>;
let latestIntakeGroups: Array<{
medicationId: string;
_max: { takenAt: Date | null };
}>;
let eraFloorGroups: Array<{
medicationId: string;
_max: { validUntil: Date | null };
}>;
let capturedTodayWhere: Record<string, unknown> | null;
const fakePrisma = {
medication: {
findMany: vi.fn(async () => {
callOrder.push("medication.findMany");
return medications;
}),
},
medicationIntakeEvent: {
findMany: vi.fn(
async (args: { where: { OR?: unknown; scheduledFor?: unknown } }) => {
if (args.where.OR) {
callOrder.push("resolved.findMany");
return resolvedRows;
}
callOrder.push("today.findMany");
capturedTodayWhere = args.where as Record<string, unknown>;
return todayRows;
},
),
groupBy: vi.fn(async () => {
callOrder.push("latest.groupBy");
return latestIntakeGroups;
}),
},
medicationScheduleRevision: {
groupBy: vi.fn(async () => {
callOrder.push("era.groupBy");
return eraFloorGroups;
}),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
beforeEach(() => {
vi.clearAllMocks();
callOrder = [];
medications = [];
todayRows = [];
resolvedRows = [];
latestIntakeGroups = [];
eraFloorGroups = [];
capturedTodayWhere = null;
projectTodayIntakesAndRecompute.mockImplementation(async () => {
callOrder.push("project");
return { projected: 0, backfilled: 0 };
});
});
describe("buildMedsTodayBlock — projection contract", () => {
it("projects the user's local today BEFORE every read (idempotency seam)", async () => {
await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW);
expect(projectTodayIntakesAndRecompute).toHaveBeenCalledTimes(1);
const arg = projectTodayIntakesAndRecompute.mock.calls[0][0] as {
userId: string;
userTz: string;
todayStart: Date;
todayEnd: Date;
};
expect(arg.userId).toBe("user-1");
expect(arg.userTz).toBe(BERLIN);
// Berlin local midnight (CEST = UTC+2) → 22:00Z the prior evening.
expect(arg.todayStart.toISOString()).toBe("2026-06-09T22:00:00.000Z");
expect(arg.todayEnd.toISOString()).toBe("2026-06-10T22:00:00.000Z");
// Projection strictly first — the tally must see the minted rows.
expect(callOrder[0]).toBe("project");
expect(callOrder.slice(1)).toContain("today.findMany");
});
it("computes the today window in the user's zone, not the server's", async () => {
// Pacific/Auckland (UTC+12, no DST in June): 10:00Z = 22:00 local,
// so local-today spans [2026-06-09T12:00Z, 2026-06-10T12:00Z).
await buildMedsTodayBlock(fakePrisma, "user-1", "Pacific/Auckland", NOW);
const where = capturedTodayWhere as {
scheduledFor: { gte: Date; lt: Date };
deletedAt: null;
};
expect(where.scheduledFor.gte.toISOString()).toBe(
"2026-06-09T12:00:00.000Z",
);
expect(where.scheduledFor.lt.toISOString()).toBe(
"2026-06-10T12:00:00.000Z",
);
expect(where.deletedAt).toBeNull();
});
});
describe("buildMedsTodayBlock — tally", () => {
it("counts scheduled / taken / skipped with the summary-tile semantics", async () => {
todayRows = [
{ takenAt: new Date("2026-06-10T06:05:00.000Z"), skipped: false },
{ takenAt: new Date("2026-06-10T08:00:00.000Z"), skipped: false },
{ takenAt: null, skipped: true }, // deliberate skip
{ takenAt: null, skipped: false }, // still-open pending
];
const block = await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW);
expect(block.scheduledToday).toBe(4);
expect(block.takenToday).toBe(2);
expect(block.skippedToday).toBe(1);
});
it("returns a zeroed block for an account with no medications", async () => {
const block = await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW);
expect(block).toEqual({
activeCount: 0,
scheduledToday: 0,
takenToday: 0,
skippedToday: 0,
nextDueAt: null,
nextDueOverdue: false,
nextDueMedicationName: null,
});
});
it("reads only ACTIVE medications", async () => {
await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW);
const arg = fakePrisma.medication.findMany.mock.calls[0][0] as {
where: { userId: string; active: boolean };
};
expect(arg.where).toEqual({ userId: "user-1", active: true });
});
});
describe("buildMedsTodayBlock — next due (real engine)", () => {
it("picks the EARLIEST future slot across medications, with its name", async () => {
// 18:00 Berlin = 16:00Z; 20:00 Berlin = 18:00Z. Yesterday's slots
// are past their catch-up bands (daily tail 4 h), so the engine
// lands on today's anchors.
medications = [
medication({
id: "med-late",
name: "Spätmittel",
schedules: [
dailySchedule({
id: "sched-late",
medicationId: "med-late",
windowStart: "20:00",
windowEnd: "21:00",
timesOfDay: ["20:00"],
}),
],
}),
medication({
id: "med-evening",
name: "Abendmittel",
schedules: [
dailySchedule({
id: "sched-evening",
medicationId: "med-evening",
windowStart: "18:00",
windowEnd: "19:00",
timesOfDay: ["18:00"],
}),
],
}),
];
const block = await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW);
expect(block.activeCount).toBe(2);
expect(block.nextDueAt).toBe("2026-06-10T16:00:00.000Z");
expect(block.nextDueOverdue).toBe(false);
expect(block.nextDueMedicationName).toBe("Abendmittel");
});
it("surfaces an open overdue slot (anchor passed, band still open)", async () => {
// 11:00 Berlin anchor = 09:00Z; now is 12:00 Berlin — one hour past
// the anchor, well inside the daily 4 h catch-up band.
medications = [
medication({
name: "Mittagsmittel",
schedules: [
dailySchedule({
windowStart: "11:00",
windowEnd: "12:00",
timesOfDay: ["11:00"],
}),
],
}),
];
const block = await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW);
expect(block.nextDueAt).toBe("2026-06-10T09:00:00.000Z");
expect(block.nextDueOverdue).toBe(true);
expect(block.nextDueMedicationName).toBe("Mittagsmittel");
});
it("advances past a RESOLVED slot to the next future anchor", async () => {
medications = [
medication({
name: "Mittagsmittel",
schedules: [
dailySchedule({
windowStart: "11:00",
windowEnd: "12:00",
timesOfDay: ["11:00"],
}),
],
}),
];
// The 11:00-Berlin slot was acted on — taken row at the anchor.
resolvedRows = [
{
medicationId: "med-1",
scheduledFor: new Date("2026-06-10T09:00:00.000Z"),
// Slot-anchored take (attributed): takenAt differs from the anchor.
takenAt: new Date("2026-06-10T09:05:00.000Z"),
},
];
const block = await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW);
// Tomorrow's 11:00 Berlin anchor.
expect(block.nextDueAt).toBe("2026-06-11T09:00:00.000Z");
expect(block.nextDueOverdue).toBe(false);
});
it("returns null next-due when no schedule has an upcoming slot", async () => {
// Course ended last month — the engine yields nothing.
medications = [
medication({
name: "Altmittel",
endsOn: new Date("2026-05-01T00:00:00.000Z"),
}),
];
const block = await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW);
expect(block.activeCount).toBe(1);
expect(block.nextDueAt).toBeNull();
expect(block.nextDueOverdue).toBe(false);
expect(block.nextDueMedicationName).toBeNull();
});
});
@@ -21,6 +21,8 @@ const ensureUserMoodRollupsFresh = vi.fn();
const computeBpInTargetFastPath = vi.fn();
const getAssistantFlags = vi.fn();
const hasAnyConfiguredProvider = vi.fn();
const buildMedsTodayBlock = vi.fn();
const computeUserHealthScoreFastPath = vi.fn();
vi.mock("@/lib/analytics/summaries-slice", () => ({
computeSummariesSlice: (...a: unknown[]) => computeSummariesSlice(...a),
@@ -46,6 +48,15 @@ vi.mock("@/lib/feature-flags", () => ({
vi.mock("@/lib/ai/provider", () => ({
hasAnyConfiguredProvider: (...a: unknown[]) => hasAnyConfiguredProvider(...a),
}));
// Meds-today + health-score builders are tested in their own suites;
// here only the assembly contract (fast vs warm phase) is pinned.
vi.mock("@/lib/dashboard/meds-today", () => ({
buildMedsTodayBlock: (...a: unknown[]) => buildMedsTodayBlock(...a),
}));
vi.mock("@/lib/analytics/health-score-fast-path", () => ({
computeUserHealthScoreFastPath: (...a: unknown[]) =>
computeUserHealthScoreFastPath(...a),
}));
import {
buildDashboardSnapshot,
@@ -112,6 +123,16 @@ beforeEach(() => {
fakePrisma.measurement.findMany.mockResolvedValue([]);
fakePrisma.moodEntry.findMany.mockResolvedValue([]);
hasAnyConfiguredProvider.mockResolvedValue(true);
buildMedsTodayBlock.mockResolvedValue({
activeCount: 1,
scheduledToday: 2,
takenToday: 1,
skippedToday: 0,
nextDueAt: null,
nextDueOverdue: false,
nextDueMedicationName: null,
});
computeUserHealthScoreFastPath.mockResolvedValue(null);
});
describe("buildDashboardSnapshot — envelope shape", () => {
@@ -195,6 +216,104 @@ describe("buildDashboardSnapshot — two-phase null extras", () => {
});
});
describe("buildDashboardSnapshot — medsToday (fast phase)", () => {
it("rides the fast phase and is present even on a rollup-coverage miss", async () => {
probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", false]]));
isFullyCovered.mockReturnValue(false);
const snap = await buildDashboardSnapshot(fakePrisma, baseUser());
expect(snap.medsToday).toEqual({
activeCount: 1,
scheduledToday: 2,
takenToday: 1,
skippedToday: 0,
nextDueAt: null,
nextDueOverdue: false,
nextDueMedicationName: null,
});
// Built from the user's id + timezone, not from any body field.
expect(buildMedsTodayBlock).toHaveBeenCalledWith(
fakePrisma,
"user-1",
"Europe/Berlin",
expect.any(Date),
);
});
});
describe("buildDashboardSnapshot — healthScore (warm phase only)", () => {
it("is null on a rollup-coverage miss and the fast path never runs", async () => {
probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", false]]));
isFullyCovered.mockReturnValue(false);
const snap = await buildDashboardSnapshot(fakePrisma, baseUser());
expect(snap.healthScore).toBeNull();
expect(computeUserHealthScoreFastPath).not.toHaveBeenCalled();
});
it("serialises score + band + delta — and ONLY those — when warm", async () => {
const coverageMap = new Map([["WEIGHT", true]]);
probeRollupCoverage.mockResolvedValue(coverageMap);
isFullyCovered.mockReturnValue(true);
computeBpInTargetFastPath.mockResolvedValue({
last7Days: { pct: 70 },
last30Days: { pct: 80 },
allTime: { pct: 75 },
priorMonth: { pct: 60 },
priorYear: { pct: 50 },
gradedScore: 88,
});
computeUserHealthScoreFastPath.mockResolvedValue({
score: 72,
band: "yellow",
delta: -12,
components: { bp: {}, weight: {}, mood: {}, compliance: {} },
});
const snap = await buildDashboardSnapshot(fakePrisma, baseUser());
// Score + band + delta only — components never reach the wire.
expect(snap.healthScore).toEqual({ score: 72, band: "yellow", delta: -12 });
// The fast path reuses the BP windows + coverage map already
// computed for `extras` (no re-probe, graded score threaded).
expect(computeUserHealthScoreFastPath).toHaveBeenCalledTimes(1);
const arg = computeUserHealthScoreFastPath.mock.calls[0][0] as {
userId: string;
bpInTargetPct: number | null;
bpGradedScore: number | null;
heightCm: number | null;
coverage: unknown;
};
expect(arg.userId).toBe("user-1");
expect(arg.bpInTargetPct).toBe(80);
expect(arg.bpGradedScore).toBe(88);
expect(arg.heightCm).toBe(180);
expect(arg.coverage).toBe(coverageMap);
});
it("is null when warm but no pillar is computable (fast path returns null)", async () => {
probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", true]]));
isFullyCovered.mockReturnValue(true);
computeBpInTargetFastPath.mockResolvedValue({
last7Days: null,
last30Days: null,
allTime: null,
priorMonth: null,
priorYear: null,
gradedScore: null,
});
computeUserHealthScoreFastPath.mockResolvedValue(null);
const snap = await buildDashboardSnapshot(fakePrisma, baseUser());
expect(snap.extras).not.toBeNull();
expect(snap.healthScore).toBeNull();
});
});
describe("buildDashboardSnapshot — briefingState matrix", () => {
beforeEach(() => {
probeRollupCoverage.mockResolvedValue(new Map());
@@ -507,6 +626,8 @@ describe("buildDashboardSnapshot — additive proof", () => {
expect(snap).toHaveProperty("metricStates");
expect(snap).toHaveProperty("layoutCatalogue");
expect(snap).toHaveProperty("briefingStale");
expect(snap).toHaveProperty("medsToday");
expect(snap).toHaveProperty("healthScore");
// The web-consumed `layout` block is the resolved per-user layout —
// NOT replaced by the 35-id catalogue.
@@ -0,0 +1,134 @@
import { describe, it, expect } from "vitest";
import { readFileSync, existsSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
/**
* Client-safety pin for the verdict resolver's import graph.
*
* `resolveDashboardVerdict` runs inside the `"use client"` dashboard
* hero, so every VALUE import reachable from `verdict.ts` must stay off
* the server graph: no prisma, no `node:` builtins, no pg-boss, no
* `@/lib/jobs/coach-nudge` (whose dispatcher/feature-flag imports pull
* `node:async_hooks` — the exact regression class the dose-window
* editor hit when it imported `DOSE_WINDOW_DEFAULTS` from
* `@/lib/analytics/compliance`; see
* `src/lib/medications/scheduling/dose-window-defaults.ts`). The shared
* nudge thresholds therefore live in the dependency-free
* `@/lib/jobs/coach-nudge-thresholds` leaf.
*
* `import type` statements are erased by the compiler AND the bundler,
* so the walker skips them — the `DashboardSnapshot` type import from
* `@/lib/dashboard/snapshot` (a heavy server module) is legal exactly
* as long as it stays type-only. If someone drops the `type` keyword,
* the walker traverses into the snapshot builder and this suite fails.
*/
const SRC = join(process.cwd(), "src");
const ENTRY = join(SRC, "lib/dashboard/verdict.ts");
/** Specifier patterns that mark a server-only dependency. */
const BANNED_SPECIFIERS: RegExp[] = [
/^node:/,
/^@\/generated\//,
/^@\/lib\/db$/,
/^@prisma\//,
/^pg-boss$/,
/^server-only$/,
/^@\/lib\/jobs\/coach-nudge$/,
];
/**
* Extract VALUE-import specifiers from a module. Type-only statements
* (`import type … from`, `export type … from`) are erased at build
* time and skipped. Side-effect imports (`import "x"`) are included.
*/
function valueImportSpecifiers(source: string): string[] {
const out: string[] = [];
// Statement-anchored (`^import` / `^export`) and semicolon-bounded
// (`[^;]*?`) so the lazy scan can never swallow across statements or
// latch onto a `from "…"` inside an unrelated string / comment.
const stmt =
/^(import|export)(\s+type)?\s+[^;"']*?from\s*["']([^"']+)["']|^import\s*["']([^"']+)["']/gm;
let m: RegExpExecArray | null;
while ((m = stmt.exec(source)) !== null) {
if (m[4] !== undefined) {
out.push(m[4]); // bare side-effect import
continue;
}
if (m[2] !== undefined) continue; // `import type` / `export type` — erased
out.push(m[3]);
}
return out;
}
/** Resolve `@/…` and relative specifiers to a file inside `src/`. */
function resolveLocal(spec: string, fromFile: string): string | null {
let base: string;
if (spec.startsWith("@/")) base = join(SRC, spec.slice(2));
else if (spec.startsWith(".")) base = resolve(dirname(fromFile), spec);
else return null; // external package
for (const candidate of [
base,
`${base}.ts`,
`${base}.tsx`,
join(base, "index.ts"),
join(base, "index.tsx"),
]) {
if (existsSync(candidate) && statSync(candidate).isFile()) {
return candidate;
}
}
throw new Error(`unresolvable local import "${spec}" from ${fromFile}`);
}
function walkGraph(entry: string): {
visited: Set<string>;
specifiers: Set<string>;
} {
const visited = new Set<string>();
const specifiers = new Set<string>();
const queue = [entry];
while (queue.length > 0) {
const file = queue.pop()!;
if (visited.has(file)) continue;
visited.add(file);
for (const spec of valueImportSpecifiers(readFileSync(file, "utf8"))) {
specifiers.add(spec);
const local = resolveLocal(spec, file);
if (local !== null) queue.push(local);
}
}
return { visited, specifiers };
}
describe("verdict.ts — client-safe import graph", () => {
const { visited, specifiers } = walkGraph(ENTRY);
it("carries no server-only specifier anywhere in the value-import graph", () => {
const offenders = [...specifiers].filter((spec) =>
BANNED_SPECIFIERS.some((re) => re.test(spec)),
);
expect(offenders).toEqual([]);
});
it("never traverses into the snapshot builder (type-only boundary holds)", () => {
const snapshotModule = join(SRC, "lib/dashboard/snapshot.ts");
expect(visited.has(snapshotModule)).toBe(false);
});
it("reads the nudge thresholds from the dependency-free leaf", () => {
expect(specifiers.has("@/lib/jobs/coach-nudge-thresholds")).toBe(true);
const leaf = readFileSync(
join(SRC, "lib/jobs/coach-nudge-thresholds.ts"),
"utf8",
);
expect(valueImportSpecifiers(leaf)).toEqual([]);
});
it("the whole graph stays local — no external runtime package rides in", () => {
const external = [...specifiers].filter(
(spec) => !spec.startsWith("@/") && !spec.startsWith("."),
);
expect(external).toEqual([]);
});
});
+705
View File
@@ -0,0 +1,705 @@
/**
* Dashboard hero — verdict-ladder unit tests.
*
* `resolveDashboardVerdict` is pure (snapshot in, verdict out, `now`
* injected), so the suite pins every rung, the first-hit-wins
* precedence, and the exact boundaries the spec locked:
* - bpCritical fires at sys 180, not 179 (fixed floors, never user
* thresholds), only on a fresh reading, and outranks an overdue
* dose (a crisis-level reading is rung 1);
* - weightDrift fires at a 0.50 kg distance delta, not 0.49;
* - silence fires at 7 days, not 6;
* - scoreDrop fires at −10, not −9;
* - doseUpcoming is timezone-correct (next anchor must be today in
* the USER's zone) and never fires on a stale past `nextDueAt`
* with `nextDueOverdue: false` (the cached-snapshot defensive
* contract).
*/
import { describe, it, expect } from "vitest";
import { resolveDashboardVerdict } from "../verdict";
import type { DashboardSnapshot } from "../snapshot";
import type { MedsTodayBlock } from "../meds-today";
import type { DataSummary } from "@/lib/analytics/trends";
/** Noon Berlin (CEST) on a fixed summer day. */
const NOW = new Date("2026-06-10T10:00:00.000Z");
function summary(partial: Partial<DataSummary>): DataSummary {
return {
count: 0,
latest: null,
min: null,
max: null,
mean: null,
median: null,
avg7: null,
avg30: null,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
...partial,
};
}
function medsToday(partial: Partial<MedsTodayBlock> = {}): MedsTodayBlock {
return {
activeCount: 0,
scheduledToday: 0,
takenToday: 0,
skippedToday: 0,
nextDueAt: null,
nextDueOverdue: false,
nextDueMedicationName: null,
...partial,
};
}
function baseSnapshot(
overrides: Partial<DashboardSnapshot> = {},
): DashboardSnapshot {
return {
user: {
username: "tester",
timezone: "Europe/Berlin",
heightCm: null,
dateOfBirth: null,
gender: null,
glucoseUnit: null,
onboardingTourCompleted: true,
greetingHour: 12,
},
layout: { version: 1, widgets: [] },
layoutCatalogue: [],
metricStates: {},
tiles: {
summaries: {},
lastSeenByType: {},
mood: { summary: null, entries: [] },
},
extras: null,
medsToday: medsToday(),
healthScore: null,
briefing: null,
briefingState: "preparing",
briefingUpdatedAt: null,
briefingStale: false,
generatedAt: NOW.toISOString(),
...overrides,
};
}
function isoHoursAgo(hours: number, now: Date = NOW): string {
return new Date(now.getTime() - hours * 60 * 60 * 1000).toISOString();
}
const FRESH_FINDING = {
tone: "info" as const,
headline: "Steps held steady this week.",
detail: "Daily totals stayed within the usual range.",
delta: null,
sourceWindow: "7d" as const,
sourceMetric: "steps" as const,
};
const WATCH_FINDING = {
tone: "watch" as const,
headline: "Systolic crept up over seven days.",
detail: "The weekly mean sits above the prior week.",
delta: "+4 mmHg",
sourceWindow: "7d" as const,
sourceMetric: "bp" as const,
};
describe("rung 2 — doseOverdue", () => {
it("fires on nextDueOverdue with the medication name and a quick-entry CTA", () => {
const verdict = resolveDashboardVerdict(
baseSnapshot({
medsToday: medsToday({
scheduledToday: 2,
nextDueAt: isoHoursAgo(1),
nextDueOverdue: true,
nextDueMedicationName: "Metformin",
}),
}),
NOW,
);
expect(verdict.variant).toBe("doseOverdue");
expect(verdict.values).toEqual({ name: "Metformin" });
expect(verdict.cta).toEqual({
kind: "quickEntry",
target: "medicationIntake",
});
});
it("yields to a simultaneously critical fresh BP reading (precedence)", () => {
// A crisis-level reading (≥ 180 / ≥ 110, ≤ 1 day old) is rung 1 —
// it must not hide behind a routine medication prompt.
const verdict = resolveDashboardVerdict(
baseSnapshot({
medsToday: medsToday({
nextDueOverdue: true,
nextDueMedicationName: "Metformin",
}),
tiles: {
summaries: {
BLOOD_PRESSURE_SYS: summary({ latest: 200, count: 5 }),
BLOOD_PRESSURE_DIA: summary({ latest: 120, count: 5 }),
},
lastSeenByType: {
BLOOD_PRESSURE_SYS: { lastSeenAt: isoHoursAgo(1), daysAgo: 0 },
},
mood: { summary: null, entries: [] },
},
}),
NOW,
);
expect(verdict.variant).toBe("bpCritical");
});
it("wins over the BP rung once the critical reading has aged past a day", () => {
const verdict = resolveDashboardVerdict(
baseSnapshot({
medsToday: medsToday({
nextDueOverdue: true,
nextDueMedicationName: "Metformin",
}),
tiles: {
summaries: {
BLOOD_PRESSURE_SYS: summary({ latest: 200, count: 5 }),
BLOOD_PRESSURE_DIA: summary({ latest: 120, count: 5 }),
},
lastSeenByType: {
BLOOD_PRESSURE_SYS: { lastSeenAt: isoHoursAgo(3 * 24), daysAgo: 3 },
},
mood: { summary: null, entries: [] },
},
}),
NOW,
);
expect(verdict.variant).toBe("doseOverdue");
});
it("never renders overdue from a stale past nextDueAt with the flag false", () => {
// Cached-snapshot defensive contract: the anchor passed AFTER the
// snapshot was built, so the flag is still false — plain summary.
const verdict = resolveDashboardVerdict(
baseSnapshot({
medsToday: medsToday({
scheduledToday: 1,
nextDueAt: isoHoursAgo(1),
nextDueOverdue: false,
nextDueMedicationName: "Metformin",
}),
}),
NOW,
);
expect(verdict.variant).toBe("allQuiet");
});
});
describe("rung 1 — bpCritical", () => {
function bpSnapshot(
sys: number | null,
dia: number | null,
lastSeenAt: string,
): DashboardSnapshot {
return baseSnapshot({
tiles: {
summaries: {
...(sys !== null && {
BLOOD_PRESSURE_SYS: summary({ latest: sys, count: 5 }),
}),
...(dia !== null && {
BLOOD_PRESSURE_DIA: summary({ latest: dia, count: 5 }),
}),
},
lastSeenByType: {
BLOOD_PRESSURE_SYS: { lastSeenAt, daysAgo: 0 },
},
mood: { summary: null, entries: [] },
},
});
}
it("fires at the fixed systolic floor 180 with a fresh reading", () => {
const verdict = resolveDashboardVerdict(
bpSnapshot(180, 95, isoHoursAgo(2)),
NOW,
);
expect(verdict.variant).toBe("bpCritical");
expect(verdict.values).toEqual({ sys: 180, dia: 95 });
expect(verdict.cta).toEqual({
kind: "link",
href: "/insights/blood-pressure",
});
});
it("does NOT fire at 179 (boundary)", () => {
const verdict = resolveDashboardVerdict(
bpSnapshot(179, 95, isoHoursAgo(2)),
NOW,
);
expect(verdict.variant).toBe("allQuiet");
});
it("fires on the diastolic floor 110 alone", () => {
const verdict = resolveDashboardVerdict(
bpSnapshot(150, 110, isoHoursAgo(2)),
NOW,
);
expect(verdict.variant).toBe("bpCritical");
expect(verdict.values).toEqual({ sys: 150, dia: 110 });
});
it("does NOT fire at dia 109 (boundary)", () => {
const verdict = resolveDashboardVerdict(
bpSnapshot(150, 109, isoHoursAgo(2)),
NOW,
);
expect(verdict.variant).toBe("allQuiet");
});
it("skips a critical-valued reading older than one day", () => {
const verdict = resolveDashboardVerdict(
bpSnapshot(190, 120, isoHoursAgo(3 * 24)),
NOW,
);
expect(verdict.variant).toBe("allQuiet");
});
it("still fires inside the one-day freshness window (daysAgo floor 1)", () => {
const verdict = resolveDashboardVerdict(
bpSnapshot(190, 120, isoHoursAgo(36)),
NOW,
);
expect(verdict.variant).toBe("bpCritical");
});
it("re-derives freshness from the injected now, not the snapshot daysAgo", () => {
// The snapshot claims daysAgo: 0 (computed at build time), but the
// injected `now` sits three days later — the rung must skip.
const snap = bpSnapshot(190, 120, isoHoursAgo(1));
const later = new Date(NOW.getTime() + 3 * 24 * 60 * 60 * 1000);
expect(resolveDashboardVerdict(snap, later).variant).toBe("allQuiet");
// Same snapshot evaluated at its build instant fires.
expect(resolveDashboardVerdict(snap, NOW).variant).toBe("bpCritical");
});
});
describe("rung 3 — doseUpcoming", () => {
function upcomingSnapshot(
nextDueAt: string,
tz = "Europe/Berlin",
): DashboardSnapshot {
const snap = baseSnapshot({
medsToday: medsToday({
scheduledToday: 2,
takenToday: 1,
skippedToday: 0,
nextDueAt,
nextDueOverdue: false,
nextDueMedicationName: "Ramipril",
}),
});
snap.user.timezone = tz;
return snap;
}
it("fires for a dose due within two hours today, with local HH:mm", () => {
// 11:30 UTC = 13:30 Berlin (CEST).
const verdict = resolveDashboardVerdict(
upcomingSnapshot("2026-06-10T11:30:00.000Z"),
NOW,
);
expect(verdict.variant).toBe("doseUpcoming");
expect(verdict.values).toEqual({ time: "13:30", name: "Ramipril" });
expect(verdict.cta).toEqual({
kind: "quickEntry",
target: "medicationIntake",
});
});
it("does NOT fire when the dose is more than two hours away", () => {
const verdict = resolveDashboardVerdict(
upcomingSnapshot("2026-06-10T12:30:00.000Z"),
NOW,
);
expect(verdict.variant).toBe("allQuiet");
});
it("fires exactly at the two-hour boundary", () => {
const verdict = resolveDashboardVerdict(
upcomingSnapshot("2026-06-10T12:00:00.000Z"),
NOW,
);
expect(verdict.variant).toBe("doseUpcoming");
});
it("does NOT fire when the next anchor falls on tomorrow in the user's zone", () => {
// 23:30 Berlin: a dose 1 h away sits at 00:30 LOCAL tomorrow even
// though it is still 2026-06-10 in UTC terms of distance.
const lateNow = new Date("2026-06-10T21:30:00.000Z"); // 23:30 Berlin
const verdict = resolveDashboardVerdict(
upcomingSnapshot("2026-06-10T22:30:00.000Z"),
lateNow,
);
expect(verdict.variant).toBe("allQuiet");
});
it("respects a non-European zone for the same instants", () => {
// Pacific/Auckland (UTC+12, no DST in June): 10:00 UTC = 22:00
// local; a dose at 11:30 UTC = 23:30 local is still TODAY there.
const verdict = resolveDashboardVerdict(
upcomingSnapshot("2026-06-10T11:30:00.000Z", "Pacific/Auckland"),
NOW,
);
expect(verdict.variant).toBe("doseUpcoming");
expect(verdict.values).toEqual({ time: "23:30", name: "Ramipril" });
});
it("does NOT fire when every scheduled dose is already resolved", () => {
const snap = upcomingSnapshot("2026-06-10T11:30:00.000Z");
snap.medsToday.takenToday = 1;
snap.medsToday.skippedToday = 1; // 2 scheduled, 2 resolved
expect(resolveDashboardVerdict(snap, NOW).variant).toBe("allQuiet");
});
});
describe("rung 4 — weightDrift", () => {
// height 180 cm → green band [59.94, 80.676] kg.
const GREEN_MAX_180 = 24.9 * 1.8 * 1.8;
function weightSnapshot(
avg7: number | null,
avg30: number | null,
heightCm: number | null = 180,
): DashboardSnapshot {
const snap = baseSnapshot({
tiles: {
summaries: {
WEIGHT: summary({ latest: avg7, avg7, avg30, count: 20 }),
},
lastSeenByType: {
WEIGHT: { lastSeenAt: isoHoursAgo(2), daysAgo: 0 },
},
mood: { summary: null, entries: [] },
},
});
snap.user.heightCm = heightCm;
return snap;
}
it("fires at exactly the 0.50 kg drift threshold", () => {
// avg30 inside the band (distance 0), avg7 exactly 0.5 kg outside.
const verdict = resolveDashboardVerdict(
weightSnapshot(GREEN_MAX_180 + 0.5, GREEN_MAX_180 - 1),
NOW,
);
expect(verdict.variant).toBe("weightDrift");
expect(verdict.cta).toEqual({ kind: "link", href: "/insights/weight" });
});
it("does NOT fire at 0.49 kg drift (boundary)", () => {
const verdict = resolveDashboardVerdict(
weightSnapshot(GREEN_MAX_180 + 0.49, GREEN_MAX_180 - 1),
NOW,
);
expect(verdict.variant).toBe("allQuiet");
});
it("skips entirely when no height is stored (no derivable range)", () => {
const verdict = resolveDashboardVerdict(
weightSnapshot(GREEN_MAX_180 + 5, GREEN_MAX_180 - 1, null),
NOW,
);
expect(verdict.variant).toBe("allQuiet");
});
it("skips when avg7 or avg30 is null", () => {
expect(resolveDashboardVerdict(weightSnapshot(null, 75), NOW).variant).toBe(
"allQuiet",
);
expect(
resolveDashboardVerdict(weightSnapshot(GREEN_MAX_180 + 5, null), NOW)
.variant,
).toBe("allQuiet");
});
it("stays silent while converging back toward the range", () => {
// avg7 closer to the band than avg30 — drift is negative.
const verdict = resolveDashboardVerdict(
weightSnapshot(GREEN_MAX_180 + 1, GREEN_MAX_180 + 3),
NOW,
);
expect(verdict.variant).toBe("allQuiet");
});
});
describe("rung 5 — shortNights", () => {
function sleepSnapshot(
avg7: number | null,
count: number,
): DashboardSnapshot {
return baseSnapshot({
tiles: {
summaries: {
SLEEP_DURATION: summary({ avg7, count, latest: avg7 }),
},
lastSeenByType: {
SLEEP_DURATION: { lastSeenAt: isoHoursAgo(8), daysAgo: 0 },
},
mood: { summary: null, entries: [] },
},
});
}
it("fires under the clear-margin floor (6.5 h) with enough readings", () => {
const verdict = resolveDashboardVerdict(sleepSnapshot(360, 7), NOW);
expect(verdict.variant).toBe("shortNights");
expect(verdict.values).toEqual({ hours: 6 });
expect(verdict.cta).toEqual({ kind: "link", href: "/insights/sleep" });
});
it("fires just under the 390-minute floor, not at it (boundary)", () => {
expect(resolveDashboardVerdict(sleepSnapshot(389, 7), NOW).variant).toBe(
"shortNights",
);
expect(resolveDashboardVerdict(sleepSnapshot(390, 7), NOW).variant).toBe(
"allQuiet",
);
});
it("stays silent on a sparse week (fewer than 5 readings)", () => {
expect(resolveDashboardVerdict(sleepSnapshot(300, 4), NOW).variant).toBe(
"allQuiet",
);
});
});
describe("rung 6 — silence", () => {
function silenceSnapshot(
seen: Record<string, { lastSeenAt: string; daysAgo: number } | null>,
): DashboardSnapshot {
return baseSnapshot({
tiles: {
summaries: {},
lastSeenByType: seen,
mood: { summary: null, entries: [] },
},
});
}
it("fires when every logged-ever core vital is silent for ≥ 7 days", () => {
const verdict = resolveDashboardVerdict(
silenceSnapshot({
WEIGHT: { lastSeenAt: isoHoursAgo(8 * 24), daysAgo: 8 },
BLOOD_PRESSURE_SYS: { lastSeenAt: isoHoursAgo(9 * 24), daysAgo: 9 },
// glucose never logged — excluded from the min.
}),
NOW,
);
expect(verdict.variant).toBe("silence");
expect(verdict.values).toEqual({ days: 8 });
expect(verdict.cta).toEqual({ kind: "quickEntry", target: "measurement" });
});
it("does NOT fire at 6 days (boundary), fires at exactly 7", () => {
expect(
resolveDashboardVerdict(
silenceSnapshot({
WEIGHT: { lastSeenAt: isoHoursAgo(6 * 24), daysAgo: 6 },
}),
NOW,
).variant,
).toBe("allQuiet");
expect(
resolveDashboardVerdict(
silenceSnapshot({
WEIGHT: { lastSeenAt: isoHoursAgo(7 * 24), daysAgo: 7 },
}),
NOW,
).variant,
).toBe("silence");
});
it("a single fresh vital silences the rung (min, not max)", () => {
const verdict = resolveDashboardVerdict(
silenceSnapshot({
WEIGHT: { lastSeenAt: isoHoursAgo(30 * 24), daysAgo: 30 },
BLOOD_GLUCOSE: { lastSeenAt: isoHoursAgo(2), daysAgo: 0 },
}),
NOW,
);
expect(verdict.variant).toBe("allQuiet");
});
it("skips when none of the three vitals was ever logged", () => {
expect(resolveDashboardVerdict(silenceSnapshot({}), NOW).variant).toBe(
"allQuiet",
);
});
});
describe("rung 7 — scoreDrop", () => {
function scoreSnapshot(delta: number | null): DashboardSnapshot {
return baseSnapshot({
healthScore: { score: 60, band: "yellow", delta },
});
}
it("fires at a −10 delta (boundary)", () => {
const verdict = resolveDashboardVerdict(scoreSnapshot(-10), NOW);
expect(verdict.variant).toBe("scoreDrop");
expect(verdict.values).toEqual({ points: 10 });
expect(verdict.cta).toEqual({ kind: "link", href: "/insights" });
});
it("does NOT fire at −9 (boundary)", () => {
expect(resolveDashboardVerdict(scoreSnapshot(-9), NOW).variant).toBe(
"allQuiet",
);
});
it("skips a null delta and a null score block", () => {
expect(resolveDashboardVerdict(scoreSnapshot(null), NOW).variant).toBe(
"allQuiet",
);
expect(
resolveDashboardVerdict(baseSnapshot({ healthScore: null }), NOW).variant,
).toBe("allQuiet");
});
});
describe("rung 8 — briefing", () => {
it("renders the first watch-tone headline verbatim from a fresh briefing", () => {
const verdict = resolveDashboardVerdict(
baseSnapshot({
briefing: {
paragraph: "Calm day overall.",
keyFindings: [FRESH_FINDING, WATCH_FINDING],
},
briefingState: "ready",
briefingStale: false,
briefingUpdatedAt: NOW.toISOString(),
}),
NOW,
);
expect(verdict.variant).toBe("briefing");
expect(verdict.values).toEqual({ headline: WATCH_FINDING.headline });
expect(verdict.cta).toEqual({ kind: "link", href: "/insights" });
});
it("falls back to the first finding when no watch tone exists", () => {
const verdict = resolveDashboardVerdict(
baseSnapshot({
briefing: {
paragraph: "Calm day overall.",
keyFindings: [FRESH_FINDING],
},
briefingState: "ready",
briefingStale: false,
}),
NOW,
);
expect(verdict.variant).toBe("briefing");
expect(verdict.values).toEqual({ headline: FRESH_FINDING.headline });
});
it("skips a STALE briefing even in ready-shaped delivery", () => {
const verdict = resolveDashboardVerdict(
baseSnapshot({
briefing: {
paragraph: "Yesterday's text.",
keyFindings: [WATCH_FINDING],
},
briefingState: "preparing",
briefingStale: true,
}),
NOW,
);
expect(verdict.variant).toBe("allQuiet");
});
it("skips ready + stale (belt and braces on the two flags)", () => {
const verdict = resolveDashboardVerdict(
baseSnapshot({
briefing: {
paragraph: "Old text.",
keyFindings: [WATCH_FINDING],
},
briefingState: "ready",
briefingStale: true,
}),
NOW,
);
expect(verdict.variant).toBe("allQuiet");
});
it("skips an empty keyFindings array", () => {
const verdict = resolveDashboardVerdict(
baseSnapshot({
briefing: { paragraph: "Flat data day.", keyFindings: [] },
briefingState: "ready",
briefingStale: false,
}),
NOW,
);
expect(verdict.variant).toBe("allQuiet");
});
});
describe("rung 9 — allQuiet + full-ladder precedence", () => {
it("returns allQuiet with no CTA on an empty snapshot", () => {
const verdict = resolveDashboardVerdict(baseSnapshot(), NOW);
expect(verdict).toEqual({ variant: "allQuiet", values: {}, cta: null });
});
it("bpCritical outranks every lower rung when stacked", () => {
// BP critical + short nights + score drop + fresh briefing — the
// ladder must stop at rung 2.
const verdict = resolveDashboardVerdict(
baseSnapshot({
tiles: {
summaries: {
BLOOD_PRESSURE_SYS: summary({ latest: 185, count: 5 }),
BLOOD_PRESSURE_DIA: summary({ latest: 100, count: 5 }),
SLEEP_DURATION: summary({ avg7: 300, count: 7 }),
},
lastSeenByType: {
BLOOD_PRESSURE_SYS: { lastSeenAt: isoHoursAgo(1), daysAgo: 0 },
SLEEP_DURATION: { lastSeenAt: isoHoursAgo(8), daysAgo: 0 },
},
mood: { summary: null, entries: [] },
},
healthScore: { score: 40, band: "red", delta: -20 },
briefing: {
paragraph: "Busy day.",
keyFindings: [WATCH_FINDING],
},
briefingState: "ready",
briefingStale: false,
}),
NOW,
);
expect(verdict.variant).toBe("bpCritical");
});
it("scoreDrop outranks the briefing teaser", () => {
const verdict = resolveDashboardVerdict(
baseSnapshot({
healthScore: { score: 50, band: "yellow", delta: -15 },
briefing: {
paragraph: "Busy day.",
keyFindings: [WATCH_FINDING],
},
briefingState: "ready",
briefingStale: false,
}),
NOW,
);
expect(verdict.variant).toBe("scoreDrop");
expect(verdict.values).toEqual({ points: 15 });
});
});
+215
View File
@@ -0,0 +1,215 @@
/**
* Today's medication block for the dashboard hero + summary tally.
*
* `buildMedsTodayBlock` is the one shared builder behind every surface
* that answers "how is the medication day going?":
*
* 1. **Projection** — `projectTodayIntakesAndRecompute` idempotently
* mints the day's pending `MedicationIntakeEvent` rows for active
* schedules (same helper the intake route + dashboard summary use),
* so the tally below never under-counts a daily med whose rows the
* reminder worker has not minted yet.
* 2. **Tally** — one today-window event read produces
* `scheduledToday` / `takenToday` / `skippedToday` with the exact
* semantics the summary route's compliance tile always had
* (taken = `takenAt !== null && !skipped`; tombstones excluded).
* 3. **Next due** — the same four feeder reads +
* `computeDisplayDue` loop the medications list route runs
* (`src/app/api/medications/route.ts`), restricted to ACTIVE
* medications: latest non-skipped intake per med (rolling cadences
* re-anchor on it), resolved slots over the overdue-lookback
* horizon, and the current-era floor per med. The block surfaces
* the EARLIEST display-due across medications — an open overdue
* slot sits in the past, so it naturally wins over any future slot.
*
* The feeder reads are not lifted out of the medications list route
* because that route reuses the same rows for its own response fields
* (`lastTakenAt`, `todayEventCount`, per-card next-due); the clean
* shared seam is `computeDisplayDue` itself, which both call sites go
* through.
*
* Consumer contract (snapshot caching): `nextDueAt` / `nextDueOverdue`
* are computed at build time and may be served from a cache. A
* `nextDueAt` in the past with `nextDueOverdue: false` means the slot's
* anchor passed AFTER the block was built — render the plain day
* summary, never an overdue state, until a fresh block arrives.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
import {
computeDisplayDue,
OVERDUE_LOOKBACK_MS,
toResolvedSlotMark,
type ResolvedSlotMark,
} from "@/lib/medications/scheduling/next-due";
import { getUserTodayBounds } from "@/lib/tz/local-day";
export interface MedsTodayBlock {
/** Active medications (paused courses excluded). */
activeCount: number;
/** Intake-event rows scheduled in the user's local today (all states). */
scheduledToday: number;
/** Rows with `takenAt` set and not skipped. */
takenToday: number;
/** Rows the user deliberately skipped. */
skippedToday: number;
/**
* Earliest display-due instant across active medications (ISO8601),
* or null when nothing is due (no schedules, course ended, all PRN).
* May lie outside today — the consumer gates on the local day.
*/
nextDueAt: string | null;
/**
* True when `nextDueAt` is an OPEN overdue slot (anchor passed, still
* inside its catch-up band, unresolved). A cached `nextDueAt` in the
* past with `nextDueOverdue: false` must render as the plain summary,
* never as overdue — see the module doc.
*/
nextDueOverdue: boolean;
/** Name of the medication carrying `nextDueAt`; null when none due. */
nextDueMedicationName: string | null;
}
export async function buildMedsTodayBlock(
prisma: PrismaClient,
userId: string,
userTz: string,
now: Date,
): Promise<MedsTodayBlock> {
const { start: todayStart, end: todayEnd } = getUserTodayBounds(now, userTz);
// The projector + tally read use an exclusive upper bound (`lt`),
// matching the summary route's window convention.
const todayEndExclusive = new Date(todayStart.getTime() + 24 * 60 * 60 * 1000);
// Projection FIRST so the tally read below sees the freshly minted
// pending rows. Idempotent — `skipDuplicates` + the
// `(userId, medicationId, scheduledFor, source)` unique index make a
// concurrent intake-route hit safe.
await projectTodayIntakesAndRecompute({
userId,
userTz,
todayStart,
todayEnd: todayEndExclusive,
});
// Resolved-slot window — same horizon the medications list route uses:
// back as far as the widest band tail (so a long-resolved past slot
// cannot resurface as overdue), forward two days for the lookahead.
const resolvedWindowStart = new Date(
todayStart.getTime() - OVERDUE_LOOKBACK_MS,
);
const resolvedWindowEnd = new Date(
todayEnd.getTime() + 2 * 24 * 60 * 60 * 1000,
);
const [medications, todayEvents, latestIntakes, resolvedEvents, eraFloors] =
await Promise.all([
prisma.medication.findMany({
where: { userId, active: true },
include: { schedules: true },
orderBy: { createdAt: "desc" },
}),
prisma.medicationIntakeEvent.findMany({
where: {
userId,
deletedAt: null,
scheduledFor: { gte: todayStart, lt: todayEndExclusive },
},
select: { takenAt: true, skipped: true },
}),
prisma.medicationIntakeEvent.groupBy({
by: ["medicationId"],
where: {
userId,
deletedAt: null,
skipped: false,
takenAt: { not: null },
},
_max: { takenAt: true },
}),
prisma.medicationIntakeEvent.findMany({
where: {
userId,
deletedAt: null,
scheduledFor: { gte: resolvedWindowStart, lte: resolvedWindowEnd },
OR: [
{ takenAt: { not: null } },
{ skipped: true },
{ autoMissed: true },
],
},
// v1.16.9 — `takenAt` rides along so an ad-hoc row (`scheduledFor
// === takenAt`) cannot ±6h-resolve a different slot.
select: { medicationId: true, scheduledFor: true, takenAt: true },
}),
// Current-era floor per medication: the newest revision's
// `validUntil` is where the LIVE schedule rows became valid; the
// open-overdue search must not reach past it into a previous
// era's cadence.
prisma.medicationScheduleRevision.groupBy({
by: ["medicationId"],
where: { medication: { userId }, supersededByRevisionId: null },
_max: { validUntil: true },
}),
]);
const takenToday = todayEvents.filter(
(e) => e.takenAt !== null && !e.skipped,
).length;
const skippedToday = todayEvents.filter((e) => e.skipped).length;
const lastTakenAtByMedId = new Map<string, Date | null>(
latestIntakes.map((entry) => [entry.medicationId, entry._max.takenAt]),
);
const resolvedSlotsByMedId = new Map<string, ResolvedSlotMark[]>();
for (const e of resolvedEvents) {
const mark = toResolvedSlotMark(e);
const list = resolvedSlotsByMedId.get(e.medicationId);
if (list) list.push(mark);
else resolvedSlotsByMedId.set(e.medicationId, [mark]);
}
const eraStartByMedId = new Map<string, Date>();
for (const f of eraFloors) {
if (f._max.validUntil) eraStartByMedId.set(f.medicationId, f._max.validUntil);
}
// Earliest display-due across active medications. An open overdue
// slot's anchor is in the past, so the minimum naturally prefers it
// over any future slot.
let nextDueAt: Date | null = null;
let nextDueOverdue = false;
let nextDueMedicationName: string | null = null;
for (const m of medications) {
const display = computeDisplayDue({
medication: {
id: m.id,
startsOn: m.startsOn,
endsOn: m.endsOn,
oneShot: m.oneShot,
createdAt: m.createdAt,
},
schedules: m.schedules,
now,
userTz,
lastIntakeAt: lastTakenAtByMedId.get(m.id) ?? null,
resolvedSlots: resolvedSlotsByMedId.get(m.id) ?? [],
eraStart: eraStartByMedId.get(m.id) ?? null,
});
if (!display) continue;
if (nextDueAt === null || display.at.getTime() < nextDueAt.getTime()) {
nextDueAt = display.at;
nextDueOverdue = display.overdue;
nextDueMedicationName = m.name;
}
}
return {
activeCount: medications.length,
scheduledToday: todayEvents.length,
takenToday,
skippedToday,
nextDueAt: nextDueAt ? nextDueAt.toISOString() : null,
nextDueOverdue,
nextDueMedicationName,
};
}
+96 -18
View File
@@ -66,6 +66,12 @@ import { dailyBriefingSchema, type DailyBriefing } from "@/lib/ai/schema";
import { getAssistantFlags } from "@/lib/feature-flags";
import { hasAnyConfiguredProvider } from "@/lib/ai/provider";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import {
buildMedsTodayBlock,
type MedsTodayBlock,
} from "@/lib/dashboard/meds-today";
import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path";
import type { HealthScoreBand } from "@/lib/analytics/health-score";
/** Briefing freshness window — mirrors the 24 h TTL on the advisor cache. */
const BRIEFING_TTL_MS = 24 * 60 * 60 * 1000;
@@ -213,6 +219,18 @@ export interface DashboardLayoutCatalogueEntry {
order: number;
}
/**
* Health-score summary for the hero — score + band + week-over-week
* delta ONLY. The per-pillar component breakdown is deliberately NOT
* serialised here; the analytics route remains the one surface that
* exposes components.
*/
export interface DashboardSnapshotHealthScore {
score: number;
band: HealthScoreBand;
delta: number | null;
}
export interface DashboardSnapshot {
user: DashboardSnapshotUser;
layout: DashboardLayout;
@@ -241,6 +259,24 @@ export interface DashboardSnapshot {
};
/** Thick phase — null on a rollup-coverage miss. */
extras: DashboardSnapshotExtras | null;
/**
* Fast phase — today's medication block (projection-backed tally +
* earliest next-due across active medications).
*
* Caching contract: the snapshot is served read-through a cache, so
* `medsToday.nextDueAt` can sit in the past with
* `medsToday.nextDueOverdue: false` when the slot's anchor passed
* AFTER the snapshot was built. Consumers MUST render that state as
* the plain day summary — never as overdue — until a fresh snapshot
* carries `nextDueOverdue: true` from the band model itself.
*/
medsToday: MedsTodayBlock;
/**
* Warm-phase health score (score + band + delta only — no
* components). `null` on a rollup-coverage miss (the score rides the
* thick phase alongside `extras`) and when no pillar is computable.
*/
healthScore: DashboardSnapshotHealthScore | null;
briefing: DailyBriefing | null;
briefingState: BriefingState;
briefingUpdatedAt: string | null;
@@ -377,29 +413,40 @@ async function buildMoodBlock(
}
/**
* Thick slice — BD-Zielbereich + per-context glucose. Only called by
* the builder when the rollup tier is warm (`isFullyCovered`); the BP
* fast-path then stays on the sub-second rollup branch and never drops
* into the multi-second live fallback that would make the whole strip
* wait. On a coverage miss the builder skips this entirely and emits
* `extras: null` (per-tile shimmer until the boot backfill converges).
* Thick slice — BD-Zielbereich + per-context glucose + health score.
* Only called by the builder when the rollup tier is warm
* (`isFullyCovered`); the BP fast-path then stays on the sub-second
* rollup branch and never drops into the multi-second live fallback
* that would make the whole strip wait. On a coverage miss the builder
* skips this entirely and emits `extras: null` + `healthScore: null`
* (per-tile shimmer until the boot backfill converges).
*
* The health score rides this warm phase because it reuses the BP
* windows already computed here (`gradedScore` + `last30Days.pct`) and
* the same coverage map, so the score's weight pillar also stays on
* the rollup branch.
*/
async function buildExtras(
prisma: PrismaClient,
user: SnapshotUserInput,
userTz: string,
coverage: RollupCoverageMap,
): Promise<DashboardSnapshotExtras> {
now: Date,
time: <T>(label: string, fn: () => Promise<T>) => Promise<T>,
): Promise<{
extras: DashboardSnapshotExtras;
healthScore: DashboardSnapshotHealthScore | null;
}> {
let bpInTargetPct: number | null = null;
let bpInTargetPct7d: number | null = null;
let bpInTargetPct30d: number | null = null;
let bpInTargetPctAllTime: number | null = null;
let bpInTargetPctPriorMonth: number | null = null;
let bpInTargetPctPriorYear: number | null = null;
let bpGradedScore: number | null = null;
const bpTargets = getBpTargets(user.dateOfBirth);
if (bpTargets) {
const now = new Date();
const windows = await computeBpInTargetFastPath({
userId: user.id,
targets: bpTargets,
@@ -413,8 +460,30 @@ async function buildExtras(
bpInTargetPctAllTime = windows.allTime?.pct ?? null;
bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null;
bpInTargetPctPriorYear = windows.priorYear?.pct ?? null;
bpGradedScore = windows.gradedScore ?? null;
}
// Health score — reuses the BP windows captured above plus the
// already-probed coverage map (no second probe). Score + band +
// delta only; the component breakdown stays off this wire.
const scoreResult = await time("healthScore", () =>
computeUserHealthScoreFastPath({
userId: user.id,
bpInTargetPct,
bpGradedScore,
heightCm: user.heightCm,
now,
coverage,
}),
);
const healthScore: DashboardSnapshotHealthScore | null = scoreResult
? {
score: scoreResult.score,
band: scoreResult.band,
delta: scoreResult.delta,
}
: null;
const glucoseSince = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const glucoseRows = await prisma.measurement.findMany({
where: {
@@ -440,13 +509,16 @@ async function buildExtras(
}
return {
bpInTargetPct,
bpInTargetPct7d,
bpInTargetPct30d,
bpInTargetPctAllTime,
bpInTargetPctPriorMonth,
bpInTargetPctPriorYear,
glucoseByContext,
extras: {
bpInTargetPct,
bpInTargetPct7d,
bpInTargetPct30d,
bpInTargetPctAllTime,
bpInTargetPctPriorMonth,
bpInTargetPctPriorYear,
glucoseByContext,
},
healthScore,
};
}
@@ -646,15 +718,19 @@ export async function buildDashboardSnapshot(
const coverage = await time("coverage", () => probeRollupCoverage(user.id));
const warm = isFullyCovered(coverage);
const [slim, mood, extras, flags] = await Promise.all([
const [slim, mood, extrasResult, flags, medsToday] = await Promise.all([
// A5 — reuse the coverage map already probed above so the slice
// doesn't re-run the identical `probeRollupCoverage` query.
time("summaries", () => computeSummariesSlice(user.id, coverage)),
time("mood", () => buildMoodBlock(prisma, user.id)),
warm
? time("extras", () => buildExtras(prisma, user, userTz, coverage))
? time("extras", () =>
buildExtras(prisma, user, userTz, coverage, now, time),
)
: Promise.resolve(null),
time("flags", () => getAssistantFlags()),
// Fast phase — projection-backed today tally + earliest next-due.
time("medsToday", () => buildMedsTodayBlock(prisma, user.id, userTz, now)),
]);
const layout = resolveDashboardLayout(user.dashboardWidgetsJson);
@@ -689,7 +765,9 @@ export async function buildDashboardSnapshot(
entries: mood.entries,
},
},
extras,
extras: extrasResult?.extras ?? null,
medsToday,
healthScore: extrasResult?.healthScore ?? null,
briefing: briefing.briefing,
briefingState: briefing.briefingState,
briefingUpdatedAt: briefing.briefingUpdatedAt,
+286
View File
@@ -0,0 +1,286 @@
/**
* Dashboard hero — daily-verdict resolver.
*
* Pure function over an already-built `DashboardSnapshot`: no DB, no
* network, no clock read (`now` is injected so the resolver is
* deterministic under test and a cached snapshot can be re-evaluated
* against the current instant).
*
* Nine rungs, first hit wins. The ordering is a severity ladder:
* the fixed-floor BP emergency first (a fresh crisis-level reading
* outranks an overdue dose), then the actionable medication states,
* then trend nudges, then the briefing teaser, then the all-quiet
* fallback. Threshold constants are imported from the client-safe
* coach-nudge-thresholds leaf where one exists so the hero and the
* nudge cron can never disagree on what "drift" or "deficit" means —
* and so this module never pulls the cron's server graph into a
* client bundle (this resolver runs inside `"use client"` code).
*
* Freshness is re-derived from the snapshot's ISO timestamps against
* the injected `now` (not the snapshot's own `daysAgo` fields) so a
* snapshot served from cache ages honestly.
*
* Defensive contract (cache staleness): a `medsToday.nextDueAt` in the
* past with `nextDueOverdue: false` means the slot's anchor passed
* after the snapshot was built. Rung 2 keys ONLY on the server-computed
* `nextDueOverdue` flag and rung 3 requires `nextDueAt >= now`, so that
* state falls through to the plain summary — never renders as overdue.
*/
import {
COACH_NUDGE_WEIGHT_DRIFT_KG,
COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H,
} from "@/lib/jobs/coach-nudge-thresholds";
import { buildWeightRangeFromHeight } from "@/lib/analytics/value-bands";
import type { DashboardSnapshot } from "@/lib/dashboard/snapshot";
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Fixed clinical floors for the BP-critical rung (ESC/ESH grade-3 /
* hypertensive-crisis territory). Deliberately NOT the user's
* personal targets — a user-relaxed threshold must never silence a
* 180/110 reading on the hero.
*/
const BP_CRITICAL_SYS_FLOOR = 180;
const BP_CRITICAL_DIA_FLOOR = 110;
/** A critical reading older than this many days is history, not an alert. */
const BP_CRITICAL_MAX_DAYS_AGO = 1;
/** Rung 3 — surface a dose at most this far before its anchor. */
const DOSE_UPCOMING_WINDOW_MS = 2 * 60 * 60 * 1000;
/** Rung 5 — nightly floor (hours) the short-nights rung measures against. */
const SHORT_NIGHTS_FLOOR_H = 7;
/** Rung 5 — minimum readings in the summary window before the rung fires. */
const SHORT_NIGHTS_MIN_COUNT = 5;
/** Rung 6 — every logged-ever core vital silent for at least this long. */
const SILENCE_MIN_DAYS = 7;
/** Rung 6 — the core vitals the silence rung watches. */
const SILENCE_TYPES = [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_GLUCOSE",
] as const;
/** Rung 7 — week-over-week score drop (points) that fires. */
const SCORE_DROP_MIN_POINTS = 10;
export type DashboardVerdictVariant =
| "doseOverdue"
| "bpCritical"
| "doseUpcoming"
| "weightDrift"
| "shortNights"
| "silence"
| "scoreDrop"
| "briefing"
| "allQuiet";
export type DashboardVerdictCta =
| { kind: "quickEntry"; target: "medicationIntake" | "measurement" }
| { kind: "link"; href: string };
export interface DashboardVerdict {
variant: DashboardVerdictVariant;
/**
* Interpolation values for the variant's i18n message. `briefing`
* carries the model-authored headline VERBATIM under `headline`;
* renderers must emit it as plain text children (no HTML, no
* markdown) per the repo-wide no-markdown-renderer rule.
*/
values: Record<string, string | number>;
cta: DashboardVerdictCta | null;
}
/** YYYY-MM-DD key of `d`'s calendar day in `tz`. */
function dayKeyInTz(d: Date, tz: string): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(d);
}
/** HH:mm wall-clock of `d` in `tz`. */
function hmInTz(d: Date, tz: string): string {
const parts = new Intl.DateTimeFormat("en-GB", {
timeZone: tz,
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(d);
// Some ICU builds emit "24:xx" for midnight under hour12: false.
return parts.startsWith("24") ? `00${parts.slice(2)}` : parts;
}
/** Whole days between an ISO instant and `now` (floor; negative-safe). */
function daysSince(iso: string, now: Date): number {
return Math.floor((now.getTime() - Date.parse(iso)) / DAY_MS);
}
export function resolveDashboardVerdict(
snapshot: DashboardSnapshot,
now: Date,
): DashboardVerdict {
const meds = snapshot.medsToday;
const tz = snapshot.user.timezone;
const summaries = snapshot.tiles.summaries;
const lastSeen = snapshot.tiles.lastSeenByType;
// ── 1 · bpCritical ─────────────────────────────────────────────────
// Fixed floors (sys ≥ 180 OR dia ≥ 110), never user thresholds, and
// only while the reading is fresh (≤ 1 day, re-derived against the
// injected `now`). Sits ABOVE the overdue dose: a fresh crisis-level
// reading is the one item that must not hide behind a routine
// medication prompt.
const sysLatest = summaries.BLOOD_PRESSURE_SYS?.latest ?? null;
const diaLatest = summaries.BLOOD_PRESSURE_DIA?.latest ?? null;
const sysSeen = lastSeen.BLOOD_PRESSURE_SYS;
const bpCriticalValue =
(sysLatest !== null && sysLatest >= BP_CRITICAL_SYS_FLOOR) ||
(diaLatest !== null && diaLatest >= BP_CRITICAL_DIA_FLOOR);
if (
bpCriticalValue &&
sysSeen &&
daysSince(sysSeen.lastSeenAt, now) <= BP_CRITICAL_MAX_DAYS_AGO
) {
return {
variant: "bpCritical",
values: { sys: sysLatest ?? 0, dia: diaLatest ?? 0 },
cta: { kind: "link", href: "/insights/blood-pressure" },
};
}
// ── 2 · doseOverdue ────────────────────────────────────────────────
// Keys ONLY on the server-computed band-model flag. A stale cached
// `nextDueAt` in the past with `nextDueOverdue: false` falls through
// (defensive contract — see module doc).
if (meds.nextDueOverdue === true) {
return {
variant: "doseOverdue",
values: { name: meds.nextDueMedicationName ?? "" },
cta: { kind: "quickEntry", target: "medicationIntake" },
};
}
// ── 3 · doseUpcoming ───────────────────────────────────────────────
// Unresolved doses remain today AND the next anchor sits within the
// next two hours on the user's local calendar day.
const unresolvedToday =
meds.scheduledToday > meds.takenToday + meds.skippedToday;
if (unresolvedToday && meds.nextDueAt !== null) {
const dueMs = Date.parse(meds.nextDueAt);
const untilDue = dueMs - now.getTime();
if (
untilDue >= 0 &&
untilDue <= DOSE_UPCOMING_WINDOW_MS &&
dayKeyInTz(new Date(dueMs), tz) === dayKeyInTz(now, tz)
) {
return {
variant: "doseUpcoming",
values: {
time: hmInTz(new Date(dueMs), tz),
name: meds.nextDueMedicationName ?? "",
},
cta: { kind: "quickEntry", target: "medicationIntake" },
};
}
}
// ── 4 · weightDrift ────────────────────────────────────────────────
// Distance-to-green-range drift: the 7-day mean sits further from
// the BMI-derived green band than the 30-day mean by at least the
// coach-nudge drift threshold. No stored height → no range → skip.
const weight = summaries.WEIGHT;
if (
snapshot.user.heightCm !== null &&
weight &&
weight.avg7 !== null &&
weight.avg30 !== null
) {
const range = buildWeightRangeFromHeight(snapshot.user.heightCm);
const dist = (x: number): number =>
Math.max(0, range.greenMin - x, x - range.greenMax);
if (dist(weight.avg7) - dist(weight.avg30) >= COACH_NUDGE_WEIGHT_DRIFT_KG) {
return {
variant: "weightDrift",
values: {},
cta: { kind: "link", href: "/insights/weight" },
};
}
}
// ── 5 · shortNights ────────────────────────────────────────────────
// 7-day mean sleep (stored in minutes) clearly under the floor, with
// enough readings that a sparse week stays silent. The clear-margin
// is the same constant the coach-nudge sleep trigger uses.
const sleep = summaries.SLEEP_DURATION;
if (sleep && sleep.avg7 !== null && sleep.count >= SHORT_NIGHTS_MIN_COUNT) {
const floorMinutes =
(SHORT_NIGHTS_FLOOR_H - COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H) * 60;
if (sleep.avg7 < floorMinutes) {
return {
variant: "shortNights",
values: { hours: Math.round((sleep.avg7 / 60) * 10) / 10 },
cta: { kind: "link", href: "/insights/sleep" },
};
}
}
// ── 6 · silence ────────────────────────────────────────────────────
// EVERY logged-ever core vital has been silent for ≥ 7 days (min over
// the per-type ages). Types the user never logged don't count — a
// BP-only account is judged on BP alone.
let minDaysAgo: number | null = null;
for (const type of SILENCE_TYPES) {
const seen = lastSeen[type];
if (!seen) continue;
const days = daysSince(seen.lastSeenAt, now);
if (minDaysAgo === null || days < minDaysAgo) minDaysAgo = days;
}
if (minDaysAgo !== null && minDaysAgo >= SILENCE_MIN_DAYS) {
return {
variant: "silence",
values: { days: minDaysAgo },
cta: { kind: "quickEntry", target: "measurement" },
};
}
// ── 7 · scoreDrop ──────────────────────────────────────────────────
const score = snapshot.healthScore;
if (
score !== null &&
score.delta !== null &&
score.delta <= -SCORE_DROP_MIN_POINTS
) {
return {
variant: "scoreDrop",
values: { points: Math.round(Math.abs(score.delta)) },
cta: { kind: "link", href: "/insights" },
};
}
// ── 8 · briefing ───────────────────────────────────────────────────
// Fresh briefing only (`ready` + not stale) with at least one key
// finding. Prefer a "watch"-tone finding; the headline rides verbatim
// as plain text — see the `values` doc.
if (
snapshot.briefingState === "ready" &&
!snapshot.briefingStale &&
snapshot.briefing !== null &&
snapshot.briefing.keyFindings.length > 0
) {
const findings = snapshot.briefing.keyFindings;
const picked = findings.find((f) => f.tone === "watch") ?? findings[0];
return {
variant: "briefing",
values: { headline: picked.headline },
cta: { kind: "link", href: "/insights" },
};
}
// ── 9 · allQuiet ───────────────────────────────────────────────────
return { variant: "allQuiet", values: {}, cta: null };
}
@@ -40,9 +40,12 @@ describe("prompt reads exclude tombstoned rows", () => {
});
it("the Coach snapshot filters its intake read", () => {
// v1.16.9 — the compliance timeline reads intake events through the
// medication relation (`intakeEvents`) so the ledger can be rebuilt
// per medication; the tombstone filter must ride on that nested read.
const src = read("../../ai/coach/snapshot.ts");
const intakeRead = src.match(
/medicationIntakeEvent\.findMany\(\{[\s\S]{0,200}?where:\s*\{[^}]*\}/,
/intakeEvents:\s*\{[\s\S]{0,300}?where:\s*\{[^}]*\}/,
);
expect(intakeRead).not.toBeNull();
expect(intakeRead![0]).toContain("deletedAt: null");
+19
View File
@@ -0,0 +1,19 @@
/**
* Coach-nudge trend thresholds shared with client-reachable code.
*
* Kept in a leaf module — free of any server-only import — because the
* dashboard hero's verdict resolver (`@/lib/dashboard/verdict`) reads
* these to stay numerically identical to the nudge cron, and that
* resolver runs inside a `"use client"` component. Importing them from
* `@/lib/jobs/coach-nudge` would drag the prisma/dispatcher server
* graph into the browser bundle (the same regression class the
* dose-window defaults hit — see
* `src/lib/medications/scheduling/dose-window-defaults.ts`).
* `@/lib/jobs/coach-nudge` re-exports both so server-side imports keep
* their path.
*/
/** Weight trigger: minimum kg the weekly mean must drift AWAY from the range. */
export const COACH_NUDGE_WEIGHT_DRIFT_KG = 0.5;
/** Sleep trigger: a night must undershoot the floor by this margin (h). */
export const COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H = 0.5;
+12 -4
View File
@@ -77,6 +77,18 @@ import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { Locale } from "@/lib/i18n/config";
import { defaultLocale, locales } from "@/lib/i18n/config";
import { getEvent } from "@/lib/logging/context";
// Trend thresholds shared with the dashboard hero's verdict resolver
// live in a client-safe leaf module; re-exported below so server-side
// imports keep their path.
import {
COACH_NUDGE_WEIGHT_DRIFT_KG,
COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H,
} from "@/lib/jobs/coach-nudge-thresholds";
export {
COACH_NUDGE_WEIGHT_DRIFT_KG,
COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H,
} from "@/lib/jobs/coach-nudge-thresholds";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -109,14 +121,10 @@ export const COACH_NUDGE_SELF_CONTEXT_STALE_DAYS = 60;
export const COACH_NUDGE_COACH_ACTIVE_DAYS = 14;
/** Weight trigger: minimum readings per weekly window. */
export const COACH_NUDGE_WEIGHT_MIN_READINGS = 3;
/** Weight trigger: minimum kg the weekly mean must drift AWAY from the range. */
export const COACH_NUDGE_WEIGHT_DRIFT_KG = 0.5;
/** Sleep trigger: minimum recorded nights in the 7-day window. */
export const COACH_NUDGE_SLEEP_MIN_NIGHTS = 5;
/** Sleep trigger: nights under the floor required to fire. */
export const COACH_NUDGE_SLEEP_DEFICIT_NIGHTS = 4;
/** Sleep trigger: a night must undershoot the floor by this margin (h). */
export const COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H = 0.5;
/** Gap trigger: distinct active days required in the prior 3 weeks. */
export const COACH_NUDGE_GAP_MIN_ACTIVE_DAYS = 10;
/** Gap trigger: length of the silent window that fires (days). */
@@ -0,0 +1,261 @@
/**
* v1.16.9 — ad-hoc→pending band convergence in the nightly dedup.
*
* The production damage this heals: the external-API and Telegram write
* paths historically bare-created `scheduledFor = takenAt`, leaving the
* worker-minted pending REMINDER row open. The legacy snap pass only
* reaches a ± half-window tolerance and the discovery query only matched
* 2-minute drift, so an 08:42 API take beside its pending 09:00 row
* never collapsed — the ledger showed an ad-hoc take PLUS a missed slot
* for one real dose.
*
* Covered here:
* 1. The 08:42 / 09:00 pair heals: the pending row tombstones, the
* take normalises onto the canonical anchor.
* 2. A take outside the legacy snap tolerance but inside the band's
* late tail still heals (the case the snap pass can never reach).
* 3. An ambiguous double-slot (two live rows on the anchor) is left
* alone by the convergence pass.
* 4. A WEB ad-hoc row (deliberate user shape) is never converged.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: {
findFirst: vi.fn(),
findMany: vi.fn(),
updateMany: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() }));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
}));
import { dedupeUserIntakeSlots } from "../intake-slot-dedup";
import { prisma } from "@/lib/db";
const TZ = "Europe/Berlin";
// Daily 09:00 Berlin med. 09:00 CEST on 2026-06-10 = 07:00:00Z.
const SLOT_0900 = new Date("2026-06-10T07:00:00.000Z");
const TAKEN_0842 = new Date("2026-06-10T06:42:00.000Z");
// 12:30 Berlin — outside the ±2h legacy snap tolerance of the 09:00
// anchor, but inside the band's late tail (on-time end 10:00 + 180 min).
const TAKEN_1230 = new Date("2026-06-10T10:30:00.000Z");
const SCHEDULE = {
id: "s1",
windowStart: "09:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["09:00"],
reminderGraceMinutes: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
};
const MED = {
id: "med-1",
userId: "u1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
schedules: [SCHEDULE],
scheduleRevisions: [],
};
function pendingRow(over: Record<string, unknown> = {}) {
return {
id: "r-pending",
medicationId: "med-1",
scheduledFor: SLOT_0900,
takenAt: null,
skipped: false,
syncVersion: 0,
createdAt: new Date("2026-06-10T05:00:00Z"),
attributionSource: "AUTO",
source: "REMINDER",
...over,
};
}
function adhocRow(at: Date, over: Record<string, unknown> = {}) {
return {
id: "r-adhoc",
medicationId: "med-1",
scheduledFor: at,
takenAt: at,
skipped: false,
syncVersion: 1,
createdAt: at,
attributionSource: "AUTO",
source: "API",
...over,
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: TZ,
} as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([MED] as never);
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(
null as never,
);
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({} as never);
});
describe("dedupeUserIntakeSlots — ad-hoc→pending convergence (v1.16.9)", () => {
it("heals the 08:42 API take / pending 09:00 REMINDER pair", async () => {
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
adhocRow(TAKEN_0842),
pendingRow(),
] as never);
const summary = await dedupeUserIntakeSlots("u1");
// The pending row tombstones; the take is the dose of record.
expect(prisma.medicationIntakeEvent.updateMany).toHaveBeenCalledTimes(1);
const tombstone = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock
.calls[0][0] as {
where: { id: string };
data: { deletedAt: Date };
};
expect(tombstone.where.id).toBe("r-pending");
expect(tombstone.data.deletedAt).toBeInstanceOf(Date);
// The take normalises onto the canonical 09:00 anchor.
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
const normalise = vi.mocked(prisma.medicationIntakeEvent.update).mock
.calls[0][0] as {
where: { id: string };
data: { scheduledFor: Date };
};
expect(normalise.where.id).toBe("r-adhoc");
expect(normalise.data.scheduledFor.getTime()).toBe(SLOT_0900.getTime());
expect(summary.rowsSoftDeleted).toBe(1);
expect(summary.rowsNormalised).toBe(1);
expect(summary.slotsCollapsed).toBe(1);
expect(summary.daysRecomputed).toBeGreaterThanOrEqual(1);
});
it("heals an early take ahead of an explicit window that starts AT the dose time", async () => {
// Explicit 09:00–10:00 dose window: the on-time band starts AT the
// dose time, so an 08:42 take sits only inside the bounded early
// grace below `onTimeStart`. The convergence must accept it exactly
// like the write-path attribution does — otherwise the actioned
// ad-hoc row stays beside the unactioned 09:00 REMINDER row and the
// slot later auto-misses a dose the user took.
vi.mocked(prisma.medication.findMany).mockResolvedValue([
{
...MED,
schedules: [
{
...SCHEDULE,
windowStart: "09:00",
windowEnd: "10:00",
doseWindows: [{ timeOfDay: "09:00", start: "09:00", end: "10:00" }],
},
],
},
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
adhocRow(TAKEN_0842),
pendingRow(),
] as never);
const summary = await dedupeUserIntakeSlots("u1");
const tombstone = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock
.calls[0][0] as { where: { id: string } };
expect(tombstone.where.id).toBe("r-pending");
const normalise = vi.mocked(prisma.medicationIntakeEvent.update).mock
.calls[0][0] as { where: { id: string }; data: { scheduledFor: Date } };
expect(normalise.where.id).toBe("r-adhoc");
expect(normalise.data.scheduledFor.getTime()).toBe(SLOT_0900.getTime());
expect(summary.slotsCollapsed).toBe(1);
});
it("heals a take outside the legacy snap tolerance but inside the band's late tail", async () => {
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
adhocRow(TAKEN_1230),
pendingRow(),
] as never);
const summary = await dedupeUserIntakeSlots("u1");
expect(summary.rowsSoftDeleted).toBe(1);
expect(summary.rowsNormalised).toBe(1);
const tombstone = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock
.calls[0][0] as { where: { id: string } };
expect(tombstone.where.id).toBe("r-pending");
const normalise = vi.mocked(prisma.medicationIntakeEvent.update).mock
.calls[0][0] as { where: { id: string }; data: { scheduledFor: Date } };
expect(normalise.where.id).toBe("r-adhoc");
expect(normalise.data.scheduledFor.getTime()).toBe(SLOT_0900.getTime());
});
it("leaves an ambiguous double-slot (two live rows on the anchor) alone", async () => {
// Two live rows already sit on the 09:00 anchor — converging the take
// would have to pick one blindly. The convergence pass declines; the
// take stays where it is. (The legacy exact-instant collapse of the
// two anchor rows is a separate, pre-existing pass.)
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
adhocRow(TAKEN_1230),
pendingRow(),
pendingRow({ id: "r-pending-2", source: "API" }),
] as never);
await dedupeUserIntakeSlots("u1");
// The ad-hoc take was never normalised onto the anchor.
const normaliseCalls = vi
.mocked(prisma.medicationIntakeEvent.update)
.mock.calls.map((c) => (c[0] as { where: { id: string } }).where.id);
expect(normaliseCalls).not.toContain("r-adhoc");
});
it("never converges a WEB ad-hoc row (deliberate user shape)", async () => {
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
adhocRow(TAKEN_1230, { source: "WEB" }),
pendingRow(),
] as never);
const summary = await dedupeUserIntakeSlots("u1");
expect(summary.rowsSoftDeleted).toBe(0);
expect(summary.rowsNormalised).toBe(0);
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
});
it("never converges a USER_PIN released row", async () => {
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
adhocRow(TAKEN_1230, { attributionSource: "USER_PIN" }),
pendingRow(),
] as never);
const summary = await dedupeUserIntakeSlots("u1");
expect(summary.rowsSoftDeleted).toBe(0);
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
});
});
@@ -365,3 +365,95 @@ describe("reduceCurrentWindowStatus — display-due gate (v1.16.6)", () => {
expect(res.status).toBeNull();
});
});
/**
* v1.16.9 — day-scale early-take downgrade. A weekly injectable taken two
* days before its slot day is already on board; on the slot day the
* in-window pill must carry taken-early context instead of prompting a
* full take (a "take now" there is a double-dose prompt).
*/
describe("reduceCurrentWindowStatus — day-scale taken-early context", () => {
// Weekly Wednesday 09:00 (2026-06-10 is a Wednesday). daysOfWeek "3" =
// Wednesday in the legacy CSV encoding.
const weeklyWedSchedule = {
windowStart: "09:00",
windowEnd: "09:00",
daysOfWeek: "3",
timesOfDay: ["09:00"],
};
// Monday 2026-06-08, 08:00 Berlin (CEST) — two days before the slot day.
const MONDAY_TAKE = "2026-06-08T06:00:00.000Z";
it("flags takenEarly on the slot day after a Monday take (Mon-take/Wed-slot)", () => {
const res = reduceCurrentWindowStatus({
...BASE,
schedules: [weeklyWedSchedule],
nowBerlin: localClock(9, 15),
lastTakenAt: MONDAY_TAKE,
todayEventCount: 0,
});
expect(res.status).toBe("in_window");
expect(res.takenEarlyDaysAgo).toBe(2);
});
it("does not flag takenEarly when the last take was a full period ago", () => {
// Previous Wednesday — the prior cycle's regular dose.
const res = reduceCurrentWindowStatus({
...BASE,
schedules: [weeklyWedSchedule],
nowBerlin: localClock(9, 15),
lastTakenAt: "2026-06-03T07:05:00.000Z",
todayEventCount: 0,
});
expect(res.status).toBe("in_window");
expect(res.takenEarlyDaysAgo).toBeNull();
});
it("does not flag a daily cadence (minute-scale periods stay untouched)", () => {
const res = reduceCurrentWindowStatus({
...BASE,
schedules: [
{
windowStart: "09:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["09:00"],
},
],
nowBerlin: localClock(9, 15),
lastTakenAt: "2026-06-09T07:05:00.000Z", // yesterday — normal daily take
todayEventCount: 0,
});
expect(res.status).toBe("in_window");
expect(res.takenEarlyDaysAgo).toBeNull();
});
it("rides on the late tier too — an early take must not escalate to overdue", () => {
const res = reduceCurrentWindowStatus({
...BASE,
schedules: [weeklyWedSchedule],
nowBerlin: localClock(11, 30), // past the band + inside lateMinutes
lastTakenAt: MONDAY_TAKE,
todayEventCount: 0,
});
expect(res.status).toBe("late");
expect(res.takenEarlyDaysAgo).toBe(2);
});
it("evaluates the early-take day in the supplied timezone", () => {
// Pacific/Auckland (UTC+12): the same instant lands on different local
// days in Berlin vs Auckland near midnight. 2026-06-08T13:00:00Z is
// 2026-06-09 01:00 in Auckland (one day before the Wed slot day) —
// still inside the weekly period → takenEarly.
const res = reduceCurrentWindowStatus({
...BASE,
schedules: [weeklyWedSchedule],
nowBerlin: localClock(9, 15), // interpreted as Auckland wall clock
lastTakenAt: "2026-06-08T13:00:00.000Z",
todayEventCount: 0,
tz: "Pacific/Auckland",
});
expect(res.status).toBe("in_window");
expect(res.takenEarlyDaysAgo).toBe(1);
});
});
+233 -1
View File
@@ -49,6 +49,11 @@ import { annotate } from "@/lib/logging/context";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { resolveCanonicalSlotInstant } from "@/lib/medications/scheduling/resolve-slot-instant";
import {
buildMedicationDayBands,
type AttributeIntakeMedication,
} from "@/lib/medications/scheduling/attribute-intake";
import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras";
import type {
WorkerMedicationRow,
WorkerScheduleRow,
@@ -89,6 +94,8 @@ type DedupMedication = WorkerMedicationRow & {
userId: string;
timezone: string;
schedules: WorkerScheduleRow[];
/** v1.16.9 — archived eras so the band convergence honours old times. */
scheduleRevisions: ScheduleRevisionLike[];
};
interface DedupIntakeRow {
@@ -105,6 +112,13 @@ interface DedupIntakeRow {
* ad-hoc). Such a row never joins a snap cluster.
*/
attributionSource: string;
/**
* v1.16.9 — write origin. The ad-hoc→pending band convergence only
* touches API / REMINDER rows (external ingest + the Telegram webhook,
* the two paths that historically bare-created `scheduledFor =
* takenAt`); a WEB or IMPORT ad-hoc row is a deliberate user shape.
*/
source: string;
}
/**
@@ -197,6 +211,21 @@ export async function dedupeUserIntakeSlots(
scheduleType: true,
cyclicOnWeeks: true,
cyclicOffWeeks: true,
// v1.16.9 — the band convergence honours the persisted per-dose
// windows (the same lever the write path + read ledger consume).
doseWindows: true,
},
},
// v1.16.9 — archived eras: an old ad-hoc row converges against the
// schedule that was live at its takenAt, not today's times.
scheduleRevisions: {
orderBy: { validFrom: "asc" },
select: {
id: true,
validFrom: true,
validUntil: true,
payload: true,
supersededByRevisionId: true,
},
},
},
@@ -235,12 +264,33 @@ export async function dedupeUserIntakeSlots(
createdAt: true,
// v1.16.0 — pin-awareness: a USER_PIN row never joins a cluster.
attributionSource: true,
// v1.16.9 — write origin for the ad-hoc→pending convergence gate.
source: true,
},
orderBy: { scheduledFor: "asc" },
})) as DedupIntakeRow[];
if (rows.length < 2) continue;
// v1.16.9 — heal pass FIRST: converge ad-hoc actioned API/REMINDER
// rows onto the pending row of the slot whose minted band contains
// the takenAt. The legacy snap pass below only reaches a ±half-window
// tolerance, so an 18-minute-early API ingest beside its pending
// 09:00 REMINDER row never clustered — the production damage this
// backfill exists to repair. The pass mutates `rows` in place
// (tombstoned pending rows removed, the winner's `scheduledFor`
// normalised) so the snap pass below operates on the healed state.
await convergeAdhocRowsOntoPendingSlots({
med,
rows,
userTz,
lastIntakeAt,
summary,
daysToRecompute,
});
if (rows.length < 2) continue;
// Group rows by the canonical slot instant they snap to. Rows that
// resolve to `null` are PRN / off-slot — keyed individually so they
// never collapse together.
@@ -402,6 +452,168 @@ export async function dedupeUserIntakeSlots(
return summary;
}
/**
* v1.16.9 — converge ad-hoc actioned rows onto their slot's pending row.
*
* The healed shape: an external-API or Telegram take historically
* bare-created `scheduledFor = takenAt`, leaving the worker-minted
* pending REMINDER row open. The pending row later auto-missed, so the
* ledger showed an ad-hoc take PLUS a missed slot for one real dose.
* The write paths converge since v1.16.9; this pass repairs the rows
* already written.
*
* Deliberately conservative — a pair converges ONLY when:
* - the actioned row is ad-hoc shaped (`scheduledFor === takenAt`,
* taken, not skipped), source API or REMINDER, and not USER_PIN;
* - exactly ONE minted band contains the takenAt (the bands are
* disjoint by construction, but a zero- or ambiguous-match take is
* left alone);
* - the band's anchor carries exactly ONE live row and that row is
* unactioned (no takenAt, not skipped — a cron auto-miss flag does
* not count as an action: the take proves the miss wrong).
*
* The actioned row is the dose of record: its `scheduledFor` normalises
* to the band anchor and the pending row is tombstoned (soft-deleted,
* syncVersion bumped) per the canonical pattern. `rows` is mutated in
* place so the caller's snap pass sees the healed state.
*/
async function convergeAdhocRowsOntoPendingSlots(input: {
med: DedupMedication;
rows: DedupIntakeRow[];
userTz: string;
lastIntakeAt: Date | null;
summary: IntakeSlotDedupSummary;
daysToRecompute: Set<string>;
}): Promise<void> {
const { med, rows, userTz, lastIntakeAt, summary, daysToRecompute } = input;
const isRolling = med.schedules.some((s) => s.rollingIntervalDays !== null);
const adhocCandidates = rows.filter(
(r) =>
r.takenAt !== null &&
!r.skipped &&
r.scheduledFor.getTime() === r.takenAt.getTime() &&
r.attributionSource !== "USER_PIN" &&
(r.source === "API" || r.source === "REMINDER"),
);
if (adhocCandidates.length === 0) return;
const medication: AttributeIntakeMedication = {
id: med.id,
startsOn: med.startsOn,
endsOn: med.endsOn,
oneShot: med.oneShot,
createdAt: med.createdAt,
schedules: med.schedules,
scheduleRevisions: med.scheduleRevisions ?? [],
};
for (const adhoc of adhocCandidates) {
try {
const takenAt = adhoc.takenAt as Date;
// Rolling cadences anchor their retrospective grid AT each intake;
// build the instants from the rows already in memory, EXCLUDING the
// candidate itself (its own ad-hoc anchor must not mint the band it
// is judged against).
const intakeInstants = isRolling
? rows
.filter(
(r) =>
r.id !== adhoc.id &&
r.takenAt !== null &&
!r.skipped &&
r.takenAt.getTime() <= takenAt.getTime(),
)
.map((r) => r.takenAt as Date)
.sort((a, b) => a.getTime() - b.getTime())
: undefined;
const { bands, hasExpectedSlots } = buildMedicationDayBands({
medication,
userTz,
around: takenAt,
lastIntakeAt,
intakeInstants,
now: takenAt,
});
if (!hasExpectedSlots) continue;
const t = takenAt.getTime();
// The lower bound honours the bounded early grace (`earlyStart`):
// an explicit window that starts AT the dose time ("09:00–10:00")
// must still converge an 08:42 take — the same reach the write-path
// attribution credits. Bands without the field keep the strict
// on-time start.
const matching = bands.filter(
(b) =>
t >= (b.earlyStart ?? b.onTimeStart).getTime() &&
t <= b.overdueEnd.getTime(),
);
if (matching.length !== 1) continue; // zero or ambiguous — leave alone.
const anchor = matching[0].at;
if (anchor.getTime() === adhoc.scheduledFor.getTime()) continue; // same instant — the snap pass owns it.
const anchorRows = rows.filter(
(r) =>
r.id !== adhoc.id && r.scheduledFor.getTime() === anchor.getTime(),
);
if (anchorRows.length !== 1) continue; // empty or double-slot — leave alone.
const pending = anchorRows[0];
if (pending.takenAt !== null || pending.skipped) continue; // already served.
// Tombstone the pending row; the take is the dose of record.
await prisma.medicationIntakeEvent.updateMany({
where: { id: pending.id, deletedAt: null },
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
summary.rowsSoftDeleted += 1;
const adhocDayInstant = adhoc.scheduledFor;
// Normalise the take onto the canonical anchor. Same P2002 guard as
// the snap pass: a tombstoned row may already occupy
// `(anchor, source)`; when it does the take keeps its own anchor —
// the pending row is already gone, so the slot no longer double-counts.
try {
await prisma.medicationIntakeEvent.update({
where: { id: adhoc.id },
data: { scheduledFor: anchor, syncVersion: { increment: 1 } },
});
summary.rowsNormalised += 1;
adhoc.scheduledFor = anchor;
} catch (normErr) {
if (!isP2002(normErr)) throw normErr;
annotate({
meta: {
intake_slot_dedup_converge_collision: true,
intake_slot_dedup_medication: med.id,
intake_slot_dedup_slot: anchor.toISOString(),
},
});
}
summary.slotsCollapsed += 1;
// Both local days can differ for day-scale bands — recompute each.
daysToRecompute.add(`${med.id}|${anchor.toISOString()}`);
daysToRecompute.add(`${med.id}|${adhocDayInstant.toISOString()}`);
// Mutate the in-memory rows so the snap pass sees the healed state.
const pendingIdx = rows.findIndex((r) => r.id === pending.id);
if (pendingIdx >= 0) rows.splice(pendingIdx, 1);
} catch (pairErr) {
annotate({
meta: {
intake_slot_dedup_converge_failed: true,
intake_slot_dedup_medication: med.id,
intake_slot_dedup_error:
pairErr instanceof Error ? pairErr.message : String(pairErr),
},
});
}
}
}
/**
* Discovery pass — runs at worker boot and on the daily cron tick (the
* cron payload omits `userId`; the worker handler dispatches here). Finds
@@ -444,6 +656,15 @@ export async function enqueueBootTimeIntakeSlotDedup(): Promise<{
// `(user_id, medication_id, scheduled_for)`. The window absorbs the
// sub-minute iOS-vs-server drift; the per-user pass re-checks
// precisely so a wider-than-needed window only costs idle jobs.
//
// v1.16.9 — second arm: the ad-hoc→pending heal pattern. An actioned
// ad-hoc row (`scheduled_for = taken_at`) beside an unactioned row on
// the same medication can sit up to the widest band reach apart
// (weekly on-time 1d + overdue 4d ≈ 5 days; 475200 s adds half a day
// of slack) — far outside the 2-minute drift arm, which is why the
// 18-minute API/REMINDER pairs were never enqueued. The per-user pass
// re-checks by exact band membership, so the wide arm only costs idle
// jobs.
const users = await prisma.$queryRaw<Array<{ id: string }>>`
SELECT DISTINCT a."user_id" AS id
FROM "medication_intake_events" a
@@ -453,7 +674,18 @@ export async function enqueueBootTimeIntakeSlotDedup(): Promise<{
AND a."id" <> b."id"
AND a."deleted_at" IS NULL
AND b."deleted_at" IS NULL
AND abs(extract(epoch FROM (a."scheduled_for" - b."scheduled_for"))) <= 120
AND (
abs(extract(epoch FROM (a."scheduled_for" - b."scheduled_for"))) <= 120
OR (
a."taken_at" IS NOT NULL
AND a."scheduled_for" = a."taken_at"
AND a."skipped" = false
AND a."source" IN ('API', 'REMINDER')
AND b."taken_at" IS NULL
AND b."skipped" = false
AND abs(extract(epoch FROM (a."scheduled_for" - b."scheduled_for"))) <= 475200
)
)
`;
if (users.length === 0) {
@@ -96,8 +96,17 @@ describe("attributeIntakeToSlot — summer (CEST)", () => {
expect(r?.status).toBe("on_time");
});
it("a take at 17:30 is ad-hoc (past the 07:00 tail, before the 19:00 window)", () => {
expect(attributeIntakeToSlot(at(day, 17, 30), bands)).toBeNull();
it("a take at 17:30 credits the 19:00 slot through the bounded early grace (v1.16.9)", () => {
// 30 min before the 18:00 on-time start — inside the 60-min early
// grace, so a slightly-early take credits its slot instead of
// orphaning ad-hoc while the slot later reads missed.
const r = attributeIntakeToSlot(at(day, 17, 30), bands);
expect(r?.band.timeOfDay).toBe("19:00");
expect(r?.status).toBe("on_time");
});
it("a take at 16:30 stays ad-hoc (beyond the 60-min early grace)", () => {
expect(attributeIntakeToSlot(at(day, 16, 30), bands)).toBeNull();
});
});
@@ -166,3 +175,73 @@ describe("attributeIntakeToSlot — configurable window widens on-time", () => {
expect(r?.status).toBe("on_time");
});
});
/**
* v1.16.9 — bounded early grace ahead of the on-time band.
*
* A window configured to start AT the dose time ("09:00–10:00") refused
* an 08:42 take: it orphaned ad-hoc and the slot read missed. Takes up to
* 60 min before `onTimeStart` now credit the slot; the reach is capped at
* the previous slot's `overdueEnd` so a take inside the prior dose's late
* tail is never re-claimed.
*/
describe("attributeIntakeToSlot — bounded early grace (v1.16.9)", () => {
const day = new Date("2026-06-08T12:00:00Z");
function nineToTenWindow(): SlotWindowInput {
return {
at: localHmAsUtc(day, TZ, 9, 0),
timeOfDay: "09:00",
onTimeStart: localHmAsUtc(day, TZ, 9, 0),
onTimeEnd: localHmAsUtc(day, TZ, 10, 0),
lateGraceMs: 180 * MIN,
};
}
it("an 08:42 take credits the 09:00 slot whose window starts AT 09:00", () => {
const bands = buildSlotBands([nineToTenWindow()]);
const r = attributeIntakeToSlot(at(day, 8, 42), bands);
expect(r?.band.timeOfDay).toBe("09:00");
expect(r?.status).toBe("on_time");
});
it("a take more than 60 min before the window start stays ad-hoc", () => {
const bands = buildSlotBands([nineToTenWindow()]);
expect(attributeIntakeToSlot(at(day, 7, 55), bands)).toBeNull();
});
it("the early grace never crosses into the previous slot's band", () => {
// 06:00 slot with a long late tail reaching to 09:00 (capped exactly
// at the 09:00 window start) + the 09:00–10:00 window. A take at
// 08:42 sits inside the 06:00 slot's tail — it must stay attributed
// LATE to the 06:00 slot, never early-claimed by the 09:00 slot.
const slots: SlotWindowInput[] = [
{
at: localHmAsUtc(day, TZ, 6, 0),
timeOfDay: "06:00",
onTimeStart: localHmAsUtc(day, TZ, 5, 30),
onTimeEnd: localHmAsUtc(day, TZ, 6, 30),
lateGraceMs: 600 * MIN, // capped at the next window's start
},
nineToTenWindow(),
];
const bands = buildSlotBands(slots);
// The cap binds: the 09:00 band's early reach starts where the 06:00
// tail ends.
expect(bands[1].earlyStart.toISOString()).toBe(
bands[0].overdueEnd.toISOString(),
);
const r = attributeIntakeToSlot(at(day, 8, 42), bands);
expect(r?.band.timeOfDay).toBe("06:00");
expect(r?.status).toBe("late");
});
it("isLastIntakeInBand suppression mirrors the grace (no take-now after an 08:42 take)", () => {
// Covered at the card level: see window-status — this pins the band
// shape the suppression relies on (earlyStart <= onTimeStart always).
const bands = buildSlotBands([nineToTenWindow()]);
expect(bands[0].earlyStart.getTime()).toBeLessThanOrEqual(
bands[0].onTimeStart.getTime(),
);
});
});
@@ -11,12 +11,22 @@
*/
import { describe, expect, it } from "vitest";
import { computeDisplayDue, computeNextDueAt } from "../next-due";
import {
computeDisplayDue,
computeNextDueAt,
toResolvedSlotMark,
type ResolvedSlotMark,
} from "../next-due";
import type {
WorkerMedicationRow,
WorkerScheduleRow,
} from "../worker-helpers";
/** A slot-anchored resolved mark (the write paths' canonical shape). */
function mark(at: Date): ResolvedSlotMark {
return { at, slotAnchored: true };
}
function d(iso: string): Date {
return new Date(iso);
}
@@ -155,8 +165,8 @@ describe("computeNextDueAt — skip resolved slots (twice-daily)", () => {
userTz: BERLIN,
lastIntakeAt: d("2026-06-10T15:30:00Z"),
resolvedSlots: [
d("2026-06-10T05:00:00Z"), // today 07:00 Berlin
d("2026-06-10T17:00:00Z"), // today 19:00 Berlin
mark(d("2026-06-10T05:00:00Z")), // today 07:00 Berlin
mark(d("2026-06-10T17:00:00Z")), // today 19:00 Berlin
],
});
expect(next).not.toBeNull();
@@ -173,7 +183,7 @@ describe("computeNextDueAt — skip resolved slots (twice-daily)", () => {
now,
userTz: BERLIN,
lastIntakeAt: d("2026-06-10T07:13:00Z"),
resolvedSlots: [d("2026-06-10T05:00:00Z")], // only today 07:00
resolvedSlots: [mark(d("2026-06-10T05:00:00Z"))], // only today 07:00
});
expect(next).not.toBeNull();
expect(next!.toISOString()).toBe("2026-06-10T17:00:00.000Z"); // today 19:00
@@ -315,7 +325,7 @@ describe("computeDisplayDue — open overdue slot", () => {
now,
userTz: BERLIN,
lastIntakeAt: null,
resolvedSlots: [d("2026-06-10T19:00:00Z")],
resolvedSlots: [mark(d("2026-06-10T19:00:00Z"))],
});
expect(due).not.toBeNull();
expect(due!.overdue).toBe(false);
@@ -336,3 +346,137 @@ describe("computeDisplayDue — open overdue slot", () => {
expect(due!.overdue).toBe(false);
});
});
/**
* v1.16.9 — an AD-HOC row (`scheduledFor === takenAt`) must never
* ±6h-resolve a DIFFERENT slot. The proven failure: twice-daily 08:00 /
* 20:00 Berlin, an ad-hoc take at 14:30 sat within 6h of tonight's 20:00
* anchor and resolved it — the genuinely-due dose disappeared from the
* card while the ledger still counted the slot missed.
*/
describe("resolved-slot marks — ad-hoc rows only match their own anchor", () => {
const medication = makeMedication();
// 08:00 / 20:00 Berlin (CEST): 06:00Z / 18:00Z.
function schedule(): WorkerScheduleRow {
return {
id: "sched-2x",
windowStart: "08:00",
windowEnd: "20:00",
daysOfWeek: null,
timesOfDay: ["08:00", "20:00"],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
};
}
const ADHOC_1430 = d("2026-06-10T12:30:00Z"); // 14:30 Berlin
it("a 14:30 ad-hoc take leaves tonight's 20:00 slot due", () => {
const now = d("2026-06-10T15:00:00Z"); // 17:00 Berlin
const next = computeNextDueAt({
medication,
schedules: [schedule()],
now,
userTz: BERLIN,
lastIntakeAt: ADHOC_1430,
resolvedSlots: [
// The morning slot was genuinely taken (slot-anchored row)…
mark(d("2026-06-10T06:00:00Z")),
// …and the 14:30 take recorded ad-hoc (`scheduledFor === takenAt`).
toResolvedSlotMark({ scheduledFor: ADHOC_1430, takenAt: ADHOC_1430 }),
],
});
expect(next).not.toBeNull();
// Tonight's 20:00 Berlin (18:00Z) stays due — the ad-hoc row must not
// resolve it across the radius.
expect(next!.toISOString()).toBe("2026-06-10T18:00:00.000Z");
});
it("a 14:30 ad-hoc take does not suppress the 20:00 overdue pill either", () => {
const now = d("2026-06-10T19:00:00Z"); // 21:00 Berlin — inside the late tail
const due = computeDisplayDue({
medication,
schedules: [schedule()],
now,
userTz: BERLIN,
lastIntakeAt: ADHOC_1430,
resolvedSlots: [
mark(d("2026-06-10T06:00:00Z")),
toResolvedSlotMark({ scheduledFor: ADHOC_1430, takenAt: ADHOC_1430 }),
],
});
expect(due).not.toBeNull();
expect(due!.overdue).toBe(true);
expect(due!.at.toISOString()).toBe("2026-06-10T18:00:00.000Z");
});
it("a slot-anchored attributed row still resolves its slot across drift", () => {
const now = d("2026-06-10T19:00:00Z"); // 21:00 Berlin
const due = computeDisplayDue({
medication,
schedules: [schedule()],
now,
userTz: BERLIN,
lastIntakeAt: d("2026-06-10T18:05:00Z"),
resolvedSlots: [
mark(d("2026-06-10T06:00:00Z")),
// Attributed take: scheduledFor on the anchor, takenAt 20:05.
toResolvedSlotMark({
scheduledFor: d("2026-06-10T18:00:00Z"),
takenAt: d("2026-06-10T18:05:00Z"),
}),
],
});
expect(due).not.toBeNull();
expect(due!.overdue).toBe(false);
// Both today's slots resolved → tomorrow's 08:00 Berlin (06:00Z).
expect(due!.at.toISOString()).toBe("2026-06-11T06:00:00.000Z");
});
it("an ad-hoc row sitting exactly on the anchor still resolves that slot", () => {
const now = d("2026-06-10T19:00:00Z");
const due = computeDisplayDue({
medication,
schedules: [schedule()],
now,
userTz: BERLIN,
lastIntakeAt: d("2026-06-10T18:00:00Z"),
resolvedSlots: [
mark(d("2026-06-10T06:00:00Z")),
// Take recorded at the exact anchor instant — ad-hoc shaped but ON
// the slot; the near-exact epsilon keeps it resolving.
toResolvedSlotMark({
scheduledFor: d("2026-06-10T18:00:00Z"),
takenAt: d("2026-06-10T18:00:00Z"),
}),
],
});
expect(due).not.toBeNull();
expect(due!.overdue).toBe(false);
expect(due!.at.toISOString()).toBe("2026-06-11T06:00:00.000Z");
});
it("behaves identically in a non-European zone (Pacific/Auckland)", () => {
const AUCKLAND = "Pacific/Auckland"; // UTC+12 in June (NZST)
// 08:00 / 20:00 Auckland on 2026-06-10 = 2026-06-09T20:00Z / 2026-06-10T08:00Z.
const adhoc = d("2026-06-10T02:30:00Z"); // 14:30 Auckland
const now = d("2026-06-10T09:00:00Z"); // 21:00 Auckland
const due = computeDisplayDue({
medication,
schedules: [schedule()],
now,
userTz: AUCKLAND,
lastIntakeAt: adhoc,
resolvedSlots: [
mark(d("2026-06-09T20:00:00Z")),
toResolvedSlotMark({ scheduledFor: adhoc, takenAt: adhoc }),
],
});
expect(due).not.toBeNull();
expect(due!.overdue).toBe(true);
expect(due!.at.toISOString()).toBe("2026-06-10T08:00:00.000Z");
});
});
+39 -2
View File
@@ -25,6 +25,8 @@
* this module timezone-agnostic and trivially testable.
*/
import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults";
/** Per-slot window the caller supplies (bounds already minted as instants). */
export interface SlotWindowInput {
/** Canonical anchor instant for the slot (labelling / next-due / pairing). */
@@ -39,10 +41,29 @@ export interface SlotWindowInput {
lateGraceMs: number;
}
/**
* v1.16.9 — bounded early grace ahead of the on-time band. A user who
* configures a window to start AT the dose time ("09:00–10:00") would
* otherwise have an 08:42 take refused and orphaned ad-hoc while the
* slot read missed. A take up to this far before `onTimeStart` still
* credits the slot (as on-time); the reach is capped at the PREVIOUS
* slot's `overdueEnd` so it can never claim a take that belongs to the
* prior dose's late tail. Sourced from the shared dose-window-defaults
* leaf (client-safe) so the card pill suppression reads the same width.
*/
export const EARLY_GRACE_MS =
DOSE_WINDOW_DEFAULTS.earlyGraceMinutes * 60 * 1000;
/** A slot window with its (capped) late-tail cutoff resolved. */
export interface SlotBand extends SlotWindowInput {
/** Cutoff past which a take no longer pairs to this slot (capped). */
overdueEnd: Date;
/**
* Earliest instant a take still credits this slot (the bounded early
* grace before `onTimeStart`, capped at the previous slot's
* `overdueEnd`).
*/
earlyStart: Date;
}
export type AttributionStatus = "on_time" | "late";
@@ -59,7 +80,7 @@ export interface AttributionResult {
*/
export function buildSlotBands(slots: SlotWindowInput[]): SlotBand[] {
const sorted = [...slots].sort((a, b) => a.at.getTime() - b.at.getTime());
return sorted.map((slot, i) => {
const withTails = sorted.map((slot, i) => {
let overdueEnd = slot.onTimeEnd.getTime() + slot.lateGraceMs;
const next = sorted[i + 1];
if (next) {
@@ -71,6 +92,19 @@ export function buildSlotBands(slots: SlotWindowInput[]): SlotBand[] {
overdueEnd = Math.max(overdueEnd, slot.onTimeEnd.getTime());
return { ...slot, overdueEnd: new Date(overdueEnd) };
});
// Second pass — the early reach is bounded by the PREVIOUS slot's
// resolved tail, so it never claims a take inside the prior dose's
// band. The previous `overdueEnd` is already capped at this slot's
// `onTimeStart`, so `earlyStart <= onTimeStart` holds by construction.
return withTails.map((band, i) => {
let earlyStart = band.onTimeStart.getTime() - EARLY_GRACE_MS;
const prev = withTails[i - 1];
if (prev) {
earlyStart = Math.max(earlyStart, prev.overdueEnd.getTime());
}
earlyStart = Math.min(earlyStart, band.onTimeStart.getTime());
return { ...band, earlyStart: new Date(earlyStart) };
});
}
/**
@@ -90,7 +124,10 @@ export function attributeIntakeToSlot(
null;
for (const band of bands) {
const onStart = band.onTimeStart.getTime();
// v1.16.9 — the bounded early grace extends the on-time capture below
// `onTimeStart` (never into the previous slot's band — `buildSlotBands`
// caps it). Hand-built bands without the field keep the strict start.
const onStart = (band.earlyStart ?? band.onTimeStart).getTime();
const onEnd = band.onTimeEnd.getTime();
const overdueEnd = band.overdueEnd.getTime();
@@ -20,6 +20,15 @@
export const DOSE_WINDOW_DEFAULTS = {
/** Daily / intraday on-time half-width around the target instant (min). */
dailyOnTimeMinutes: 60,
/**
* Bounded early grace ahead of a dose's on-time window (min). A take up
* to this far before the window start still credits the slot (the
* attribution caps the reach at the previous slot's overdue end).
* Single source for the write-path attribution AND the card pill's
* last-intake suppression, so the two can never disagree on how early
* a take may land.
*/
earlyGraceMinutes: 60,
/**
* Daily / intraday overdue tail past the on-time window (min). A dose
* taken inside `[onTime, onTime + overdue]` still counts; beyond it the
+63 -19
View File
@@ -29,6 +29,63 @@ import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-d
*/
const RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000;
/**
* v1.16.9 — exact-match slop for an AD-HOC row. An ad-hoc take anchors
* `scheduledFor = takenAt` on its own instant, so it can only resolve a
* slot it actually sits on (sub-minute drift absorbed); letting it use
* the ±6h radius hid genuinely-due slots — a 14:30 ad-hoc take resolved
* tonight's 20:00 dose while the ledger still counted that slot missed.
*/
const ADHOC_RESOLVE_EPSILON_MS = 60 * 1000;
/**
* A row that resolves a slot (taken / deliberately skipped /
* cron-auto-missed), with its anchoring shape preserved.
*
* `slotAnchored: false` marks the ad-hoc shape (`scheduledFor ===
* takenAt`): such a row resolves a slot only on a near-exact anchor
* match, never across the ±6h radius. Slot-anchored rows (the write
* paths snap their `scheduledFor` to the canonical slot instant) keep
* the defensive radius.
*/
export interface ResolvedSlotMark {
at: Date;
slotAnchored: boolean;
}
/**
* Map a resolved intake row to its `ResolvedSlotMark`. The ad-hoc shape
* is detectable only for taken rows (`scheduledFor === takenAt` to the
* millisecond — the documented standalone-insert contract); skips and
* auto-misses anchor on their slot by construction.
*/
export function toResolvedSlotMark(row: {
scheduledFor: Date;
takenAt: Date | null;
}): ResolvedSlotMark {
return {
at: row.scheduledFor,
slotAnchored:
row.takenAt === null ||
row.takenAt.getTime() !== row.scheduledFor.getTime(),
};
}
function buildIsResolved(
resolved: ResolvedSlotMark[],
): (slotAt: Date) => boolean {
return (slotAt: Date): boolean => {
const slot = slotAt.getTime();
for (const r of resolved) {
const radius = r.slotAnchored
? RESOLVE_RADIUS_MS
: ADHOC_RESOLVE_EPSILON_MS;
if (Math.abs(r.at.getTime() - slot) <= radius) return true;
}
return false;
};
}
/**
* v1.16.4 — how far back the open-overdue search mints bands. The widest
* possible band reach is a weekly slot's on-time half-width plus its
@@ -57,21 +114,15 @@ export function computeNextDueAt(input: {
* these so a twice-daily med whose remaining slots today are all logged
* advances to the next genuinely-open slot (tomorrow's first dose) instead
* of re-surfacing a resolved present/past slot. Omit for the legacy
* purely-time-anchored next-due.
* purely-time-anchored next-due. v1.16.9 — each mark carries its
* anchoring shape; ad-hoc rows only resolve on a near-exact match.
*/
resolvedSlots?: Date[];
resolvedSlots?: ResolvedSlotMark[];
}): Date | null {
const { medication, schedules, now, userTz, lastIntakeAt } = input;
if (schedules.length === 0) return null;
const resolved = input.resolvedSlots ?? [];
const isResolved = (slotAt: Date): boolean => {
const slot = slotAt.getTime();
for (const r of resolved) {
if (Math.abs(r.getTime() - slot) <= RESOLVE_RADIUS_MS) return true;
}
return false;
};
const isResolved = buildIsResolved(input.resolvedSlots ?? []);
const ctx = buildRecurrenceContext({ medication, userTz, lastIntakeAt });
let earliest: Date | null = null;
@@ -113,7 +164,7 @@ export interface ComputeDisplayDueInput {
now: Date;
userTz: string;
lastIntakeAt: Date | null;
resolvedSlots?: Date[];
resolvedSlots?: ResolvedSlotMark[];
/**
* Floor of the CURRENT schedule era (the newest revision's `validUntil`),
* when the medication has archived revisions. The overdue search mints
@@ -148,14 +199,7 @@ function findOpenOverdueSlot(input: ComputeDisplayDueInput): Date | null {
const { medication, schedules, now, userTz, lastIntakeAt } = input;
if (schedules.length === 0) return null;
const resolved = input.resolvedSlots ?? [];
const isResolved = (slotAt: Date): boolean => {
const slot = slotAt.getTime();
for (const r of resolved) {
if (Math.abs(r.getTime() - slot) <= RESOLVE_RADIUS_MS) return true;
}
return false;
};
const isResolved = buildIsResolved(input.resolvedSlots ?? []);
let floor = new Date(now.getTime() - OVERDUE_LOOKBACK_MS);
if (input.eraStart && input.eraStart.getTime() > floor.getTime()) {
@@ -117,6 +117,18 @@ export interface RecurrenceContext {
* Latest `MedicationIntakeEvent.takenAt` for this medication.
* Used only by rolling schedules. Null when the user has never
* logged an intake (rolling then anchors on `startsOn ?? createdAt`).
*
* Skip semantics (documented decision, v1.16.9): a deliberate SKIP
* does NOT advance the rolling anchor. A skipped row carries
* `takenAt: null`, so every `lastIntakeAt` feeder query
* (`takenAt: { not: null }`) excludes it by construction, and the
* next due stays `previous take + N` — the skipped dose keeps
* surfacing as due until a real take re-anchors the grid. Advancing
* the anchor from the skipped instant instead would silently shift
* the whole future grid off the user's established rhythm; if that
* trade-off is ever revisited, every `lastIntakeAt` feeder (the
* projector, the reminder worker, the list route, the write-path
* resolvers, the dedup pass) must change together.
*/
lastIntakeAt: Date | null;
}
@@ -54,8 +54,13 @@ export interface ApplyCanonicalSlotWriteInput {
isExplicitTaken: boolean;
isExplicitSkip: boolean;
idempotencyKey: string | null;
/** Source to stamp on a freshly-created slot row. */
createSource: "WEB" | "API";
/**
* Source to stamp on a freshly-created slot row. `REMINDER` is the
* Telegram-webhook path — its take / skip confirmations converge onto
* the worker-minted pending row like every other write, and a fresh
* create keeps the historical reminder provenance.
*/
createSource: "WEB" | "API" | "REMINDER";
/**
* v1.8.5 — resolved + server-validated injection site to persist on a
* taken write. `null` = no site (the column stays / is set NULL). Only
+164 -30
View File
@@ -87,6 +87,18 @@ export interface CurrentWindowStatus<Schedule extends ScheduleWindowInput> {
schedule: Schedule | null;
/** Non-null exactly when `status` is non-null. */
window: MatchedDoseWindow | null;
/**
* v1.16.9 — non-null when the matched schedule is a DAY-SCALE cadence
* (weekly / N-weekly injectable) and a recent actioned take exists
* inside the current cadence period but on an earlier day. The dose is
* already on board: the card must render last-dose context instead of
* the "take now" / overdue prompt — a full take prompt on the slot day
* is a double-dose prompt. The value is the whole local days since
* that take, so the pill copy stays factual (the take may have been an
* early shot OR the previous slot served late — the day distance does
* not distinguish). Always null when `status` is null.
*/
takenEarlyDaysAgo: number | null;
}
/**
@@ -303,11 +315,28 @@ function getScheduleStatus(
return best;
}
/**
* v1.16.9 — the attribution module's bounded early grace: a take
* slightly before the on-time band still credits the slot, so the pill
* suppression must accept it too (an 08:42 take for a 09:00-window slot
* must not leave "take now" burning at 09:05). Read from the shared
* dose-window-defaults leaf — the same constant the write-path
* attribution derives `EARLY_GRACE_MS` from — so the two surfaces can
* never disagree.
*/
const EARLY_GRACE_MINUTES = DOSE_WINDOW_DEFAULTS.earlyGraceMinutes;
function isLastIntakeInBand(
lastTakenAt: string | null,
band: DoseWindowBounds,
nowLocal: Date,
tz: string,
/**
* Lower bound (minutes-of-day) the early grace may not cross — the end
* of the schedule's previous dose band, so a take belonging to the
* prior dose never suppresses this one. Defaults to 0 (no earlier band).
*/
earlyFloorMins = 0,
): boolean {
if (!lastTakenAt) return false;
@@ -330,7 +359,81 @@ function isLastIntakeInBand(
? intakeMins + 24 * 60
: intakeMins;
return adjustedIntake >= band.startMins && adjustedIntake <= band.endMins;
const startWithGrace = Math.max(
band.startMins - EARLY_GRACE_MINUTES,
earlyFloorMins,
0,
);
return adjustedIntake >= startWithGrace && adjustedIntake <= band.endMins;
}
/**
* v1.16.9 — day-scale cadence detection for the taken-early downgrade.
*
* A schedule whose dose days sit ≥ 2 days apart (a single weekday, a
* sparse Mo/Th pattern, an N-weekly interval) is day-scale: one dose
* covers a multi-day period, so a take earlier in the period must inform
* the slot-day pill. Daily / near-daily patterns (minute-scale) return
* `dayScale: false` — their period is a day and the same-day band checks
* already cover them. `periodDays` is the smallest gap between
* consecutive dose days (the span one dose covers).
*/
function dayScaleCadence(daysOfWeek: string | null): {
dayScale: boolean;
periodDays: number;
} {
const recurrence = parseScheduleRecurrence(daysOfWeek);
const days = [...new Set(recurrence.daysOfWeek)].sort((a, b) => a - b);
if (days.length === 0) return { dayScale: false, periodDays: 0 };
const intervalWeeks = Math.max(1, recurrence.intervalWeeks ?? 1);
if (days.length === 1) {
return { dayScale: true, periodDays: 7 * intervalWeeks };
}
let minGap = Infinity;
for (let i = 1; i < days.length; i++) {
minGap = Math.min(minGap, days[i] - days[i - 1]);
}
// Wrap from the week's last dose day to the next cycle's first.
const wrapGap = days[0] + 7 * intervalWeeks - days[days.length - 1];
minGap = Math.min(minGap, wrapGap);
return { dayScale: minGap >= 2, periodDays: minGap };
}
/**
* Whole local days since the last actioned take when it lies on an
* EARLIER local day inside the current cadence period — the shape the
* taken-early downgrade fires on — else `null`. A same-day take is the
* in-band suppression's job; a take a full period (or more) ago is the
* previous cycle's dose. The day count rides into the pill copy, which
* stays neutral ("last dose {n} days ago"): the period may have opened
* with an early shot OR the previous slot served late, and the distance
* alone cannot tell the two apart.
*/
function earlyTakeDaysAgo(
lastTakenAt: string | null,
nowLocal: Date,
tz: string,
periodDays: number,
): number | null {
if (!lastTakenAt || periodDays < 2) return null;
const intake = toZonedDate(new Date(lastTakenAt), tz);
const dayMs = 24 * 60 * 60 * 1000;
const intakeDay = Math.floor(
new Date(
intake.getFullYear(),
intake.getMonth(),
intake.getDate(),
).getTime() / dayMs,
);
const nowDay = Math.floor(
new Date(
nowLocal.getFullYear(),
nowLocal.getMonth(),
nowLocal.getDate(),
).getTime() / dayMs,
);
const diff = nowDay - intakeDay;
return diff >= 1 && diff < periodDays ? diff : null;
}
/**
@@ -388,32 +491,39 @@ function countPassedDoses<Schedule extends ScheduleWindowInput>(
* the default are the legacy Berlin contract — callers that serve other
* timezones pass their own.
*/
export function reduceCurrentWindowStatus<Schedule extends ScheduleWindowInput>(
options: {
schedules: Schedule[];
nowBerlin: Date;
lateMinutes: number;
missedMinutes: number;
active: boolean;
lastTakenAt: string | null;
todayEventCount: number;
/** IANA timezone matching `nowBerlin`'s conversion; defaults to Berlin. */
tz?: string;
/**
* v1.16.6 — the server display-due gate. `undefined` keeps the legacy
* purely band-derived behaviour (callers without the list payload);
* `null` means the server found NO upcoming slot (paused / ended /
* one-shot past) so no pill renders at all. With a gate present:
* - `overdue: false` (next unresolved slot in the future) suppresses
* late / very_late outright and allows in_window only when the due
* instant falls on the current local day — a rolling cadence whose
* next dose is tomorrow stays calm today;
* - `overdue: true` keeps the band-derived escalation (the slot is
* genuinely in its catch-up tail).
*/
nextDue?: NextDueGate | null;
},
): CurrentWindowStatus<Schedule> {
export function reduceCurrentWindowStatus<
Schedule extends ScheduleWindowInput,
>(options: {
schedules: Schedule[];
nowBerlin: Date;
lateMinutes: number;
missedMinutes: number;
active: boolean;
lastTakenAt: string | null;
/**
* v1.16.9 — number of ACTIONED intake events today (taken or
* explicitly skipped). The server list feeder counts only actioned
* rows: the dashboard projector mints a pending row for every slot of
* the day, so an all-rows count covered every passed dose after any
* dashboard visit and the overdue pill went dark nondeterministically.
*/
todayEventCount: number;
/** IANA timezone matching `nowBerlin`'s conversion; defaults to Berlin. */
tz?: string;
/**
* v1.16.6 — the server display-due gate. `undefined` keeps the legacy
* purely band-derived behaviour (callers without the list payload);
* `null` means the server found NO upcoming slot (paused / ended /
* one-shot past) so no pill renders at all. With a gate present:
* - `overdue: false` (next unresolved slot in the future) suppresses
* late / very_late outright and allows in_window only when the due
* instant falls on the current local day — a rolling cadence whose
* next dose is tomorrow stays calm today;
* - `overdue: true` keeps the band-derived escalation (the slot is
* genuinely in its catch-up tail).
*/
nextDue?: NextDueGate | null;
}): CurrentWindowStatus<Schedule> {
const {
schedules,
nowBerlin,
@@ -430,6 +540,7 @@ export function reduceCurrentWindowStatus<Schedule extends ScheduleWindowInput>(
status: null,
schedule: null,
window: null,
takenEarlyDaysAgo: null,
};
if (!active) return none;
if (nextDue === null) return none;
@@ -465,14 +576,36 @@ export function reduceCurrentWindowStatus<Schedule extends ScheduleWindowInput>(
// intake events for the day.
if (hit.status !== "in_window" && !hasUncoveredOverdue) return best;
// Don't show in_window if last intake is already within this band
// today.
// today (including the bounded early grace, floored at the end of
// the schedule's previous dose band so a prior dose's take never
// suppresses this one).
const earlyFloorMins = resolveDoseWindows(s).reduce(
(floor, b) =>
b.endMins <= hit.band.startMins && b.endMins > floor
? b.endMins
: floor,
0,
);
if (
hit.status === "in_window" &&
isLastIntakeInBand(lastTakenAt, hit.band, nowBerlin, tz)
isLastIntakeInBand(lastTakenAt, hit.band, nowBerlin, tz, earlyFloorMins)
) {
return best;
}
if (!best.status || STATUS_PRIORITY[hit.status] > STATUS_PRIORITY[best.status]) {
if (
!best.status ||
STATUS_PRIORITY[hit.status] > STATUS_PRIORITY[best.status]
) {
// v1.16.9 — day-scale early-take downgrade: a weekly injectable
// taken days before its slot day is already on board, so the pill
// must carry last-dose context instead of prompting a full take
// (a "Jetzt einnehmen" / overdue prompt here is a double-dose
// prompt). The server next-due stays canonical — only the pill's
// framing changes; the day count rides along for the copy.
const cadence = dayScaleCadence(s.daysOfWeek);
const takenEarlyDaysAgo = cadence.dayScale
? earlyTakeDaysAgo(lastTakenAt, nowBerlin, tz, cadence.periodDays)
: null;
return {
status: hit.status,
schedule: s,
@@ -481,6 +614,7 @@ export function reduceCurrentWindowStatus<Schedule extends ScheduleWindowInput>(
start: hit.band.startHm,
end: hit.band.endHm,
},
takenEarlyDaysAgo,
};
}
return best;
+29
View File
@@ -606,6 +606,35 @@ const dashboardSnapshotResponse = z
glucoseByContext: dataSummaryRecord,
})
.nullable(),
// Dashboard hero — today's medication block (fast phase, always
// present). Projection-backed tally + earliest next-due across
// active medications.
medsToday: z
.object({
activeCount: z.number().int(),
scheduledToday: z.number().int(),
takenToday: z.number().int(),
skippedToday: z.number().int(),
nextDueAt: z.string().nullable(),
nextDueOverdue: z.boolean(),
nextDueMedicationName: z.string().nullable(),
})
.describe(
"Today's medication block: active-medication count, today-window tally (scheduled / taken / skipped), and the earliest next-due slot across active medications. `nextDueOverdue: true` marks an OPEN overdue slot (anchor passed, still inside its catch-up band). Staleness contract: the snapshot is cache-served, so a `nextDueAt` in the past with `nextDueOverdue: false` means the anchor passed after the snapshot was built — render the plain day summary, never an overdue state.",
),
// Dashboard hero — health score (warm phase, nullable on a
// rollup-coverage miss). Score + band + delta only; the per-pillar
// component breakdown stays on the analytics route.
healthScore: z
.object({
score: z.number().int(),
band: z.enum(["green", "yellow", "red"]),
delta: z.number().nullable(),
})
.nullable()
.describe(
"Personal health score summary (0..100 score, traffic-light band, week-over-week delta). Null on a rollup-coverage miss (it rides the thick phase alongside `extras`) and when no pillar is computable. Component breakdown is deliberately not serialised here.",
),
briefing: z.record(z.string(), z.unknown()).nullable(),
briefingState: z.enum(["ready", "preparing", "disabled", "no-provider"]),
briefingUpdatedAt: z.string().nullable(),
+2 -2
View File
@@ -217,7 +217,7 @@ const medicationListEntry = medicationResource
.int()
.nonnegative()
.describe(
"Number of intake events scheduled for today (user-local day window).",
"Number of ACTIONED intake events for today (user-local day window): rows with a recorded `takenAt` or an explicit skip. Pending projector-minted rows do not count — the card overdue-pill suppression compares this against the passed-dose count, and a pending mint must not read as covered.",
),
})
.meta({
@@ -619,7 +619,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Medications"],
summary: "List medications for the calling user",
description:
"Returns every medication owned by the caller (active + paused), ordered by `createdAt DESC`. Each row carries its nested `schedules`, the joined clinical `category`, the latest non-skipped `lastTakenAt`, and the count of intake events scheduled for today (`todayEventCount`). The response is cached server-side for 60 s per user; writes flush the cache.",
"Returns every medication owned by the caller (active + paused), ordered by `createdAt DESC`. Each row carries its nested `schedules`, the joined clinical `category`, the latest non-skipped `lastTakenAt`, and the count of today's actioned intake events (`todayEventCount`). The response is cached server-side for 60 s per user; writes flush the cache.",
responses: {
"200": {
description: "Medication list.",
@@ -11,7 +11,11 @@
*/
import { describe, expect, it } from "vitest";
import { updateIntakeEventSchema } from "@/lib/validations/medication";
import {
externalIntakeSchema,
intakeSchema,
updateIntakeEventSchema,
} from "@/lib/validations/medication";
const HOUR_MS = 60 * 60 * 1000;
const YEAR_MS = 365 * 24 * HOUR_MS;
@@ -74,3 +78,70 @@ describe("updateIntakeEventSchema — takenAt bounds (P0-4)", () => {
expect(result.success).toBe(true);
});
});
/**
* v1.16.9 — the same bounds on every CREATE path. The retro-add dialog
* posts through `intakeSchema`, the iOS sync through the bulk entry
* shape, external integrations through `externalIntakeSchema`; none of
* them may insert a row the edit path would refuse.
*/
describe("intakeSchema — takenAt bounds on create (v1.16.9)", () => {
it("rejects a future takenAt beyond the skew allowance", () => {
const result = intakeSchema.safeParse({
medicationId: "med-1",
takenAt: new Date(Date.now() + HOUR_MS).toISOString(),
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toContain("future");
}
});
it("rejects a takenAt more than 5 years in the past", () => {
const result = intakeSchema.safeParse({
medicationId: "med-1",
takenAt: new Date(Date.now() - 6 * YEAR_MS).toISOString(),
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toContain("5 years");
}
});
it("accepts a recent takenAt and a body that omits it", () => {
expect(
intakeSchema.safeParse({
medicationId: "med-1",
takenAt: new Date(Date.now() - 2 * HOUR_MS).toISOString(),
}).success,
).toBe(true);
expect(intakeSchema.safeParse({ medicationId: "med-1" }).success).toBe(
true,
);
});
});
describe("externalIntakeSchema — takenAt bounds on ingest (v1.16.9)", () => {
it("rejects future and implausibly-old instants, accepts recent ones", () => {
const future = externalIntakeSchema.safeParse({
medicationName: "Metformin",
idempotencyKey: "k-1",
takenAt: new Date(Date.now() + HOUR_MS).toISOString(),
});
expect(future.success).toBe(false);
const ancient = externalIntakeSchema.safeParse({
medicationName: "Metformin",
idempotencyKey: "k-2",
takenAt: new Date(Date.now() - 6 * YEAR_MS).toISOString(),
});
expect(ancient.success).toBe(false);
const ok = externalIntakeSchema.safeParse({
medicationName: "Metformin",
idempotencyKey: "k-3",
takenAt: new Date(Date.now() - HOUR_MS).toISOString(),
});
expect(ok.success).toBe(true);
});
});
+29 -22
View File
@@ -163,6 +163,30 @@ function hhmmToMinutes(hhmm: string): number {
return Number(h) * 60 + Number(m);
}
/**
* v1.15.19 introduced `takenAt` plausibility bounds on the EDIT path
* (audit P0-4); v1.16.9 lifts them onto every CREATE path too (the
* per-medication intake POST, the bulk endpoint, the external ingest) so
* the retro-add dialog and a hand-rolled API call can't insert a row a
* decade in the past or hours in the future. Future allowance covers
* client clock skew; the 5-year floor matches the GLP-1 dose-change
* validator's window. Slot-distance / medication-start checks stay in
* the routes — the schema cannot see the medication.
*/
export const TAKEN_AT_CLOCK_SKEW_MS = 5 * 60 * 1000;
export const TAKEN_AT_MAX_AGE_MS = 5 * 365 * 24 * 60 * 60 * 1000;
/** ISO `takenAt` → `Date` with the shared plausibility bounds applied. */
export const boundedTakenAtSchema = z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.refine((d) => d.getTime() <= Date.now() + TAKEN_AT_CLOCK_SKEW_MS, {
message: "takenAt must not be in the future",
})
.refine((d) => d.getTime() >= Date.now() - TAKEN_AT_MAX_AGE_MS, {
message: "takenAt must be within the last 5 years",
});
export const scheduleSchema = z
.object({
windowStart: z
@@ -598,12 +622,10 @@ export const intakeSchema = z
.describe(
"Slot the dose belongs to. Defaults to `takenAt` (or `now()` when both are absent) so the compliance pairing logic can pin the dose to a schedule slot.",
),
takenAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
takenAt: boundedTakenAtSchema
.optional()
.describe(
"When the dose was actually taken. NULL when `skipped` is true; defaults to `now()` for non-skipped intakes.",
"When the dose was actually taken. NULL when `skipped` is true; defaults to `now()` for non-skipped intakes. Must not lie in the future (5-minute clock-skew allowance) nor more than 5 years in the past.",
),
skipped: z
.boolean()
@@ -673,10 +695,8 @@ export const intakeSchema = z
export const externalIntakeSchema = z.object({
medicationName: z.string().min(1).max(200),
takenAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
// v1.16.9 — same plausibility bounds as the interactive create paths.
takenAt: boundedTakenAtSchema.optional(),
idempotencyKey: z.string().max(128),
});
@@ -718,22 +738,9 @@ export const listIntakeEventsSchema = z.object({
* checks stay out of the schema — it cannot see the medication — and live
* in the route (start-date guard) + the edit dialog (non-blocking hint).
*/
const TAKEN_AT_CLOCK_SKEW_MS = 5 * 60 * 1000;
const TAKEN_AT_MAX_AGE_MS = 5 * 365 * 24 * 60 * 60 * 1000;
export const updateIntakeEventSchema = z
.object({
takenAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.refine((d) => d.getTime() <= Date.now() + TAKEN_AT_CLOCK_SKEW_MS, {
message: "takenAt must not be in the future",
})
.refine((d) => d.getTime() >= Date.now() - TAKEN_AT_MAX_AGE_MS, {
message: "takenAt must be within the last 5 years",
})
.nullable()
.optional(),
takenAt: boundedTakenAtSchema.nullable().optional(),
skipped: z.boolean().optional(),
scheduledFor: z.iso
.datetime({ offset: true })
+4 -2
View File
@@ -7,8 +7,10 @@ import { z } from "zod/v4";
* multi-operator product). Stored encrypted on `User`.
*/
export const whoopCredentialsSchema = z.object({
clientId: z.string().min(1).max(200),
clientSecret: z.string().min(1).max(200),
// Trimmed: a trailing space or newline from the portal's copy button
// reaches WHOOP verbatim and answers as "unknown client".
clientId: z.string().trim().min(1).max(200),
clientSecret: z.string().trim().min(1).max(200),
});
export type WhoopCredentialsInput = z.infer<typeof whoopCredentialsSchema>;
+3 -1
View File
@@ -34,8 +34,10 @@ export interface WhoopCredentials {
}
function getRedirectUri(): string {
// `||`, not `??`: the compose whitelist materialises the var as an empty
// string when unset, which must still fall through to the derived URI.
return (
process.env.WHOOP_REDIRECT_URI ??
process.env.WHOOP_REDIRECT_URI ||
`${process.env.NEXT_PUBLIC_APP_URL}/api/whoop/callback`
);
}
@@ -377,8 +377,11 @@ describe("POST /api/medications/intake/bulk (real Postgres)", () => {
});
const { POST } = await import("@/app/api/medications/intake/bulk/route");
const t1 = new Date("2026-06-15T09:00:00.000Z");
const t2 = new Date("2026-06-15T15:00:00.000Z");
// Relative past instants: takenAt now carries a no-future plausibility
// bound, so fixed calendar dates would rot into rejections.
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
const t1 = new Date(yesterday.setUTCHours(9, 0, 0, 0));
const t2 = new Date(yesterday.setUTCHours(15, 0, 0, 0));
await POST(
makeRequest({
entries: [
@@ -408,7 +411,14 @@ describe("POST /api/medications/intake/bulk (real Postgres)", () => {
it("creates exactly one row for a fresh scheduled dose with no pre-existing slot row", async () => {
const prisma = getPrismaClient();
const medId = await makeScheduledMed(["19:00"]);
const slot = localHmAsUtc(new Date(), TZ, 19, 0);
// Yesterday's slot: today's 19:00 is in the future for any run before
// the evening, and a future takenAt is now rejected by design.
const slot = localHmAsUtc(
new Date(Date.now() - 24 * 60 * 60 * 1000),
TZ,
19,
0,
);
const { POST } = await import("@/app/api/medications/intake/bulk/route");
const res = await POST(