mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
85 files changed
+6552
-437
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,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>();
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
@@ -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".
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Vendored
+32
@@ -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.
|
||||
*
|
||||
|
||||
Vendored
+96
-26
@@ -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;
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>;
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user