diff --git a/CHANGELOG.md b/CHANGELOG.md
index e9ee59e7e..08947ecb5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 5190ca81e..541c54915 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -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
diff --git a/e2e/utils/mock-dashboard-snapshot.ts b/e2e/utils/mock-dashboard-snapshot.ts
index 6bfdd2954..cdc207dd8 100644
--- a/e2e/utils/mock-dashboard-snapshot.ts
+++ b/e2e/utils/mock-dashboard-snapshot.ts
@@ -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,
diff --git a/messages/de.json b/messages/de.json
index 7b5c36cd2..008dd5f5f 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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)",
diff --git a/messages/en.json b/messages/en.json
index d50aa6490..891a06406 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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)",
diff --git a/messages/es.json b/messages/es.json
index 593d43f7b..06537b0b8 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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",
diff --git a/messages/fr.json b/messages/fr.json
index ea85d0e79..445d9e1f3 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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",
diff --git a/messages/it.json b/messages/it.json
index ef90af625..a71112349 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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",
diff --git a/messages/pl.json b/messages/pl.json
index 84f135b09..18ac910b5 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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",
diff --git a/package.json b/package.json
index 0e81d3c37..cbbc776ae 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/app/__tests__/dashboard-hero-wiring.test.ts b/src/app/__tests__/dashboard-hero-wiring.test.ts
new file mode 100644
index 000000000..9e26a1939
--- /dev/null
+++ b/src/app/__tests__/dashboard-hero-wiring.test.ts
@@ -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]*?/,
+ );
+ 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(" {
+ 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");
+ });
+});
diff --git a/src/app/__tests__/dashboard-suspense-boundaries.test.ts b/src/app/__tests__/dashboard-suspense-boundaries.test.ts
index 9db552cbe..30c4dee8d 100644
--- a/src/app/__tests__/dashboard-suspense-boundaries.test.ts
+++ b/src/app/__tests__/dashboard-suspense-boundaries.test.ts
@@ -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");
});
});
diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts
index e8171168f..5299cc133 100644
--- a/src/app/api/admin/backups/[id]/restore/route.ts
+++ b/src/app/api/admin/backups/[id]/restore/route.ts
@@ -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,
diff --git a/src/app/api/admin/data/route.ts b/src/app/api/admin/data/route.ts
index 444965d80..06e90123d 100644
--- a/src/app/api/admin/data/route.ts
+++ b/src/app/api/admin/data/route.ts
@@ -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 });
});
diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts
index 11a8c39db..36f1e9506 100644
--- a/src/app/api/dashboard/summary/__tests__/route.test.ts
+++ b/src/app/api/dashboard/summary/__tests__/route.test.ts
@@ -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;
diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts
index a2bd8b53f..9761a8ba3 100644
--- a/src/app/api/dashboard/summary/route.ts
+++ b/src/app/api/dashboard/summary/route.ts
@@ -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> {
- 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;
diff --git a/src/app/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts
index b46247982..66458f54e 100644
--- a/src/app/api/dashboard/widgets/__tests__/route.test.ts
+++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts
@@ -399,6 +399,132 @@ describe("PUT /api/dashboard/widgets — accept-and-ignore unknown ids (v1.7.0 #
const callGet = GET as unknown as () => Promise;
+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;
+ };
+ };
+ };
+ 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({
diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts
index 692b7ed70..5896c6322 100644
--- a/src/app/api/dashboard/widgets/route.ts
+++ b/src/app/api/dashboard/widgets/route.ts
@@ -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 {
@@ -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({
diff --git a/src/app/api/ingest/medication/__tests__/route.test.ts b/src/app/api/ingest/medication/__tests__/route.test.ts
index cc2deb55d..629b8c247 100644
--- a/src/app/api/ingest/medication/__tests__/route.test.ts
+++ b/src/app/api/ingest/medication/__tests__/route.test.ts
@@ -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;
+ }) => {
+ 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;
+ }) =>
+ 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());
+ });
+});
diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts
index fe75ffda1..dfeafa5f4 100644
--- a/src/app/api/ingest/medication/route.ts
+++ b/src/app/api/ingest/medication/route.ts
@@ -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,
diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts
index 47b93df37..997db221a 100644
--- a/src/app/api/medications/[id]/intake/[eventId]/route.ts
+++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts
@@ -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;
}
diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts
index faa5beb26..0fe4beafb 100644
--- a/src/app/api/medications/[id]/intake/route.ts
+++ b/src/app/api/medications/[id]/intake/route.ts
@@ -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,
diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts
index 919c01e26..b7c729399 100644
--- a/src/app/api/medications/[id]/route.ts
+++ b/src/app/api/medications/[id]/route.ts
@@ -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,
});
diff --git a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts
index 10ead9ff6..bc9011a47 100644
--- a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts
+++ b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts
@@ -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 });
diff --git a/src/app/api/medications/[id]/schedule-revisions/route.ts b/src/app/api/medications/[id]/schedule-revisions/route.ts
index 9eda687b5..c78500192 100644
--- a/src/app/api/medications/[id]/schedule-revisions/route.ts
+++ b/src/app/api/medications/[id]/schedule-revisions/route.ts
@@ -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(
diff --git a/src/app/api/medications/__tests__/route.test.ts b/src/app/api/medications/__tests__/route.test.ts
index 69c198e46..c00b3f3b0 100644
--- a/src/app/api/medications/__tests__/route.test.ts
+++ b/src/app/api/medications/__tests__/route.test.ts
@@ -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,
+ ) => 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
+ )(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 }]);
+ });
+});
diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts
index 8bc18e95b..5a1440f62 100644
--- a/src/app/api/medications/intake/bulk/route.ts
+++ b/src/app/api/medications/intake/bulk/route.ts
@@ -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 {
});
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
diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts
index 50891c00e..cbc3461a5 100644
--- a/src/app/api/medications/intake/route.ts
+++ b/src/app/api/medications/intake/route.ts
@@ -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);
});
diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts
index 34f8c1418..2da559eaa 100644
--- a/src/app/api/medications/route.ts
+++ b/src/app/api/medications/route.ts
@@ -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();
+ const resolvedSlotsByMedId = new Map();
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();
diff --git a/src/app/api/settings/data/route.ts b/src/app/api/settings/data/route.ts
index 71235937e..b537b765e 100644
--- a/src/app/api/settings/data/route.ts
+++ b/src/app/api/settings/data/route.ts
@@ -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 });
});
diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts
index e33c3622c..ce8c8f72d 100644
--- a/src/app/api/telegram/webhook/__tests__/route.test.ts
+++ b/src/app/api/telegram/webhook/__tests__/route.test.ts
@@ -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;
+ }) => {
+ 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[])) 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::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:' 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;
+ }) =>
+ 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", {
diff --git a/src/app/api/telegram/webhook/route.ts b/src/app/api/telegram/webhook/route.ts
index b492fa489..6a95195a6 100644
--- a/src/app/api/telegram/webhook/route.ts
+++ b/src/app/api/telegram/webhook/route.ts
@@ -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 {
+ 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
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 0e665bbd1..48e4dce3d 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -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 (
+ {heroVisible &&
+ (primaryLoading || !heroSnapshot ? (
+
+ ) : (
+
+ ))}
+
{/* 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
diff --git a/src/components/dashboard/__tests__/dashboard-header.test.tsx b/src/components/dashboard/__tests__/dashboard-header.test.tsx
new file mode 100644
index 000000000..da873f652
--- /dev/null
+++ b/src/components/dashboard/__tests__/dashboard-header.test.tsx
@@ -0,0 +1,57 @@
+/**
+ * `` 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(
+ {node},
+ );
+}
+
+describe(" — greeting", () => {
+ it("renders the greeting line when the hero is hidden (showGreeting)", () => {
+ const html = renderSSR(
+ 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( 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");
+ });
+});
diff --git a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx
index df9042c79..54593d1fa 100644
--- a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx
+++ b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx
@@ -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".
diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx
index a8e7cb5c1..51c48967f 100644
--- a/src/components/dashboard/dashboard-header.tsx
+++ b/src/components/dashboard/dashboard-header.tsx
@@ -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 (``) 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) => 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). */
-
+ /* 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. */
+
{t("dashboard.title")}
- {/* 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. */}
-
- {welcomeText}
-
+ {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. */
+
+ {welcomeText}
+
+ ) : null}
{/* Customize shortcut to the dashboard-customization settings
diff --git a/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx b/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx
new file mode 100644
index 000000000..0084eb4c6
--- /dev/null
+++ b/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx
@@ -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 `` (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("", () => {
+ const html = renderToStaticMarkup();
+
+ it("carries the data-slot and aria-hidden contract", () => {
+ expect(html).toContain('data-slot="dashboard-hero-skeleton"');
+ const root = html.match(
+ /
]*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("