perf(i18n,shell): ship one locale and stop blocking the first paint

The client bundle carries only the active locale — resolved on the
server and handed to the provider with no hydration flash, halving the
dashboard route to under a megabyte parsed (288 KiB gzip). The auth
shell renders the app chrome immediately instead of a spinner while the
session resolves in parallel, the widgets request folds into the
snapshot it duplicated, and insight pills prefetch their sub-pages —
a first visit to a sub-page lands in about ninety milliseconds instead
of nearly a second.
This commit is contained in:
Marc-André Bombeck committed 2026-06-11 02:19:27 +02:00
1 parent 9445d89b76
commit 3b480b9885
284 files changed
+6058 -5398

No files matched your search

+66
View File
@@ -1735,6 +1735,31 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/api/measurements/restore:
post:
tags:
- Measurements
summary: Restore soft-deleted measurements
description: Un-tombstones up to 200 of the caller's soft-deleted measurement rows in one call — the delete-Undo
counterpart to `/api/measurements/bulk-delete`. Idempotent via the `Idempotency-Key` header; rate-limited
`measurements:restore:<userId>`. Forged / foreign / not-deleted ids are silently skipped — `restored` is the
count of rows actually un-tombstoned. The restored rows re-surface in `/api/sync/changes` as upserts.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MeasurementsRestoreRequest"
responses:
"200":
description: Restore processed.
content:
application/json:
schema:
$ref: "#/components/schemas/MeasurementsRestoreResponse"
"401": *a1
"422": *a3
"429": *a4
/api/sleep/night:
get:
tags:
@@ -4229,6 +4254,20 @@ components:
required:
- ids
description: 1..200 measurement ids, scoped to the caller. Forged / foreign ids are silently skipped (no existence leak).
MeasurementsRestoreRequest:
type: object
properties:
ids:
minItems: 1
maxItems: 200
type: array
items:
type: string
minLength: 1
required:
- ids
description: 1..200 measurement ids, scoped to the caller. Forged / foreign / not-deleted ids are silently skipped (no
existence leak).
DeviceRegisterRequest:
type: object
properties:
@@ -7323,6 +7362,33 @@ components:
required:
- deleted
additionalProperties: false
MeasurementsRestoreResponse:
type: object
properties:
data:
$ref: "#/components/schemas/MeasurementsRestoreResult"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MeasurementsRestoreResult:
type: object
properties:
restored:
type: integer
minimum: 0
maximum: 9007199254740991
required:
- restored
additionalProperties: false
SleepNightResponse:
type: object
properties:
+32 -3
View File
@@ -149,9 +149,38 @@ Wrap inputs in `<label>` (visible) plus `aria-describedby` for helper text.
_and_ the user routinely sweeps. For most thresholds and counts, an
`<Input type="number">` plus stepper buttons is faster and less
fiddly. See §6 (Slider audit) for the migration path.
- **Boolean toggle**: `<Switch>` for "applies immediately" settings (e.g.
enable Telegram). `<Checkbox>` for "applies on Save" lists (e.g. select
measurements to export).
- **Boolean toggle**: `<Switch>` for settings. Exactly two persistence
patterns are legal (see §2.2.1); pick one per card, never mix.
`<Checkbox>` stays reserved for "applies on Save" _lists_ (e.g. select
measurements to export) and for action parameters that are not
persisted at all (export options).
#### 2.2.1 Settings persistence patterns
Every control under `/settings` follows exactly one of two save
patterns. The pattern is a property of the **card**, not of the
individual control:
1. **Immediate persist** — a standalone boolean that is the card's only
mutable state. The `<Switch>` fires the mutation in
`onCheckedChange`, disables itself while pending, updates
optimistically with rollback, and toasts on success/error. No Save
button anywhere on the card. Examples: hide-Coach, ntfy enable,
cycle tracking, research mode, mood reminder, Coach nudges.
2. **Draft + Save** — the card edits a multi-field payload (or a list
the server PUTs whole). All controls — including `<Switch>` rows —
mutate a local draft only; a visible Save button flushes the draft
in one request and the card surfaces its dirty state. Examples:
Coach preferences, Telegram (enable + bot token), provider fallback
chain, dashboard layout, threshold editor.
A `<Switch>` inside a draft+Save card is legal **only** when the card
shows the Save button; a card with both an auto-persisting switch and
a Save-gated field is a bug. Every setting presented under `/settings`
persists server-side. `localStorage` is reserved for device-local UI
state that is not a setting (sidebar collapse, last-update-check
timestamp, tour progress) — label such keys with a comment at the
declaration site and never render them with Save/Saved affordances.
**Validation.** Inline messages below the field. Field gets
`aria-invalid="true"` and a destructive border. Form-level errors land
+13
View File
@@ -3,6 +3,7 @@
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"undo": "Rückgängig",
"edit": "Bearbeiten",
"moreOptions": "Weitere Optionen",
"create": "Erstellen",
@@ -449,6 +450,9 @@
"saveError": "Fehler beim Speichern",
"duplicateTimestamp": "Zum gewählten Zeitpunkt existiert bereits ein Messwert dieses Typs.",
"deleteError": "Fehler beim Löschen",
"deletedToast": "Messwert gelöscht",
"restoredToast": "Messwert wiederhergestellt",
"restoreError": "Wiederherstellen fehlgeschlagen",
"invalidValue": "Bitte einen gültigen Wert eingeben.",
"invalidTimestamp": "Bitte einen gültigen Zeitpunkt wählen.",
"loginRequired": "Bitte anmelden, um Messwerte zu sehen.",
@@ -547,6 +551,9 @@
"formResetConfirmAction": "Zurücksetzen",
"saveError": "Fehler beim Speichern",
"deleteError": "Fehler beim Löschen",
"deletedToast": "Eintrag gelöscht",
"restoredToast": "Eintrag wiederhergestellt",
"restoreError": "Wiederherstellen fehlgeschlagen",
"invalidTimestamp": "Bitte einen gültigen Zeitpunkt wählen.",
"deleteConfirmTitle": "Eintrag löschen?",
"deleteConfirmDescription": "Dieser Stimmungseintrag wird unwiderruflich gelöscht.",
@@ -806,6 +813,7 @@
"loading": "Wird geladen…",
"deleteCta": "Löschen",
"deleteConfirm": "Eintrag löschen?",
"deleteError": "Eintrag konnte nicht gelöscht werden",
"errorSeverity": "Schweregrad muss zwischen 1 und 5 liegen",
"errorEntry": "Bitte ein Symptom auswählen",
"categories": {
@@ -1975,6 +1983,7 @@
"errorBudget": "Du hast das heutige Coach-Limit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.",
"errorProvider": "Der Coach konnte gerade keinen Auswertungs-Anbieter erreichen. Bitte versuche es in einem Moment erneut.",
"errorNetwork": "Keine Internetverbindung — versuche es erneut, sobald du online bist.",
"prefsSaveError": "Coach-Einstellungen konnten nicht gespeichert werden. Bitte versuche es erneut.",
"errorCredentialExpired": "Deine Verbindung zum KI-Anbieter ist abgelaufen. Stelle sie in den Einstellungen wieder her, damit der Coach weiterläuft.",
"tagline": "Persönlicher Gesundheitscoach, auf deine Daten gestützt.",
"newChat": "Neuer Chat",
@@ -1995,6 +2004,7 @@
"failed": "Übernahme fehlgeschlagen. Bitte erneut versuchen."
},
"selfContextChipDismiss": "Frage verwerfen",
"selfContextChipDismissError": "Frage konnte nicht ausgeblendet werden",
"voiceComingSoon": "Spracheingabe kommt mit der iOS-App in v1.5.",
"threadEmpty": "Frag mich etwas zu deinen Trends, Medikamenten oder Messwerten, um zu starten.",
"historyTitle": "Unterhaltungen",
@@ -2085,6 +2095,7 @@
"feedbackHelpful": "Hilfreich",
"feedbackUnhelpful": "Nicht ganz",
"feedbackThanks": "Danke für das Signal.",
"feedbackError": "Feedback konnte nicht gespeichert werden",
"dailyLimitTitle": "Heute-Limit erreicht",
"dailyLimitBody": "Heute-Limit erreicht; Reset um 00:00 UTC.",
"providerRateLimitTitle": "Provider rate-limited",
@@ -5641,6 +5652,8 @@
"EMERGENCY": "Notfall"
},
"loadError": "Zyklusdaten konnten nicht geladen werden.",
"saveError": "Zykluseintrag konnte nicht gespeichert werden.",
"deleteError": "Eintrag konnte nicht gelöscht werden.",
"bbt": {
"title": "Basaltemperatur",
"caption": "Deine Morgentemperatur über diesen Zyklus. Ein anhaltender Anstieg um etwa 0,2 °C folgt auf den Eisprung.",
+13
View File
@@ -3,6 +3,7 @@
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"undo": "Undo",
"edit": "Edit",
"moreOptions": "More options",
"create": "Create",
@@ -449,6 +450,9 @@
"saveError": "Error saving",
"duplicateTimestamp": "A measurement of this type already exists at the selected timestamp.",
"deleteError": "Error deleting",
"deletedToast": "Measurement deleted",
"restoredToast": "Measurement restored",
"restoreError": "Restore failed",
"invalidValue": "Please enter a valid value.",
"invalidTimestamp": "Please select a valid time.",
"loginRequired": "Please sign in to view measurements.",
@@ -547,6 +551,9 @@
"formResetConfirmAction": "Reset",
"saveError": "Error saving",
"deleteError": "Error deleting",
"deletedToast": "Entry deleted",
"restoredToast": "Entry restored",
"restoreError": "Restore failed",
"invalidTimestamp": "Please select a valid time.",
"deleteConfirmTitle": "Delete entry?",
"deleteConfirmDescription": "This mood entry will be permanently deleted.",
@@ -806,6 +813,7 @@
"loading": "Loading…",
"deleteCta": "Delete",
"deleteConfirm": "Delete this entry?",
"deleteError": "Could not delete the entry",
"errorSeverity": "Severity must be between 1 and 5",
"errorEntry": "Pick a symptom",
"categories": {
@@ -1975,6 +1983,7 @@
"errorBudget": "You've reached today's Coach usage limit. The budget refreshes at midnight UTC.",
"errorProvider": "The Coach could not reach an Insights provider just now. Please try again in a moment.",
"errorNetwork": "No internet connection — try again once you're back online.",
"prefsSaveError": "Couldn't save your Coach preferences. Please try again.",
"errorCredentialExpired": "Your AI provider connection has expired. Reconnect it in Settings to keep the Coach running.",
"tagline": "Personal health coach, grounded in your data.",
"newChat": "New chat",
@@ -1995,6 +2004,7 @@
"failed": "Could not add. Please try again."
},
"selfContextChipDismiss": "Dismiss question",
"selfContextChipDismissError": "Could not dismiss the question",
"voiceComingSoon": "Voice input arrives with the iOS app in v1.5.",
"threadEmpty": "Ask anything about your trends, medications, or readings to start a conversation.",
"historyTitle": "Conversations",
@@ -2085,6 +2095,7 @@
"feedbackHelpful": "Helpful",
"feedbackUnhelpful": "Not quite",
"feedbackThanks": "Thanks for the signal.",
"feedbackError": "Could not save feedback",
"dailyLimitTitle": "Daily limit reached",
"dailyLimitBody": "Daily limit reached; resets at 00:00 UTC.",
"providerRateLimitTitle": "Provider rate-limited",
@@ -5641,6 +5652,8 @@
"EMERGENCY": "Emergency"
},
"loadError": "Couldn't load your cycle data.",
"saveError": "Couldn't save your cycle entry.",
"deleteError": "Couldn't delete the entry.",
"bbt": {
"title": "Basal body temperature",
"caption": "Your morning temperature across this cycle. A sustained rise of about 0.2 °C follows ovulation.",
+13
View File
@@ -3,6 +3,7 @@
"save": "Guardar",
"cancel": "Cancelar",
"delete": "Eliminar",
"undo": "Deshacer",
"edit": "Editar",
"moreOptions": "Más opciones",
"create": "Crear",
@@ -449,6 +450,9 @@
"saveError": "Error al guardar",
"duplicateTimestamp": "Ya existe una medición de este tipo en la marca de tiempo seleccionada.",
"deleteError": "Error al eliminar",
"deletedToast": "Medición eliminada",
"restoredToast": "Medición restaurada",
"restoreError": "Error al restaurar",
"invalidValue": "Introduce un valor válido.",
"invalidTimestamp": "Selecciona una fecha y hora válidas.",
"loginRequired": "Inicia sesión para ver las mediciones.",
@@ -547,6 +551,9 @@
"formResetConfirmAction": "Restablecer",
"saveError": "Error al guardar",
"deleteError": "Error al eliminar",
"deletedToast": "Entrada eliminada",
"restoredToast": "Entrada restaurada",
"restoreError": "Error al restaurar",
"invalidTimestamp": "Selecciona una fecha y hora válidas.",
"deleteConfirmTitle": "¿Eliminar entrada?",
"deleteConfirmDescription": "Esta entrada de estado de ánimo se eliminará de forma permanente.",
@@ -806,6 +813,7 @@
"loading": "Cargando…",
"deleteCta": "Eliminar",
"deleteConfirm": "¿Eliminar esta entrada?",
"deleteError": "No se pudo eliminar la entrada",
"errorSeverity": "La gravedad debe estar entre 1 y 5",
"errorEntry": "Elige un síntoma",
"categories": {
@@ -1975,6 +1983,7 @@
"errorBudget": "Has alcanzado el límite diario del coach. El cupo se reinicia a medianoche (UTC).",
"errorProvider": "El coach no ha podido contactar con un proveedor de análisis ahora mismo. Inténtalo en un momento.",
"errorNetwork": "Sin conexión a Internet — inténtalo de nuevo cuando vuelvas a estar en línea.",
"prefsSaveError": "No se pudieron guardar tus preferencias del Coach. Inténtalo de nuevo.",
"errorCredentialExpired": "Tu conexión con el proveedor de IA ha caducado. Vuelve a conectarla en Ajustes para que el Coach siga funcionando.",
"tagline": "Coach personal de salud, apoyado en tus datos.",
"newChat": "Nueva conversación",
@@ -1995,6 +2004,7 @@
"failed": "No se pudo añadir. Inténtalo de nuevo."
},
"selfContextChipDismiss": "Descartar pregunta",
"selfContextChipDismissError": "No se pudo descartar la pregunta",
"voiceComingSoon": "La entrada por voz llega con la app de iOS en la v1.5.",
"threadEmpty": "Pregúntame algo sobre tus tendencias, medicamentos o mediciones para empezar.",
"historyTitle": "Conversaciones",
@@ -2083,6 +2093,7 @@
"feedbackHelpful": "Útil",
"feedbackUnhelpful": "Poco útil",
"feedbackThanks": "Gracias por la indicación.",
"feedbackError": "No se pudo guardar el comentario",
"dailyLimitTitle": "Límite diario alcanzado",
"dailyLimitBody": "Límite diario alcanzado; reinicio a las 00:00 UTC.",
"providerRateLimitTitle": "Proveedor con límite de tasa",
@@ -5641,6 +5652,8 @@
"EMERGENCY": "Emergencia"
},
"loadError": "No se pudieron cargar tus datos del ciclo.",
"saveError": "No se pudo guardar tu registro del ciclo.",
"deleteError": "No se pudo eliminar la entrada.",
"bbt": {
"title": "Temperatura basal",
"caption": "Tu temperatura matutina a lo largo de este ciclo. Una subida sostenida de unos 0,2 °C sigue a la ovulación.",
+13
View File
@@ -3,6 +3,7 @@
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer",
"undo": "Annuler",
"edit": "Modifier",
"moreOptions": "Plus d'options",
"create": "Créer",
@@ -449,6 +450,9 @@
"saveError": "Erreur lors de l’enregistrement",
"duplicateTimestamp": "Une mesure de ce type existe déjà à cet horodatage.",
"deleteError": "Erreur lors de la suppression",
"deletedToast": "Mesure supprimée",
"restoredToast": "Mesure restaurée",
"restoreError": "Échec de la restauration",
"invalidValue": "Saisis une valeur valide.",
"invalidTimestamp": "Choisis une date et une heure valides.",
"loginRequired": "Connecte-toi pour voir tes mesures.",
@@ -547,6 +551,9 @@
"formResetConfirmAction": "Réinitialiser",
"saveError": "Erreur lors de l’enregistrement",
"deleteError": "Erreur lors de la suppression",
"deletedToast": "Entrée supprimée",
"restoredToast": "Entrée restaurée",
"restoreError": "Échec de la restauration",
"invalidTimestamp": "Choisis une date et une heure valides.",
"deleteConfirmTitle": "Supprimer l’entrée ?",
"deleteConfirmDescription": "Cette entrée d’humeur sera supprimée définitivement.",
@@ -806,6 +813,7 @@
"loading": "Chargement…",
"deleteCta": "Supprimer",
"deleteConfirm": "Supprimer cette entrée ?",
"deleteError": "Impossible de supprimer l'entrée",
"errorSeverity": "La sévérité doit être entre 1 et 5",
"errorEntry": "Choisissez un symptôme",
"categories": {
@@ -1975,6 +1983,7 @@
"errorBudget": "Tu as atteint la limite quotidienne du coach. Le quota se réinitialise à minuit (UTC).",
"errorProvider": "Le coach n’a pas pu joindre un fournisseur d’analyse maintenant. Réessaie dans un instant.",
"errorNetwork": "Pas de connexion Internet — réessaie une fois reconnecté.",
"prefsSaveError": "Impossible d’enregistrer vos préférences du Coach. Réessayez.",
"errorCredentialExpired": "Ta connexion au fournisseur d'IA a expiré. Reconnecte-la dans les Réglages pour que le Coach continue de fonctionner.",
"tagline": "Coach personnel de santé, ancré sur tes données.",
"newChat": "Nouvelle conversation",
@@ -1995,6 +2004,7 @@
"failed": "Échec de l’ajout. Veuillez réessayer."
},
"selfContextChipDismiss": "Ignorer la question",
"selfContextChipDismissError": "Impossible de masquer la question",
"voiceComingSoon": "La saisie vocale arrive avec l’app iOS en v1.5.",
"threadEmpty": "Pose une question sur tes tendances, médicaments ou mesures pour démarrer.",
"historyTitle": "Conversations",
@@ -2083,6 +2093,7 @@
"feedbackHelpful": "Utile",
"feedbackUnhelpful": "Peu utile",
"feedbackThanks": "Merci pour l’indication.",
"feedbackError": "Impossible d'enregistrer le retour",
"dailyLimitTitle": "Limite quotidienne atteinte",
"dailyLimitBody": "Limite quotidienne atteinte ; réinitialisation à 00:00 UTC.",
"providerRateLimitTitle": "Fournisseur limité en débit",
@@ -5641,6 +5652,8 @@
"EMERGENCY": "Urgence"
},
"loadError": "Impossible de charger vos données de cycle.",
"saveError": "Impossible d’enregistrer votre saisie de cycle.",
"deleteError": "Impossible de supprimer l’entrée.",
"bbt": {
"title": "Température basale",
"caption": "Votre température matinale sur ce cycle. Une hausse durable d'environ 0,2 °C suit l'ovulation.",
+13
View File
@@ -3,6 +3,7 @@
"save": "Salva",
"cancel": "Annulla",
"delete": "Elimina",
"undo": "Annulla",
"edit": "Modifica",
"moreOptions": "Altre opzioni",
"create": "Crea",
@@ -449,6 +450,9 @@
"saveError": "Errore durante il salvataggio",
"duplicateTimestamp": "Esiste già una misurazione di questo tipo a questa marca temporale.",
"deleteError": "Errore durante l’eliminazione",
"deletedToast": "Misurazione eliminata",
"restoredToast": "Misurazione ripristinata",
"restoreError": "Ripristino non riuscito",
"invalidValue": "Inserisci un valore valido.",
"invalidTimestamp": "Scegli una data e un’ora valide.",
"loginRequired": "Accedi per vedere le misurazioni.",
@@ -547,6 +551,9 @@
"formResetConfirmAction": "Reimposta",
"saveError": "Errore durante il salvataggio",
"deleteError": "Errore durante l’eliminazione",
"deletedToast": "Voce eliminata",
"restoredToast": "Voce ripristinata",
"restoreError": "Ripristino non riuscito",
"invalidTimestamp": "Scegli una data e un’ora valide.",
"deleteConfirmTitle": "Eliminare la voce?",
"deleteConfirmDescription": "Questa voce dell’umore sarà eliminata in modo definitivo.",
@@ -806,6 +813,7 @@
"loading": "Caricamento…",
"deleteCta": "Elimina",
"deleteConfirm": "Eliminare questa voce?",
"deleteError": "Impossibile eliminare la voce",
"errorSeverity": "La gravità deve essere tra 1 e 5",
"errorEntry": "Scegli un sintomo",
"categories": {
@@ -1975,6 +1983,7 @@
"errorBudget": "Hai raggiunto il limite giornaliero del coach. Il budget si reimposta a mezzanotte (UTC).",
"errorProvider": "Il coach non è riuscito a contattare un provider di analisi ora. Riprova fra poco.",
"errorNetwork": "Nessuna connessione Internet — riprova quando sei di nuovo online.",
"prefsSaveError": "Impossibile salvare le preferenze del Coach. Riprova.",
"errorCredentialExpired": "La connessione al provider di IA è scaduta. Riconnettila nelle Impostazioni per mantenere attivo il Coach.",
"tagline": "Coach personale di salute, basato sui tuoi dati.",
"newChat": "Nuova chat",
@@ -1995,6 +2004,7 @@
"failed": "Aggiunta non riuscita. Riprova."
},
"selfContextChipDismiss": "Ignora la domanda",
"selfContextChipDismissError": "Impossibile ignorare la domanda",
"voiceComingSoon": "L’input vocale arriva con l’app iOS in v1.5.",
"threadEmpty": "Chiedimi qualcosa sui tuoi trend, farmaci o misurazioni per iniziare.",
"historyTitle": "Conversazioni",
@@ -2083,6 +2093,7 @@
"feedbackHelpful": "Utile",
"feedbackUnhelpful": "Poco utile",
"feedbackThanks": "Grazie per il segnale.",
"feedbackError": "Impossibile salvare il feedback",
"dailyLimitTitle": "Limite giornaliero raggiunto",
"dailyLimitBody": "Limite giornaliero raggiunto; reset alle 00:00 UTC.",
"providerRateLimitTitle": "Provider con rate-limit",
@@ -5641,6 +5652,8 @@
"EMERGENCY": "Emergenza"
},
"loadError": "Impossibile caricare i dati del ciclo.",
"saveError": "Impossibile salvare la voce del ciclo.",
"deleteError": "Impossibile eliminare la voce.",
"bbt": {
"title": "Temperatura basale",
"caption": "La tua temperatura mattutina in questo ciclo. Un rialzo prolungato di circa 0,2 °C segue l'ovulazione.",
+13
View File
@@ -3,6 +3,7 @@
"save": "Zapisz",
"cancel": "Anuluj",
"delete": "Usuń",
"undo": "Cofnij",
"edit": "Edytuj",
"moreOptions": "Więcej opcji",
"create": "Utwórz",
@@ -449,6 +450,9 @@
"saveError": "Błąd zapisu",
"duplicateTimestamp": "Pomiar tego typu już istnieje dla wybranej daty.",
"deleteError": "Błąd usuwania",
"deletedToast": "Pomiar usunięty",
"restoredToast": "Pomiar przywrócony",
"restoreError": "Przywracanie nie powiodło się",
"invalidValue": "Wprowadź poprawną wartość.",
"invalidTimestamp": "Wybierz poprawną datę i godzinę.",
"loginRequired": "Zaloguj się, aby zobaczyć pomiary.",
@@ -547,6 +551,9 @@
"formResetConfirmAction": "Resetuj",
"saveError": "Błąd zapisu",
"deleteError": "Błąd usuwania",
"deletedToast": "Wpis usunięty",
"restoredToast": "Wpis przywrócony",
"restoreError": "Przywracanie nie powiodło się",
"invalidTimestamp": "Wybierz poprawną datę i godzinę.",
"deleteConfirmTitle": "Usunąć wpis?",
"deleteConfirmDescription": "Ten wpis nastroju zostanie nieodwracalnie usunięty.",
@@ -806,6 +813,7 @@
"loading": "Ładowanie…",
"deleteCta": "Usuń",
"deleteConfirm": "Usunąć ten wpis?",
"deleteError": "Nie udało się usunąć wpisu",
"errorSeverity": "Nasilenie musi mieścić się w zakresie 1–5",
"errorEntry": "Wybierz objaw",
"categories": {
@@ -1975,6 +1983,7 @@
"errorBudget": "Osiągnięto dzienny limit coacha. Limit resetuje się o północy (UTC).",
"errorProvider": "Coach nie mógł teraz skontaktować się z dostawcą analizy. Spróbuj za chwilę.",
"errorNetwork": "Brak połączenia z Internetem — spróbuj ponownie, gdy wrócisz online.",
"prefsSaveError": "Nie udało się zapisać ustawień Coacha. Spróbuj ponownie.",
"errorCredentialExpired": "Połączenie z dostawcą AI wygasło. Połącz je ponownie w Ustawieniach, aby Coach działał dalej.",
"tagline": "Osobisty coach zdrowia, oparty na twoich danych.",
"newChat": "Nowa rozmowa",
@@ -1995,6 +2004,7 @@
"failed": "Nie udało się dodać. Spróbuj ponownie."
},
"selfContextChipDismiss": "Odrzuć pytanie",
"selfContextChipDismissError": "Nie udało się odrzucić pytania",
"voiceComingSoon": "Wejście głosowe pojawi się z aplikacją iOS w v1.5.",
"threadEmpty": "Zapytaj o swoje trendy, leki lub pomiary, aby zacząć.",
"historyTitle": "Rozmowy",
@@ -2083,6 +2093,7 @@
"feedbackHelpful": "Przydatne",
"feedbackUnhelpful": "Mało przydatne",
"feedbackThanks": "Dzięki za sygnał.",
"feedbackError": "Nie udało się zapisać opinii",
"dailyLimitTitle": "Osiągnięto dzienny limit",
"dailyLimitBody": "Osiągnięto dzienny limit; reset o 00:00 UTC.",
"providerRateLimitTitle": "Dostawca z ograniczeniem",
@@ -5641,6 +5652,8 @@
"EMERGENCY": "Awaryjna"
},
"loadError": "Nie udało się wczytać danych cyklu.",
"saveError": "Nie udało się zapisać wpisu cyklu.",
"deleteError": "Nie udało się usunąć wpisu.",
"bbt": {
"title": "Podstawowa temperatura ciała",
"caption": "Twoja poranna temperatura w tym cyklu. Trwały wzrost o około 0,2 °C następuje po owulacji.",
@@ -9,7 +9,7 @@ import { join } from "node:path";
* thick `/api/analytics` envelope; every per-type tile waited on the
* heavy fan-out before paint. Mood and medication tiles arrived first
* (separate routes) and then every other tile arrived as one burst,
* which Marc reported as "etwas nervig" in the v1.4.39.1 post-deploy
* which the maintainer reported as "etwas nervig" in the v1.4.39.1 post-deploy
* trace.
*
* Post-fix the dashboard mounts TWO `useAnalyticsQuery` calls in
@@ -70,19 +70,19 @@ describe("GET /api/admin/audit-log — extended filters", () => {
});
it("filters by `actor` against userId OR user.username", async () => {
await GET(req("actor=marc"));
await GET(req("actor=testuser"));
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0]!;
const where = call.where as Record<string, unknown>;
// Expect an OR clause covering userId + user.username (contains)
expect(where.OR).toBeDefined();
const or = where.OR as Array<Record<string, unknown>>;
const matchesUserId = or.some((c) => c.userId === "marc");
const matchesUserId = or.some((c) => c.userId === "testuser");
const matchesUsername = or.some(
(c) =>
typeof c.user === "object" &&
c.user !== null &&
// user: { username: { contains: "marc", mode: "insensitive" } }
JSON.stringify(c).toLowerCase().includes("marc"),
// user: { username: { contains: "testuser", mode: "insensitive" } }
JSON.stringify(c).toLowerCase().includes("testuser"),
);
expect(matchesUserId).toBe(true);
expect(matchesUsername).toBe(true);
@@ -60,7 +60,7 @@ import { getSession } from "@/lib/auth/session";
const ADMIN_SESSION = {
session: { id: "sess-admin", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "marc", role: "ADMIN" as const },
user: { id: "admin-1", username: "testuser", role: "ADMIN" as const },
};
const USER_SESSION = {
session: { id: "sess-user", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -4,7 +4,7 @@ import { annotate, getEvent } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/timezone";
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day";
export const dynamic = "force-dynamic";
@@ -33,7 +33,7 @@ import { prisma } from "@/lib/db";
const ADMIN_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "marc", role: "ADMIN" as const },
user: { id: "admin-1", username: "testuser", role: "ADMIN" as const },
};
const USER_OK = {
session: { id: "sess-2", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -71,7 +71,7 @@ describe("PUT /api/admin/users/[id]", () => {
new HttpError(401, "Not authenticated"),
);
await expect(
PUT(jsonReq({ username: "marc" }), params("u1")),
PUT(jsonReq({ username: "testuser" }), params("u1")),
).rejects.toThrow("Not authenticated");
});
@@ -80,7 +80,7 @@ describe("PUT /api/admin/users/[id]", () => {
new HttpError(403, "Admin access required"),
);
await expect(
PUT(jsonReq({ username: "marc" }), params("u1")),
PUT(jsonReq({ username: "testuser" }), params("u1")),
).rejects.toThrow("Admin access required");
});
@@ -97,7 +97,7 @@ describe("PUT /api/admin/users/[id]", () => {
it("returns 404 when target user does not exist", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
const res = await PUT(jsonReq({ username: "marc" }), params("u-missing"));
const res = await PUT(jsonReq({ username: "testuser" }), params("u-missing"));
expect(res.status).toBe(404);
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
});
@@ -102,7 +102,7 @@ describe("POST /api/auth/check-user — audit-log row (M-1)", () => {
_count: { passkeys: 2 },
} as never);
const res = await POST(makeRequest({ identifier: "marc" }));
const res = await POST(makeRequest({ identifier: "testuser" }));
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledTimes(1);
@@ -111,7 +111,7 @@ describe("POST /api/auth/check-user — audit-log row (M-1)", () => {
ipAddress: "203.0.113.1",
details: {
branch: "passkey_only",
identifier_hash: expectedHash("marc"),
identifier_hash: expectedHash("testuser"),
},
});
});
@@ -123,13 +123,13 @@ describe("POST /api/auth/check-user — audit-log row (M-1)", () => {
_count: { passkeys: 0 },
} as never);
const res = await POST(makeRequest({ identifier: "marc@example.com" }));
const res = await POST(makeRequest({ identifier: "user@example.com" }));
expect(res.status).toBe(200);
const args = vi.mocked(auditLog).mock.calls[0];
expect(args[1]?.details).toEqual({
branch: "email_fallback",
identifier_hash: expectedHash("marc@example.com"),
identifier_hash: expectedHash("user@example.com"),
});
expect(args[1]?.userId).toBe("user-2");
});
@@ -75,8 +75,8 @@ import { createSession } from "@/lib/auth/session";
const FAKE_USER = {
id: "user-1",
username: "marc",
email: "marc@example.com",
username: "testuser",
email: "user@example.com",
passwordHash: "$argon2id$...",
};
@@ -88,7 +88,7 @@ function makeRequest(headers: Record<string, string> = {}): NextRequest {
...headers,
},
body: JSON.stringify({
email: "marc@example.com",
email: "user@example.com",
password: "supersecret",
}),
});
@@ -48,7 +48,7 @@ import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function req(): NextRequest {
@@ -35,7 +35,7 @@ import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function req(deviceIdHeader?: string): NextRequest {
@@ -47,7 +47,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkPatch(body: unknown): Request {
@@ -39,7 +39,7 @@ import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-pre
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkPut(body: unknown): Request {
@@ -69,7 +69,7 @@ describe("GET /api/auth/me/doctor-report-prefs", () => {
data: typeof DEFAULT_DOCTOR_REPORT_PREFS;
};
expect(env.data).toEqual(DEFAULT_DOCTOR_REPORT_PREFS);
expect(env.data.mood).toBe(false); // privacy default per Marc
expect(env.data.mood).toBe(false); // privacy default per the maintainer
});
});
@@ -145,7 +145,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => {
});
// ── Case 4 — unauthenticated cross-user request ────────────────────
// Marc's spec mentions "cross-user 404" — the route layer returns 401
// the maintainer's spec mentions "cross-user 404" — the route layer returns 401
// when there is no session at all, which is the only "you are not the
// right user" surface available before requireAuth() resolves an
// identity. `prisma.user.update` must NOT fire in this case.
@@ -48,7 +48,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkPatch(body: unknown): Request {
@@ -48,7 +48,7 @@ import { RESEARCH_MODE_DISCLAIMER_VERSION } from "@/lib/medications/glp1-pk";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkPost(body: unknown): Request {
@@ -37,7 +37,7 @@ import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkPut(body: unknown): Request {
@@ -38,7 +38,7 @@ import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkReq(body: unknown): NextRequest {
@@ -46,7 +46,7 @@ import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkPatch(body: unknown): Request {
@@ -60,8 +60,8 @@ import {
const FAKE_USER = {
id: "user-1",
username: "marc",
email: "marc@example.com",
username: "testuser",
email: "user@example.com",
};
function makeRequest(headers: Record<string, string> = {}): NextRequest {
@@ -41,7 +41,7 @@ import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function req(body: unknown): NextRequest {
@@ -41,7 +41,7 @@ import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
beforeEach(() => {
@@ -65,7 +65,7 @@ const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "marc",
username: "testuser",
role: "USER" as const,
displayName: null,
},
@@ -134,7 +134,7 @@ describe("GET /api/dashboard/summary", () => {
metrics: Array<{ id: string; kind: string; sparkline: number[] }>;
};
};
expect(body.data.greeting.salutation).toBe("Hi, marc");
expect(body.data.greeting.salutation).toBe("Hi, testuser");
expect(body.data.streak.currentDays).toBe(0);
expect(body.data.compliance.scheduledToday).toBe(0);
// REG-11 (v1.4.44): weight is the only always-emitted base metric.
@@ -48,7 +48,7 @@ import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function req(): NextRequest {
+1 -1
View File
@@ -59,7 +59,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function req(body: unknown): NextRequest {
@@ -72,7 +72,7 @@ function setEmptyDataMocks() {
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "marc",
username: "testuser",
dateOfBirth: null,
gender: null,
heightCm: null,
@@ -3,7 +3,7 @@
*
* Returns one boolean per toggleable section so the dialog can hide
* checkboxes for data types that have zero rows in the user-selected
* date range (Marc's "hide-when-empty" rule). Pure presence check —
* date range (the maintainer's "hide-when-empty" rule). Pure presence check —
* `count` queries on small indexed tables — so this is cheap enough to
* run on every date-range change in the dialog without driving cost.
*
@@ -36,7 +36,7 @@ import { prisma } from "@/lib/db";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function req(): NextRequest {
@@ -43,7 +43,7 @@ const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "marc",
username: "testuser",
role: "USER" as const,
createdAt: new Date("2026-03-01T00:00:00.000Z"),
heightCm: null,
@@ -43,7 +43,7 @@ const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "marc",
username: "testuser",
role: "USER" as const,
createdAt: new Date("2026-03-01T00:00:00.000Z"),
heightCm: null,
@@ -37,7 +37,7 @@ import { getSession } from "@/lib/auth/session";
const OWNER_SESSION = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const OTHER_SESSION = {
session: { id: "sess-2", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -45,7 +45,7 @@ const OTHER_SESSION = {
};
const ADMIN_SESSION = {
session: { id: "sess-a", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "marc-admin", role: "ADMIN" as const },
user: { id: "admin-1", username: "test-admin", role: "ADMIN" as const },
};
const NOW = new Date("2026-05-15T10:00:00.000Z");
@@ -58,7 +58,7 @@ import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
beforeEach(() => {
@@ -45,7 +45,7 @@ import { prisma } from "@/lib/db";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -41,7 +41,7 @@ import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
beforeEach(() => {
@@ -97,7 +97,7 @@ const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-comp-1",
username: "marc",
username: "testuser",
role: "USER" as const,
locale: "en",
},
@@ -45,7 +45,7 @@ import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
beforeEach(() => {
+2 -2
View File
@@ -21,7 +21,7 @@ import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { prisma } from "@/lib/db";
import { getLocalDateParts } from "@/lib/timezone";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import type { MeasurementType } from "@/generated/prisma/client";
import {
discoverCorrelations,
@@ -39,7 +39,7 @@ const WINDOW_DAYS = 180;
/** Day key (YYYY-MM-DD) for an instant in the user's display timezone. */
function tzDayKey(at: Date, tz: string): string {
const { year, month, day } = getLocalDateParts(at, tz);
const { year, month, day } = wallClockInTz(at, tz);
const mm = String(month).padStart(2, "0");
const dd = String(day).padStart(2, "0");
return `${year}-${mm}-${dd}`;
@@ -64,7 +64,7 @@ import { computeDerivedMetric } from "@/lib/insights/derived";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -54,7 +54,7 @@ import { computeDerivedMetric } from "@/lib/insights/derived";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -268,7 +268,7 @@ describe("POST /api/insights/generate — provider error mapping", () => {
});
// v1.4.16 A7.1: rate limit raised from 2 → 10/h, env-configurable.
// the maintainer reported the previous 2/h was too aggressive when iterating on
// The maintainer reported the previous 2/h was too aggressive when iterating on
// settings. The 10/h ceiling is the new default; the env override lets
// operators on a tight LLM budget dial it back without a rebuild.
describe("POST /api/insights/generate — rate limit (v1.4.16 A7.1)", () => {
@@ -47,7 +47,7 @@ import { generateMetricStatus } from "@/lib/insights/metric-status";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -46,7 +46,7 @@ import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
};
const callPost = POST as unknown as (req: NextRequest) => Promise<Response>;
@@ -41,7 +41,7 @@ import { prisma } from "@/lib/db";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -85,7 +85,7 @@ import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
const SESSION_USER = {
id: "user-targets-1",
username: "marc",
username: "testuser",
role: "USER" as const,
timezone: "Europe/Berlin",
heightCm: 180,
@@ -37,7 +37,7 @@ import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
beforeEach(() => {
@@ -28,7 +28,7 @@ import { MEASUREMENT_CATEGORIES } from "@/lib/measurements/categories";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function req(): NextRequest {
@@ -36,7 +36,7 @@ import { Prisma } from "@/generated/prisma/client";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function putRequest(id: string, body: Record<string, unknown>): NextRequest {
@@ -350,7 +350,7 @@ describe("GET /api/measurements — all-time semantics (SD-H1)", () => {
});
it("source=rollup folds the partition inline and re-reads when the rollup is under-converged (v1.4.39.2)", async () => {
// Marc's pre-fix production trace: 30-day BP_SYS window returned
// The maintainer's pre-fix production trace: 30-day BP_SYS window returned
// `total: 2` from the rollup branch even though `measurements`
// carried ~30 distinct days. The chart tripped its `< 3 daily
// points` empty-state because the route short-circuited on
@@ -60,7 +60,7 @@ import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function makeRequest(body: unknown): NextRequest {
@@ -0,0 +1,177 @@
/**
* v1.16.4 — POST /api/measurements/restore.
*
* Asserts the ownership-scoped un-tombstone contract that backs the
* delete-Undo toast: only owned, currently-tombstoned rows are touched;
* a forged / foreign / never-deleted id is a silent no-op (no 404
* existence leak); the rollup recompute collapses to the unique
* `(type, day)` set; the >200-id cap returns 422.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...args: Args) =>
fn(...args),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMeasurements: vi.fn(),
}));
vi.mock("@/lib/insights/comprehensive-generate", () => ({
invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rollups/measurement-rollups", async () => {
const actual = await vi.importActual<
typeof import("@/lib/rollups/measurement-rollups")
>("@/lib/rollups/measurement-rollups");
return {
// keep the real collapse so the test exercises the actual fan-out logic
collapseToTypeDayKeys: actual.collapseToTypeDayKeys,
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/measurements/restore", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(recomputeBucketsForMeasurement).mockResolvedValue(undefined);
vi.mocked(invalidateStatusInsightsForTypes).mockResolvedValue(undefined);
});
describe("POST /api/measurements/restore", () => {
it("un-tombstones only owned, tombstoned rows and returns that count", async () => {
// Caller asked for 3 ids but only 2 are owned + tombstoned.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T08:00:00Z") },
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T20:00:00Z") },
] as never);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
count: 2,
} as never);
const res = await POST(postReq({ ids: ["m1", "m2", "foreign"] }));
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { restored: number } };
expect(body.data.restored).toBe(2);
// updateMany is ownership-scoped + only touches tombstones — a
// forged / live id never 404s, it's just excluded from the count.
const call = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0];
expect(call.where).toMatchObject({
id: { in: ["m1", "m2", "foreign"] },
userId: "user-1",
deletedAt: { not: null },
});
expect(call.data).toMatchObject({
deletedAt: null,
syncVersion: { increment: 1 },
});
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1");
});
it("collapses the rollup recompute to the unique (type, day) set, not per-row", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T08:00:00Z") },
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T20:00:00Z") },
{ type: "WEIGHT", measuredAt: new Date("2026-01-02T09:00:00Z") },
{ type: "PULSE", measuredAt: new Date("2026-01-01T10:00:00Z") },
] as never);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
count: 4,
} as never);
await POST(postReq({ ids: ["a", "b", "c", "d"] }));
// 4 rows in, 3 distinct (type, day) recomputes out.
expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(3);
});
it("is a no-op (restored: 0) when no id is owned + tombstoned — no rollup, no invalidate", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
count: 0,
} as never);
const res = await POST(postReq({ ids: ["foreign-1", "live-1"] }));
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { restored: number } };
expect(body.data.restored).toBe(0);
expect(invalidateUserMeasurements).not.toHaveBeenCalled();
expect(recomputeBucketsForMeasurement).not.toHaveBeenCalled();
});
it("rejects a batch over the 200-id cap with 422", async () => {
const ids = Array.from({ length: 201 }, (_, i) => `m${i}`);
const res = await POST(postReq({ ids }));
expect(res.status).toBe(422);
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
});
it("rejects an empty id array with 422", async () => {
const res = await POST(postReq({ ids: [] }));
expect(res.status).toBe(422);
});
it("429s when the rate limit is exceeded", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
const res = await POST(postReq({ ids: ["m1"] }));
expect(res.status).toBe(429);
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
});
});
+144
View File
@@ -0,0 +1,144 @@
/**
* `POST /api/measurements/restore` — un-tombstone for the management
* list's delete-Undo affordance (v1.16.4).
*
* Body:
* { ids: string[] } — 1..200 measurement ids, scoped to the caller.
*
* Clears `deletedAt` (+ bumps `syncVersion`) on every owned,
* currently-tombstoned row in one `updateMany`, so the row re-surfaces
* in normal reads and the `/api/sync/changes` delta feed carries it as
* an upsert to paired clients. A forged / foreign / not-deleted id is a
* silent no-op (never a 404 existence leak): the mutation `where` pins
* `userId` so it only ever touches the caller's rows.
*
* Response (200):
* { restored: <number of rows un-tombstoned> }
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
} from "@/lib/rollups/measurement-rollups";
import type { MeasurementType } from "@/generated/prisma/client";
const MAX_IDS_PER_BATCH = 200;
const RATE_LIMIT_MAX = 60;
const RATE_LIMIT_WINDOW_MS = 60 * 1000;
const restoreSchema = z.object({
ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH),
});
export const POST = apiHandler(withIdempotency<[NextRequest]>(postRestore));
async function postRestore(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`measurements:restore:${user.id}`,
RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many restore requests, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 256 * 1024,
});
if (jsonError) return jsonError;
const parsed = restoreSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "measurement.restore.invalid",
});
}
const { ids } = parsed.data;
// Read the soon-to-be-restored rows first so we know which
// `(type, day)` buckets need a rollup recompute and which types need
// a status-insight cache drop. Ownership-scoped + `deletedAt != null`
// so a forged / foreign / never-deleted id is excluded here exactly as
// it is from the mutation below — no existence leak.
const affected = await prisma.measurement.findMany({
where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
select: { type: true, measuredAt: true },
});
// Un-tombstone in one statement. The `where` pins `userId` so a forged
// id for another user is silently a no-op; `deletedAt: { not: null }`
// keeps the count honest (a live row doesn't re-count).
const { count } = await prisma.measurement.updateMany({
where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
data: { deletedAt: null, syncVersion: { increment: 1 } },
});
await auditLog("measurement.restore", {
userId: user.id,
ipAddress: getClientIp(request),
details: { count },
});
annotate({
action: { name: "measurement.restore" },
meta: { count },
});
if (count > 0) {
// Bust per-user analytics + achievements + workouts caches so
// subsequent reads reflect the restored rows.
invalidateUserMeasurements(user.id);
// Collapse the restored rows to the unique `(type, day)` set BEFORE
// recomputing — mirrors the bulk-delete path. Best-effort: a
// populator hiccup never fails the user's restore.
const keys = collapseToTypeDayKeys(
affected.map((row) => ({
type: row.type as MeasurementType,
measuredAt: row.measuredAt,
})),
);
try {
for (const k of keys) {
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
}
} catch (err) {
console.warn("[measurements] restore rollup recompute failed", err);
}
// Drop the cached per-metric assessment rows the restore dirties so
// the next mount / nightly warm pass regenerates against the
// restored history. Fire-and-forget: never blocks the restore.
const affectedTypes = Array.from(new Set(keys.map((k) => k.type)));
if (affectedTypes.length > 0) {
invalidateStatusInsightsForTypes(user.id, affectedTypes).catch((err) => {
console.warn(
"[measurements] restore status-insight invalidate failed",
err,
);
});
}
}
return apiSuccess({ restored: count });
}
+2 -2
View File
@@ -319,7 +319,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
// `GET /api/measurements?type=…&aggregate=daily&limit=5000` requests
// (BP_SYS / BP_DIA / WEIGHT). The legacy `date_trunc('day', …)`
// path scanned the measurements table each time — a full year window
// on Marc's account hit 3 × ~3 s on the v1.4.35 HAR. Reading from
// on the maintainer's account hit 3 × ~3 s on the v1.4.35 HAR. Reading from
// the persistent `measurement_rollups` DAY buckets drops each call
// to a small indexed read against the ~5 k-row rollup table.
//
@@ -398,7 +398,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
// window) and finishes well inside the request budget.
//
// The probe runs only on suspicious rollups so the covered-tenant
// happy path stays a single indexed read. Marc's tenant on a 30-day
// happy path stays a single indexed read. The maintainer's tenant on a 30-day
// BP_SYS window pre-fix returned 2 rollup rows and hit `total: 2`
// in the live trace; post-fix the probe finds ~30 distinct days in
// `measurements`, folds them, and the chart paints all 30.
@@ -55,7 +55,7 @@ import { annotate } from "@/lib/logging/context";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function req(query: string): NextRequest {
@@ -39,7 +39,7 @@ import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
beforeEach(() => {
@@ -73,7 +73,7 @@ import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { cached } from "@/lib/cache/server-cache";
import { checkRateLimit } from "@/lib/rate-limit";
import { getUserTodayBounds } from "@/lib/timezone";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { userDayKey } from "@/lib/tz/format";
// UTC keeps the half-open day slice (`[dayStart, dayEnd)`) and the
@@ -84,7 +84,7 @@ const TZ = "UTC";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const, timezone: TZ },
user: { id: "user-1", username: "testuser", role: "USER" as const, timezone: TZ },
};
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
@@ -58,14 +58,14 @@ import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { checkRateLimit } from "@/lib/rate-limit";
import { localHmAsUtc } from "@/lib/timezone";
import { localHmAsUtc } from "@/lib/tz/local-day";
const TZ = "Europe/Berlin";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "marc",
username: "testuser",
role: "USER" as const,
timezone: TZ,
},
@@ -62,7 +62,7 @@ import { assertMedicationOwnership } from "@/lib/medications/route-guards";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const MED_OK = { id: "med-1", userId: "user-1" };
@@ -260,7 +260,7 @@ describe("PUT /api/medications/[id]/intake/[eventId] — one-shot reconcile on s
// v1.15.18 — PUT re-runs window-band slot attribution on a takenAt change
// ────────────────────────────────────────────────────────────────────
import { localHmAsUtc } from "@/lib/timezone";
import { localHmAsUtc } from "@/lib/tz/local-day";
const TZ = "Europe/Berlin";
const SESSION_TZ = {
@@ -98,7 +98,7 @@ const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "marc",
username: "testuser",
role: "USER" as const,
// Pin the user timezone so slot-instant resolution is deterministic
// regardless of the host TZ (CI runs in UTC, local in Europe/Berlin).
@@ -90,7 +90,7 @@ import {
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
@@ -52,7 +52,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const MED_OK = { id: "med-1", userId: "user-1" };
+1 -1
View File
@@ -31,7 +31,7 @@ import {
recomputeMedicationComplianceForDay,
} from "@/lib/rollups/medication-compliance-rollups";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { getUserTodayBounds } from "@/lib/timezone";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
@@ -5,7 +5,7 @@
* - hard delete; the row is user-owned, non-clinical, and the
* audit log captures enough state to reconstruct it if needed.
*
* Marc-decision (from the W19d brief): allow delete at any time
* maintainer decision (from the W19d brief): allow delete at any time
* rather than a 24-hour retraction window. Side-effect rows are not
* a clinical record; the user owns them and a stale mis-entry should
* never be undeletable. The audit trail preserves "what was deleted
@@ -60,7 +60,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const MED_OK = { id: "med-1", userId: "user-1" };
@@ -38,7 +38,7 @@ vi.mock("@/lib/medication-category", () => ({
getMedicationCategories: vi.fn().mockResolvedValue({}),
setMedicationCategory: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/timezone", () => ({
vi.mock("@/lib/tz/local-day", () => ({
getUserTodayBounds: vi
.fn()
.mockReturnValue({ start: new Date(), end: new Date() }),
@@ -66,7 +66,7 @@ import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
beforeEach(() => {
+1 -1
View File
@@ -34,7 +34,7 @@ import {
expectedSlotCountForDay,
lastNonSkippedTakenAt,
} from "@/lib/analytics/compliance";
import { getUserTodayBounds } from "@/lib/timezone";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
import {
applyCanonicalSlotWrite,
+1 -1
View File
@@ -19,7 +19,7 @@ import {
computeDisplayDue,
OVERDUE_LOOKBACK_MS,
} from "@/lib/medications/scheduling/next-due";
import { getUserTodayBounds } from "@/lib/timezone";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { NextRequest } from "next/server";
@@ -0,0 +1,161 @@
/**
* v1.16.4 — POST /api/mood-entries/restore.
*
* Asserts the ownership-scoped un-tombstone contract that backs the
* delete-Undo toast: only owned, currently-tombstoned rows are touched;
* a forged / foreign / never-deleted id is a silent no-op (no 404
* existence leak); the mood rollup recompute collapses to the unique
* day set; the >200-id cap returns 422.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
moodEntry: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...args: Args) =>
fn(...args),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMood: vi.fn(),
}));
vi.mock("@/lib/rollups/mood-rollups", () => ({
recomputeMoodBucketsForEntry: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/mood-entries/restore", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(recomputeMoodBucketsForEntry).mockResolvedValue(undefined as never);
});
describe("POST /api/mood-entries/restore", () => {
it("un-tombstones only owned, tombstoned rows and returns that count", async () => {
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
{ moodLoggedAt: new Date("2026-01-01T08:00:00Z") },
{ moodLoggedAt: new Date("2026-01-01T20:00:00Z") },
] as never);
vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({
count: 2,
} as never);
const res = await POST(postReq({ ids: ["e1", "e2", "foreign"] }));
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { restored: number } };
expect(body.data.restored).toBe(2);
const call = vi.mocked(prisma.moodEntry.updateMany).mock.calls[0][0];
expect(call.where).toMatchObject({
id: { in: ["e1", "e2", "foreign"] },
userId: "user-1",
deletedAt: { not: null },
});
expect(call.data).toMatchObject({
deletedAt: null,
syncVersion: { increment: 1 },
});
expect(invalidateUserMood).toHaveBeenCalledWith("user-1");
});
it("collapses the mood rollup recompute to the unique day set, not per-row", async () => {
// 3 restored rows across 2 distinct UTC days → 2 recomputes.
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
{ moodLoggedAt: new Date("2026-01-01T08:00:00Z") },
{ moodLoggedAt: new Date("2026-01-01T20:00:00Z") },
{ moodLoggedAt: new Date("2026-01-02T09:00:00Z") },
] as never);
vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({
count: 3,
} as never);
await POST(postReq({ ids: ["a", "b", "c"] }));
expect(recomputeMoodBucketsForEntry).toHaveBeenCalledTimes(2);
});
it("is a no-op (restored: 0) when no id is owned + tombstoned", async () => {
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({
count: 0,
} as never);
const res = await POST(postReq({ ids: ["foreign-1", "live-1"] }));
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { restored: number } };
expect(body.data.restored).toBe(0);
expect(invalidateUserMood).not.toHaveBeenCalled();
expect(recomputeMoodBucketsForEntry).not.toHaveBeenCalled();
});
it("rejects a batch over the 200-id cap with 422", async () => {
const ids = Array.from({ length: 201 }, (_, i) => `e${i}`);
const res = await POST(postReq({ ids }));
expect(res.status).toBe(422);
expect(prisma.moodEntry.updateMany).not.toHaveBeenCalled();
});
it("rejects an empty id array with 422", async () => {
const res = await POST(postReq({ ids: [] }));
expect(res.status).toBe(422);
});
it("429s when the rate limit is exceeded", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
const res = await POST(postReq({ ids: ["e1"] }));
expect(res.status).toBe(429);
expect(prisma.moodEntry.updateMany).not.toHaveBeenCalled();
});
});
+133
View File
@@ -0,0 +1,133 @@
/**
* `POST /api/mood-entries/restore` — un-tombstone for the mood list's
* delete-Undo affordance (v1.16.4).
*
* Body:
* { ids: string[] } — 1..200 mood-entry ids, scoped to the caller.
*
* Clears `deletedAt` (+ bumps `syncVersion`) on every owned,
* currently-tombstoned row in one `updateMany`, so the row re-surfaces
* in normal reads and the `/api/sync/changes` delta feed carries it as
* an upsert to paired clients. A forged / foreign / not-deleted id is a
* silent no-op (never a 404 existence leak): the mutation `where` pins
* `userId` so it only ever touches the caller's rows.
*
* Response (200):
* { restored: <number of rows un-tombstoned> }
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
const MAX_IDS_PER_BATCH = 200;
const RATE_LIMIT_MAX = 60;
const RATE_LIMIT_WINDOW_MS = 60 * 1000;
const restoreSchema = z.object({
ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH),
});
export const POST = apiHandler(withIdempotency<[NextRequest]>(postRestore));
async function postRestore(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`mood-entries:restore:${user.id}`,
RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many restore requests, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 256 * 1024,
});
if (jsonError) return jsonError;
const parsed = restoreSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "mood.restore.invalid",
});
}
const { ids } = parsed.data;
// Read the soon-to-be-restored rows first so we know which days need
// a mood-rollup recompute. Ownership-scoped + `deletedAt != null` so a
// forged / foreign / never-deleted id is excluded here exactly as it
// is from the mutation below — no existence leak.
const affected = await prisma.moodEntry.findMany({
where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
select: { moodLoggedAt: true },
});
// Un-tombstone in one statement. The `where` pins `userId` so a forged
// id for another user is silently a no-op; `deletedAt: { not: null }`
// keeps the count honest (a live row doesn't re-count).
const { count } = await prisma.moodEntry.updateMany({
where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
data: { deletedAt: null, syncVersion: { increment: 1 } },
});
await auditLog("mood.restore", {
userId: user.id,
ipAddress: getClientIp(request),
details: { count },
});
annotate({
action: { name: "mood.restore" },
meta: { count },
});
if (count > 0) {
// Bust per-user mood + achievements + analytics caches.
invalidateUserMood(user.id);
// Collapse the restored rows to the unique `(user, dayStart)` set
// BEFORE recomputing — mirrors the bulk-delete path. Best-effort: a
// populator hiccup never fails the user's restore.
const touchedDayStarts = new Set<number>();
for (const row of affected) {
const d = row.moodLoggedAt;
touchedDayStarts.add(
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()),
);
}
try {
await Promise.all(
Array.from(touchedDayStarts).map((t) =>
recomputeMoodBucketsForEntry(user.id, new Date(t)),
),
);
} catch (rollupErr) {
annotate({
meta: {
mood_rollup_write_failed: true,
mood_rollup_write_error:
rollupErr instanceof Error ? rollupErr.message : String(rollupErr),
},
});
}
}
return apiSuccess({ restored: count });
}
@@ -70,7 +70,7 @@ import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
const SESSION_USER = {
id: "user-mood-1",
username: "marc",
username: "testuser",
role: "USER" as const,
timezone: "Europe/Berlin",
heightCm: 180,
@@ -58,7 +58,7 @@ import { EVENT_TYPES } from "@/lib/notifications/types";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
beforeEach(() => {
@@ -63,7 +63,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function req(body: unknown): NextRequest {
@@ -36,7 +36,7 @@ import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function req(url: string): NextRequest {
@@ -46,7 +46,7 @@ import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function req(query: string): NextRequest {
@@ -34,7 +34,7 @@ import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
beforeEach(() => {
@@ -56,9 +56,9 @@ describe("GET /api/user/profile", () => {
it("returns flattened iOS-style fields", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "marc",
username: "testuser",
displayName: "Alex T.",
email: "marc@example.com",
email: "user@example.com",
dateOfBirth: new Date("1985-03-12T00:00:00.000Z"),
gender: "MALE",
heightCm: 180,
@@ -76,7 +76,7 @@ describe("GET /api/user/profile", () => {
locale: string | null;
};
};
expect(body.data.username).toBe("marc");
expect(body.data.username).toBe("testuser");
expect(body.data.displayName).toBe("Alex T.");
expect(body.data.heightCm).toBe(180);
expect(body.data.locale).toBe("de");
@@ -85,7 +85,7 @@ describe("GET /api/user/profile", () => {
it("echoes the insurer IK number on GET", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "marc",
username: "testuser",
displayName: null,
email: null,
dateOfBirth: null,
@@ -136,7 +136,7 @@ describe("PATCH /api/user/profile", () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.update).mockResolvedValue({
id: "user-1",
username: "marc",
username: "testuser",
displayName: "Alex T.",
email: null,
role: "USER",
@@ -165,7 +165,7 @@ describe("PATCH /api/user/profile", () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.update).mockResolvedValue({
id: "user-1",
username: "marc",
username: "testuser",
displayName: null,
email: null,
role: "USER",
@@ -209,7 +209,7 @@ describe("PATCH /api/user/profile", () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.update).mockResolvedValue({
id: "user-1",
username: "marc",
username: "testuser",
displayName: null,
email: null,
role: "USER",
@@ -76,7 +76,7 @@ import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function makeRequest(
@@ -52,7 +52,7 @@ import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function makeRequest(query = ""): NextRequest {
@@ -50,7 +50,7 @@ import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function makeRequest(): NextRequest {
+34 -44
View File
@@ -13,6 +13,7 @@ import { Separator } from "@/components/ui/separator";
import { describePasskeyError } from "@/lib/passkey-errors";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
export default function LoginPage() {
const router = useRouter();
@@ -33,12 +34,16 @@ export default function LoginPage() {
const { data: registrationEnabled } = useQuery({
queryKey: queryKeys.authRegistrationStatus(),
queryFn: async () => {
const res = await fetch("/api/auth/registration-status", {
cache: "no-store",
});
if (!res.ok) return true;
const json = await res.json();
return Boolean(json.data?.registrationEnabled ?? true);
try {
const data = await apiGet<{ registrationEnabled?: boolean }>(
"/api/auth/registration-status",
{ cache: "no-store" },
);
return Boolean(data?.registrationEnabled ?? true);
} catch {
// Fail open, exactly as the raw `if (!res.ok) return true` did.
return true;
}
},
staleTime: 60 * 1000,
});
@@ -58,38 +63,30 @@ export default function LoginPage() {
setLoading(true);
try {
const optRes = await fetch("/api/auth/passkey/login-options", {
method: "POST",
});
const optJson = await optRes.json();
if (!optRes.ok) {
setError(optJson.error);
setLoading(false);
return;
}
const { options, challengeId } = optJson.data;
const { startAuthentication } = await import("@simplewebauthn/browser");
const { options, challengeId } = await apiPost<{
options: Parameters<typeof startAuthentication>[0]["optionsJSON"];
challengeId: string;
}>("/api/auth/passkey/login-options");
const credential = await startAuthentication({ optionsJSON: options });
const verifyRes = await fetch("/api/auth/passkey/login-verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ challengeId, credential }),
await apiPost("/api/auth/passkey/login-verify", {
challengeId,
credential,
});
const verifyJson = await verifyRes.json();
if (!verifyRes.ok) {
setError(verifyJson.error || t("auth.loginFailed"));
setLoading(false);
return;
}
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push(getRedirectTarget());
} catch (err) {
const { key, params } = describePasskeyError(err);
setError(t(key, params));
// Route rejections carry the envelope error verbatim; everything
// else (WebAuthn ceremony failures) keeps the descriptive mapping.
if (err instanceof ApiError) {
setError(err.message || t("auth.loginFailed"));
} else {
const { key, params } = describePasskeyError(err);
setError(t(key, params));
}
} finally {
setLoading(false);
}
@@ -101,23 +98,16 @@ export default function LoginPage() {
setLoading(true);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const json = await res.json();
if (!res.ok) {
setError(json.error);
setLoading(false);
return;
}
await apiPost("/api/auth/login", { email, password });
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push(getRedirectTarget());
} catch {
setError(t("auth.loginFailed"));
} catch (err) {
setError(
err instanceof ApiError && err.message
? err.message
: t("auth.loginFailed"),
);
} finally {
setLoading(false);
}
+13 -19
View File
@@ -13,6 +13,7 @@ import { PasswordStrength } from "@/components/ui/password-strength";
import { useTranslations } from "@/lib/i18n/context";
import { detectBrowserTimezone } from "@/lib/tz/format";
import { queryKeys } from "@/lib/query-keys";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
export default function RegisterPage() {
const router = useRouter();
@@ -47,29 +48,22 @@ export default function RegisterPage() {
// bogus, so a tampered or proxied request can never poison
// the user's stored zone.
const timezone = detectBrowserTimezone();
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
username,
password,
timezone,
...(inviteToken ? { inviteToken } : {}),
}),
await apiPost("/api/auth/register", {
email,
username,
password,
timezone,
...(inviteToken ? { inviteToken } : {}),
});
const json = await res.json();
if (!res.ok) {
setError(json.error);
setLoading(false);
return;
}
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push("/");
} catch {
setError(t("auth.registerFailed"));
} catch (err) {
setError(
err instanceof ApiError && err.message
? err.message
: t("auth.registerFailed"),
);
} finally {
setLoading(false);
}
+23 -33
View File
@@ -17,6 +17,7 @@ import {
import { Loader2, CheckCircle2, Bug, GitPullRequest, Info } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
interface BugReportStatus {
configured: boolean;
@@ -49,10 +50,7 @@ export default function BugReportPage() {
const { data: status } = useQuery({
queryKey: queryKeys.bugreportStatus(),
queryFn: async () => {
const res = await fetch("/api/bugreport/status");
if (!res.ok) throw new Error("Failed to load status");
const json = await res.json();
return json.data as BugReportStatus;
return apiGet<BugReportStatus>("/api/bugreport/status");
},
enabled: isAuthenticated,
});
@@ -63,36 +61,28 @@ export default function BugReportPage() {
setLoading(true);
try {
const res = await fetch("/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
category,
subject: subject || description.slice(0, 60),
description,
metadata: {
locale,
userAgent:
typeof navigator !== "undefined" ? navigator.userAgent : null,
url:
typeof window !== "undefined" ? window.location.pathname : null,
},
}),
await apiPost("/api/feedback", {
category,
subject: subject || description.slice(0, 60),
description,
metadata: {
locale,
userAgent:
typeof navigator !== "undefined" ? navigator.userAgent : null,
url: typeof window !== "undefined" ? window.location.pathname : null,
},
});
setResult({ type: "success", message: t("bugreport.success") });
setSubject("");
setDescription("");
} catch (err) {
setResult({
type: "error",
message:
err instanceof ApiError
? err.message || t("bugreport.errorCreating")
: t("common.networkError"),
});
const json = await res.json();
if (res.ok) {
setResult({ type: "success", message: t("bugreport.success") });
setSubject("");
setDescription("");
} else {
setResult({
type: "error",
message: json.error || t("bugreport.errorCreating"),
});
}
} catch {
setResult({ type: "error", message: t("common.networkError") });
} finally {
setLoading(false);
}
+12 -15
View File
@@ -19,6 +19,7 @@ import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { TileHeader } from "@/components/insights/tile-header";
import { apiGet } from "@/lib/api/api-fetch";
/**
* v1.4.25 W4 — `/insights/medications`.
@@ -93,10 +94,7 @@ export default function InsightsMedikamentePage() {
const { data: comprehensive, isLoading: isComprehensiveLoading } = useQuery({
queryKey: queryKeys.insightsComprehensive(),
queryFn: async () => {
const res = await fetch("/api/insights/comprehensive");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as ComprehensiveMedicationData;
return apiGet<ComprehensiveMedicationData>("/api/insights/comprehensive");
},
enabled: isAuthenticated,
});
@@ -110,13 +108,7 @@ export default function InsightsMedikamentePage() {
MED_STATUS_TIMEOUT_MS,
);
try {
const res = await fetch(
`/api/insights/medication-compliance-status?locale=${locale}`,
{ signal: controller.signal },
);
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as MedicationComplianceStatusData;
return apiGet<MedicationComplianceStatusData>(`/api/insights/medication-compliance-status?locale=${locale}`, { signal: controller.signal });
} finally {
clearTimeout(timeoutHandle);
}
@@ -313,10 +305,15 @@ function MedicationComplianceCalendar({
const { data, isLoading } = useQuery({
queryKey: queryKeys.medicationComplianceChart(medicationId),
queryFn: async () => {
const res = await fetch(`/api/medications/${medicationId}/compliance`);
if (!res.ok) return null;
const json = await res.json();
return json.data as MedicationComplianceDailyResponse;
try {
return await apiGet<MedicationComplianceDailyResponse>(
`/api/medications/${medicationId}/compliance`,
);
} catch {
// The calendar simply doesn't render — same as the raw
// `if (!res.ok) return null` did.
return null;
}
},
enabled: !!medicationId,
staleTime: 60 * 1000,
+2 -4
View File
@@ -16,6 +16,7 @@ import { MetricTargetSummary } from "@/components/insights/metric-target-summary
import { MoodInsightsSections } from "@/components/insights/mood/mood-insights-sections";
import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { apiGet } from "@/lib/api/api-fetch";
/**
* v1.4.25 W4 — `/insights/mood`.
@@ -49,10 +50,7 @@ export default function InsightsStimmungPage() {
const { data: comprehensive } = useQuery({
queryKey: queryKeys.insightsComprehensive(),
queryFn: async () => {
const res = await fetch("/api/insights/comprehensive");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as ComprehensiveMoodData;
return apiGet<ComprehensiveMoodData>("/api/insights/comprehensive");
},
enabled: isAuthenticated,
});
+7 -5
View File
@@ -8,6 +8,7 @@ import { RefreshCw, SlidersHorizontal, TrendingUp } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
import {
@@ -202,7 +203,7 @@ const CycleRingTile = dynamic(
* cards moved onto the metric pages they belong to (Weight owns
* weight × weekday, Pulse owns mood × pulse, …), so the overview
* no longer renders a duplicate correlation row.
* - The CoachDrawer is mounted in the mother-page body only (Marc
* - The CoachDrawer is mounted in the mother-page body only (the maintainer
* directive). Navigating to a sub-page unmounts the drawer.
*
* The per-section status cards (BP/Weight/Pulse/etc.) and their
@@ -250,10 +251,11 @@ export default function InsightsPage() {
const { data, isLoading, isFetched, isError, refetch } = useQuery({
queryKey: queryKeys.insightsComprehensive(),
queryFn: async () => {
const res = await fetch("/api/insights/comprehensive");
if (!res.ok) throw new Error(t("insights.loadError"));
const json = await res.json();
return json.data as ComprehensiveData;
try {
return await apiGet<ComprehensiveData>("/api/insights/comprehensive");
} catch {
throw new Error(t("insights.loadError"));
}
},
enabled: isAuthenticated,
});
+9 -1
View File
@@ -8,6 +8,7 @@ import { MonitoringBootstrap } from "@/components/monitoring/bootstrap";
import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter";
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
import { locales, type Locale } from "@/lib/i18n/config";
import { allMessages } from "@/lib/i18n/shared-resolve";
async function resolveInitialLocale(): Promise<Locale> {
// Both cookies() and headers() can throw (DynamicServerError, etc.) —
@@ -106,6 +107,13 @@ export default async function RootLayout({
: undefined;
const initialLocale = await resolveInitialLocale();
// RSC handoff for the i18n bundle split: only EN ships statically in
// the client chunk, so a non-EN first paint needs its bundle inlined
// into the payload here — that is what keeps the split free of the
// EN→DE hydration flash. EN passes nothing (the client already holds
// the static fallback floor).
const initialMessages =
initialLocale === "en" ? undefined : allMessages[initialLocale];
return (
<html lang={initialLocale} suppressHydrationWarning>
@@ -117,7 +125,7 @@ export default async function RootLayout({
/>
</head>
<body className={`${inter.variable} font-sans antialiased`}>
<Providers initialLocale={initialLocale}>
<Providers initialLocale={initialLocale} initialMessages={initialMessages}>
<MonitoringBootstrap />
<WebVitalsReporter />
{/* `DEMO_MODE` is a server-only env var; the proxy uses it to
+2 -3
View File
@@ -28,6 +28,7 @@ import {
MedicationDetailTabs,
type MedicationDetailSnapshot,
} from "@/components/medications/detail/MedicationDetailTabs";
import { apiGet } from "@/lib/api/api-fetch";
export default function MedicationDetailPage({
params,
@@ -52,9 +53,7 @@ export default function MedicationDetailPage({
} = useQuery<MedicationDetailSnapshot>({
queryKey: queryKeys.medicationDetail(id),
queryFn: async () => {
const res = await fetch(`/api/medications/${id}`);
if (!res.ok) throw new Error("medication_detail_failed");
return (await res.json()).data as MedicationDetailSnapshot;
return apiGet<MedicationDetailSnapshot>(`/api/medications/${id}`);
},
// v1.15.20 — deliberately NOT gated on `isAuthenticated`: the session
// cookie rides the fetch either way, so waiting for `/api/auth/me`
+2 -4
View File
@@ -21,6 +21,7 @@ import {
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { CheckCircle2, Loader2, Pill, Plus } from "lucide-react";
import { apiGet } from "@/lib/api/api-fetch";
interface Schedule {
id: string;
@@ -149,10 +150,7 @@ export default function MedicationsPage() {
} = useQuery({
queryKey: queryKeys.medications(),
queryFn: async () => {
const res = await fetch("/api/medications");
if (!res.ok) throw new Error("Failed to fetch");
const json = await res.json();
return json.data as Medication[];
return apiGet<Medication[]>("/api/medications");
},
enabled: isAuthenticated,
});
+3 -11
View File
@@ -19,6 +19,7 @@ import {
type EventType,
} from "@/lib/notifications/types";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
interface ChannelInfo {
id: string;
@@ -91,10 +92,7 @@ export default function NotificationsPage() {
const { data, isLoading, isError } = useQuery({
queryKey: queryKeys.notificationsPreferences(),
queryFn: async () => {
const res = await fetch("/api/notifications/preferences");
if (!res.ok) throw new Error("Failed to load preferences");
const json = await res.json();
return json.data as PreferencesData;
return apiGet<PreferencesData>("/api/notifications/preferences");
},
enabled: isAuthenticated,
});
@@ -105,13 +103,7 @@ export default function NotificationsPage() {
eventType: string;
enabled: boolean;
}) => {
const res = await fetch("/api/notifications/preferences", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
if (!res.ok) throw new Error("Failed to update preference");
return res.json();
return apiPut("/api/notifications/preferences", params);
},
onMutate: async ({ channelId, eventType, enabled }) => {
await queryClient.cancelQueries({
+1 -1
View File
@@ -89,7 +89,7 @@ export default async function OnboardingStepPage({ params }: PageProps) {
// Welcome (step 0)
// * Fresh user → value-prop carousel.
// * Returning user (onboarding already completed) → welcome-back
// banner with "Open dashboard" CTA. Marc note 2026-05-14: the
// banner with "Open dashboard" CTA. The maintainer note 2026-05-14: the
// "restart onboarding" affordance lives in Settings → Account in
// v1.4.26; for now the banner is informational only.
if (requested === 0) {
+7 -12
View File
@@ -96,6 +96,7 @@ import {
getAgeFromDateOfBirth,
getPersonalizedPulseTarget,
} from "@/lib/analytics/pulse-targets";
import { apiGet } from "@/lib/api/api-fetch";
/**
* v1.7.0 — first-paint gate for the dashboard tile strip.
@@ -148,7 +149,7 @@ export default function DashboardPage() {
// (thick slice) blocked every per-type tile until the heavy fan-out
// resolved. Mood and medication tiles paint from their own dedicated
// endpoints and arrived first; the per-type measurement tiles then
// arrived as one burst once the full slice landed — Marc reported
// arrived as one burst once the full slice landed — the maintainer reported
// this "blocked-then-burst" pattern as "etwas nervig".
//
// Post-fix: two parallel queries. The slim slice (`?slice=summaries`)
@@ -205,7 +206,7 @@ export default function DashboardPage() {
// coverage. Pre-fix the inline `slim?.summaries ?? thick?.summaries`
// short-circuited on a truthy-but-empty `{}` from the slim slice
// and blanked the tile strip even when thick carried the full
// payload — the regression Marc's v1.4.39.3 e2e CI flagged across
// payload — the regression the maintainer's v1.4.39.3 e2e CI flagged across
// eight dashboard / chart specs. The helper falls back to thick
// when slim resolves with no content and otherwise keeps the
// v1.4.39.2 slim-wins-first progressive-paint contract.
@@ -237,10 +238,7 @@ export default function DashboardPage() {
const { data: layoutDataLegacy } = useQuery({
queryKey: queryKeys.dashboardWidgets(),
queryFn: async () => {
const res = await fetch("/api/dashboard/widgets");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as DashboardLayout;
return apiGet<DashboardLayout>("/api/dashboard/widgets");
},
enabled: !snapshotEnabled && isAuthenticated,
...DASHBOARD_QUERY_OPTS,
@@ -252,13 +250,10 @@ export default function DashboardPage() {
const { data: moodDataLegacy } = useQuery({
queryKey: queryKeys.moodAnalytics(),
queryFn: async () => {
const res = await fetch("/api/mood/analytics");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as {
return apiGet<{
entries: Array<{ date: string; score: number; samples: number }>;
summary: DataSummary;
};
}>("/api/mood/analytics");
},
enabled: !snapshotEnabled && isAuthenticated,
...DASHBOARD_QUERY_OPTS,
@@ -1362,7 +1357,7 @@ export default function DashboardPage() {
return (
<>
{/* v1.4: dashboard tiles are *always* a single row.
* maintainer-explicit (per feedback_dashboard_one_row.md): a 2-row
* Maintainer-explicit (per feedback_dashboard_one_row.md): a 2-row
* tile strip breaks the visual hierarchy and reads like an
* Excel grid. Total width caps at the parent container —
* exactly the chart-width below. When the active tile count
+5 -4
View File
@@ -245,10 +245,11 @@ describe("<PrivacyPage>", () => {
// email address attaches the actual contact.
expect(html).toContain("mailto:mbombeck@gmail.com");
expect(html).toContain("github.com/MBombeck/HealthLog/issues");
// Marc-Voice rule: no personal name in user-facing artefacts. The
// policy speaks in terms of "the operator" / "the controller"
// not Marc's full name. Only the bare email address attaches the
// contact route.
// Project-voice rule: no personal name in user-facing artefacts.
// The policy speaks in terms of "the operator" / "the controller",
// never a person's full name. Only the bare email address attaches
// the contact route. The regexes pin the operator's given-name /
// surname fragments so a regression cannot reintroduce them.
expect(html).not.toMatch(/Marc[- ]?Andr/i);
expect(html).not.toMatch(/\bBombeck\s+[A-Z]/);
});
@@ -58,7 +58,7 @@ describe("<AiQualitySection>", () => {
mockQueryState.isError = false;
const html = render(<AiQualitySection />);
// v1.4.33 IW7 — admin section renamed from "AI Quality" to
// "Insights Quality" per the Marc-Voice rule.
// "Insights Quality" per the project-voice rule.
expect(html).toContain("Insights Quality");
expect(html).toContain("No feedback yet");
});
@@ -35,8 +35,8 @@ vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "marc",
email: "marc@example.com",
username: "testuser",
email: "user@example.com",
role: "ADMIN",
},
isAuthenticated: true,
Loaded 100 of 284 files, more files were not shown because too many files have changed in this diff. Show more