mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge pull request #442 from MBombeck/release/v1.27.32
Release v1.27.32 — quality-of-life, accessibility, cold-start and performance polish
This commit is contained in:
87 files changed
+2449
-1071
No files matched your search
+17
-8
@@ -18,15 +18,24 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database
|
||||
# -----------------------------------------------------------------------------
|
||||
# When using docker-compose.yml: the app container reaches Postgres via the
|
||||
# service name "db", so the hostname is "db" — NOT "localhost".
|
||||
# When developing without Docker (`pnpm dev` against a host-installed Postgres):
|
||||
# change the hostname to "localhost" and adjust the credentials.
|
||||
DATABASE_URL="postgresql://healthlog:CHANGE-ME@db:5432/healthlog?schema=public"
|
||||
# Bundled docker-compose.yml path (the common case): you do NOT set DATABASE_URL
|
||||
# here. Compose CONSTRUCTS it inside the `app` service from POSTGRES_PASSWORD
|
||||
# (see docker-compose.yml — the `environment:` block wins over any value in this
|
||||
# file), so a DATABASE_URL line here is ignored for that stack. Set only
|
||||
# POSTGRES_PASSWORD below and compose wires the connection string for you.
|
||||
#
|
||||
# Bare-Node / custom-compose path (`pnpm dev` against a host Postgres, or your
|
||||
# own compose that does NOT derive DATABASE_URL): uncomment DATABASE_URL and set
|
||||
# it explicitly. Hostname is "db" on the bundled compose network, or
|
||||
# "localhost" for a host-installed Postgres.
|
||||
# DATABASE_URL="postgresql://healthlog:YOUR_PASSWORD@localhost:5432/healthlog?schema=public"
|
||||
|
||||
# Postgres password — used both by the db service AND by DATABASE_URL above.
|
||||
# Pick a strong random password, e.g. `openssl rand -hex 32`.
|
||||
POSTGRES_PASSWORD="CHANGE-ME"
|
||||
# Postgres password for the bundled `db` service — and the value compose splices
|
||||
# into DATABASE_URL. The quick-start appends a generated one:
|
||||
# echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)" >> .env
|
||||
# Left commented here so that append does not create a duplicate key. Uncomment
|
||||
# and set it directly instead if you prefer to edit the file by hand.
|
||||
# POSTGRES_PASSWORD="generate-with-openssl-rand-hex-32"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -2,6 +2,23 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.27.32] — 2026-07-09 — Quality-of-life, accessibility, cold-start and performance polish
|
||||
|
||||
### Changed
|
||||
|
||||
- **No more dead-ends:** the dashboard metric tiles (weight, blood pressure, pulse, glucose, mood, sleep, steps, VO₂max) now link to their Insights detail; the daily-briefing finding rows are tappable again; the "no notification channels" prompt points to where channels actually live now; and Coach Plans are reachable from the conversations header.
|
||||
- **Honest states:** the Achievements, Coach-conversations, Notifications, custom-metrics and Insights surfaces render a retryable error card on a failed load instead of a confident empty state, and auth-gated pages show a layout-reserving skeleton instead of a bare spinner.
|
||||
- **Medication dialogs** move to the bottom-sheet pattern on phones (consistent with the rest of the app), and secret/config inputs suppress password-manager autofill.
|
||||
|
||||
### Accessibility
|
||||
|
||||
- Fixed several contrast shortfalls (muted text/icons below AA, phantom colour utilities that emitted no rule), added an accessible name to the admin toggle, and `aria-pressed` to the chart range switches.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Documentation now matches the actual compose contract for `DATABASE_URL`; a `SESSION_COOKIE_SECURE` transport mismatch is diagnosed (was a silent login loop); a boot readiness summary prints per-secret status before fail-closed loaders throw; and a nudge appears while public registration is open.
|
||||
- CSV import batches its writes; the off-host backup pages its large reads; the theme provider closes a hydration seam; and hot per-row Intl formatters are cached.
|
||||
|
||||
## [1.27.31] — 2026-07-09 — Document AI provider governance
|
||||
|
||||
### Security
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.27.31
|
||||
version: 1.27.32
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
@@ -53,11 +53,14 @@ echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
|
||||
| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
|
||||
| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens before storage | Invalidates every issued `hlk_*` token |
|
||||
|
||||
Then open `.env` and confirm `DATABASE_URL` includes the password you
|
||||
just generated. The default template (`postgresql://healthlog:CHANGE-ME@db:5432/healthlog`)
|
||||
references the literal string `CHANGE-ME`; replace it with the value
|
||||
you put in `POSTGRES_PASSWORD`, or rewrite the URL to substitute
|
||||
explicitly.
|
||||
You do **not** edit `DATABASE_URL` on the bundled `docker-compose.yml`
|
||||
stack: compose constructs the connection string inside the `app`
|
||||
service from `${POSTGRES_PASSWORD}` (its `environment:` block overrides
|
||||
any `DATABASE_URL` in `.env`), so the `openssl` line above is the only
|
||||
password step. `DATABASE_URL` only matters if you run a **bare Node**
|
||||
process (`pnpm dev` against a host Postgres) or a **custom compose** that
|
||||
does not derive it — in that case uncomment and set `DATABASE_URL`
|
||||
explicitly in `.env` (see the commented example there).
|
||||
|
||||
## 3. Bring the stack up
|
||||
|
||||
@@ -92,6 +95,22 @@ After the first user lands, you can:
|
||||
- Open `/measurements` and add a weight or blood pressure to confirm
|
||||
the data path is wired end-to-end.
|
||||
|
||||
> **Close public registration if you expose the instance to the
|
||||
> internet.** Registration stays **open** after the first admin so you
|
||||
> can invite the rest of your household — but on a publicly reachable
|
||||
> host that means strangers can still sign up. Once everyone who needs
|
||||
> an account has one, turn it off in **`/admin` → registration toggle**
|
||||
> (or keep it open and hand out invite links instead). The admin
|
||||
> settings show a reminder underneath the toggle while registration is
|
||||
> open.
|
||||
|
||||
> **No email is configured out of the box** — a deliberate first-boot
|
||||
> simplicity win, but it also means password reset has no self-service
|
||||
> path until you set SMTP (see `docs/self-hosting/notifications.md`). If
|
||||
> you are the sole admin, **save your password in a manager now**: a
|
||||
> locked-out solo admin with no SMTP recovers only via the CLI script in
|
||||
> `docs/ops/password-reset.md`.
|
||||
|
||||
## 5. Point the instance at a public hostname
|
||||
|
||||
Local-only is fine for a single-user trial. For anything more — and
|
||||
|
||||
+17
-8
@@ -924,7 +924,6 @@
|
||||
"recentTitle": "Letzte 30 Tage",
|
||||
"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",
|
||||
@@ -957,7 +956,9 @@
|
||||
"gastroparesisLike": "Verlangsamte Magenentleerung",
|
||||
"dysgeusia": "Geschmacksveränderung",
|
||||
"gallbladderDiscomfort": "Gallenblasen-Beschwerden"
|
||||
}
|
||||
},
|
||||
"deleteConfirmTitle": "Eintrag löschen?",
|
||||
"deleteConfirmBody": "Dieser Nebenwirkungs-Eintrag wird dauerhaft entfernt."
|
||||
},
|
||||
"scheduling": {
|
||||
"naturalLanguage": {
|
||||
@@ -2082,7 +2083,7 @@
|
||||
"emptyAction": "Briefing erstellen",
|
||||
"loadingLabel": "Briefing wird zusammengestellt",
|
||||
"noProviderTitle": "Kein KI-Anbieter verbunden",
|
||||
"noProviderDescription": "Das Tagesbriefing fasst deine Werte in Klartext zusammen. Dafür braucht HealthLog einen KI-Anbieter — nutze deinen eigenen Schlüssel (OpenAI, Anthropic) oder ein lokales Modell. Deine Daten bleiben dabei bei dir.",
|
||||
"noProviderDescription": "Richte HealthLog auf ein lokales Modell (Ollama, LM Studio – nichts verlässt dein Netzwerk) oder hinterlege deinen eigenen OpenAI- oder Anthropic-Schlüssel, und das tägliche Briefing fasst deine eigenen Messwerte in klarer Sprache zusammen – zum Beispiel: „Gewicht in zwei Wochen um 1,2 kg gesunken, Ruhepuls stabil bei 58, Blutdruck an 6 von 7 Tagen im Zielbereich.“ Die Einrichtung dauert etwa eine Minute.",
|
||||
"noProviderAction": "Zu den KI-Einstellungen",
|
||||
"staleNoProviderHint": "Dieses Briefing kann nicht aktualisiert werden — es ist kein KI-Anbieter verbunden.",
|
||||
"refreshFailedHint": "Briefing konnte nicht aktualisiert werden — es wird das letzte angezeigt.",
|
||||
@@ -4057,14 +4058,17 @@
|
||||
"connectionsHeading": "Verbindungen",
|
||||
"connectionsDescription": "Datenquellen und Geräte verbinden und verwalten.",
|
||||
"channelsHeading": "Zustellkanäle",
|
||||
"channelsDescription": "Wohin Erinnerungen und Hinweise zugestellt werden."
|
||||
"channelsDescription": "Wohin Erinnerungen und Hinweise zugestellt werden.",
|
||||
"matrixLink": "Benachrichtigungen je Kanal"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
"description": "Alle Erinnerungen an einem Ort — was wann erinnert wird und wohin es zugestellt wird.",
|
||||
"subtitle": "Wähle, welche Erinnerungen du erhältst und wohin sie zugestellt werden.",
|
||||
"remindersHeading": "Erinnerungen",
|
||||
"remindersDescription": "Was du erhältst."
|
||||
"remindersDescription": "Was du erhältst.",
|
||||
"matrixLink": "Benachrichtigungen je Kanal",
|
||||
"channelsLink": "Zustellkanäle"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Darstellung",
|
||||
@@ -5496,6 +5500,7 @@
|
||||
"appSettingsDescription": "Globale Standardwerte für Registrierung, Sprache und Zeitzone neuer Konten.",
|
||||
"registrationEnabled": "Registrierung erlauben",
|
||||
"registrationEnabledDescription": "Wenn deaktiviert, können sich keine neuen Nutzer registrieren.",
|
||||
"registrationOpenHint": "Die Registrierung ist offen — jeder, der diese Instanz erreichen kann, kann ein Konto anlegen. Schließe sie hier, sobald alle ein Konto haben.",
|
||||
"defaultLanguage": "Standard-Sprache",
|
||||
"defaultLanguageDescription": "Wird für neue Nutzer und als Fallback verwendet.",
|
||||
"defaultUserTimezone": "Standard-Zeitzone für neue Konten",
|
||||
@@ -6270,7 +6275,7 @@
|
||||
"eventColumn": "Ereignis",
|
||||
"subtitle": "Wähle aus, welche Benachrichtigungen du über welchen Kanal erhalten möchtest.",
|
||||
"loadError": "Benachrichtigungseinstellungen konnten nicht geladen werden.",
|
||||
"noChannels": "Konfiguriere zuerst einen Benachrichtigungskanal in den Einstellungen.",
|
||||
"noChannels": "Konfiguriere zuerst einen Benachrichtigungskanal unter Einstellungen → Integrationen.",
|
||||
"goToSettings": "Zu den Kanal-Einstellungen",
|
||||
"channelDisabled": "Deaktiviert",
|
||||
"admin": {
|
||||
@@ -6381,7 +6386,10 @@
|
||||
"newDeviceBody": "Eine neue Anmeldung von {device} ({location}). Falls du das nicht warst, ändere sofort dein Passwort.",
|
||||
"unknownLocation": "unbekannter Ort"
|
||||
},
|
||||
"loginRequired": "Bitte melde dich an, um Benachrichtigungen zu verwalten."
|
||||
"loginRequired": "Bitte melde dich an, um Benachrichtigungen zu verwalten.",
|
||||
"someChannelsGloballyDisabled": "Ein oder mehrere Kanäle sind in den Einstellungen deaktiviert und erhalten keine Benachrichtigungen.",
|
||||
"reminderSettingsLink": "Erinnerungseinstellungen",
|
||||
"channelsLink": "Kanäle"
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
@@ -7377,7 +7385,8 @@
|
||||
"pdfNeedsAnthropic": "PDF-Scannen benötigt einen Claude-Bildanbieter; lade stattdessen ein Foto hoch."
|
||||
},
|
||||
"loadMoreReadings": "Weitere Werte laden",
|
||||
"documentsLink": "Befund-Dokumente"
|
||||
"documentsLink": "Befund-Dokumente",
|
||||
"notLinkedYet": "Noch keinem Biomarker zugeordnet"
|
||||
},
|
||||
"modules": {
|
||||
"cycle": {
|
||||
|
||||
+17
-8
@@ -924,7 +924,6 @@
|
||||
"recentTitle": "Last 30 days",
|
||||
"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",
|
||||
@@ -957,7 +956,9 @@
|
||||
"gastroparesisLike": "Slow stomach emptying",
|
||||
"dysgeusia": "Taste change",
|
||||
"gallbladderDiscomfort": "Gallbladder discomfort"
|
||||
}
|
||||
},
|
||||
"deleteConfirmTitle": "Delete this entry?",
|
||||
"deleteConfirmBody": "This side-effect entry will be permanently removed."
|
||||
},
|
||||
"scheduling": {
|
||||
"naturalLanguage": {
|
||||
@@ -2082,7 +2083,7 @@
|
||||
"emptyAction": "Generate briefing",
|
||||
"loadingLabel": "Synthesising today's briefing",
|
||||
"noProviderTitle": "No AI provider connected",
|
||||
"noProviderDescription": "The daily briefing turns your readings into plain-language summary. HealthLog needs an AI provider for that — use your own key (OpenAI, Anthropic) or a local model. Your data stays with you.",
|
||||
"noProviderDescription": "Point HealthLog at a local model (Ollama, LM Studio — nothing leaves your network) or bring your own OpenAI or Anthropic key, and the daily briefing turns your own readings into a plain-language summary — for example: “weight down 1.2 kg over two weeks, resting pulse steady at 58, blood pressure in range on 6 of 7 days.” Setup takes about a minute.",
|
||||
"noProviderAction": "Open AI settings",
|
||||
"staleNoProviderHint": "This briefing can't refresh — no AI provider is connected.",
|
||||
"refreshFailedHint": "Couldn't refresh the briefing — showing the last one.",
|
||||
@@ -4057,14 +4058,17 @@
|
||||
"connectionsHeading": "Connections",
|
||||
"connectionsDescription": "Connect and manage your data sources and devices.",
|
||||
"channelsHeading": "Delivery channels",
|
||||
"channelsDescription": "Where reminders and alerts are delivered."
|
||||
"channelsDescription": "Where reminders and alerts are delivered.",
|
||||
"matrixLink": "Per-channel notifications"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"description": "Every reminder in one place — what gets reminded and when, and where it's delivered.",
|
||||
"subtitle": "Choose which reminders you receive and where they're delivered.",
|
||||
"remindersHeading": "Reminders",
|
||||
"remindersDescription": "What you receive."
|
||||
"remindersDescription": "What you receive.",
|
||||
"matrixLink": "Per-channel notifications",
|
||||
"channelsLink": "Delivery channels"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Appearance",
|
||||
@@ -5496,6 +5500,7 @@
|
||||
"appSettingsDescription": "Global defaults for registration, language, and the timezone of new accounts.",
|
||||
"registrationEnabled": "Allow registration",
|
||||
"registrationEnabledDescription": "When disabled, new users cannot register.",
|
||||
"registrationOpenHint": "Registration is open — anyone who can reach this instance can create an account. Close it here once everyone who needs an account has one.",
|
||||
"defaultLanguage": "Default language",
|
||||
"defaultLanguageDescription": "Used for new users and as fallback.",
|
||||
"defaultUserTimezone": "Default timezone for new accounts",
|
||||
@@ -6270,7 +6275,7 @@
|
||||
"eventColumn": "Event",
|
||||
"subtitle": "Choose which notifications you want to receive through which channel.",
|
||||
"loadError": "Notification preferences could not be loaded.",
|
||||
"noChannels": "Configure a notification channel in Settings first.",
|
||||
"noChannels": "Configure a notification channel in Settings → Integrations first.",
|
||||
"goToSettings": "Go to channel settings",
|
||||
"channelDisabled": "Disabled",
|
||||
"admin": {
|
||||
@@ -6381,7 +6386,10 @@
|
||||
"newDeviceBody": "A new sign-in from {device} ({location}). If this wasn't you, change your password immediately.",
|
||||
"unknownLocation": "unknown location"
|
||||
},
|
||||
"loginRequired": "Please sign in to manage notifications."
|
||||
"loginRequired": "Please sign in to manage notifications.",
|
||||
"someChannelsGloballyDisabled": "One or more channels are turned off in Settings and won’t receive notifications.",
|
||||
"reminderSettingsLink": "Reminder settings",
|
||||
"channelsLink": "Channels"
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
@@ -7377,7 +7385,8 @@
|
||||
"pdfNeedsAnthropic": "PDF scanning needs a Claude vision provider; upload a photo instead."
|
||||
},
|
||||
"loadMoreReadings": "Load more readings",
|
||||
"documentsLink": "Lab report documents"
|
||||
"documentsLink": "Lab report documents",
|
||||
"notLinkedYet": "Not linked to a biomarker yet"
|
||||
},
|
||||
"modules": {
|
||||
"cycle": {
|
||||
|
||||
+17
-8
@@ -924,7 +924,6 @@
|
||||
"recentTitle": "Últimos 30 días",
|
||||
"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",
|
||||
@@ -957,7 +956,9 @@
|
||||
"gastroparesisLike": "Vaciado gástrico lento",
|
||||
"dysgeusia": "Alteración del gusto",
|
||||
"gallbladderDiscomfort": "Molestia en la vesícula biliar"
|
||||
}
|
||||
},
|
||||
"deleteConfirmTitle": "¿Eliminar esta entrada?",
|
||||
"deleteConfirmBody": "Esta entrada de efecto secundario se eliminará de forma permanente."
|
||||
},
|
||||
"scheduling": {
|
||||
"naturalLanguage": {
|
||||
@@ -2082,7 +2083,7 @@
|
||||
"emptyAction": "Generar resumen",
|
||||
"loadingLabel": "Preparando el resumen",
|
||||
"noProviderTitle": "Ningún proveedor de IA conectado",
|
||||
"noProviderDescription": "El briefing diario resume tus lecturas en lenguaje claro. HealthLog necesita un proveedor de IA para ello: usa tu propia clave (OpenAI, Anthropic) o un modelo local. Tus datos siguen siendo tuyos.",
|
||||
"noProviderDescription": "Apunta HealthLog a un modelo local (Ollama, LM Studio: nada sale de tu red) o usa tu propia clave de OpenAI o Anthropic, y el resumen diario convierte tus propias mediciones en un resumen en lenguaje claro; por ejemplo: «peso 1,2 kg menos en dos semanas, pulso en reposo estable en 58, tensión en rango 6 de 7 días». La configuración lleva alrededor de un minuto.",
|
||||
"noProviderAction": "Abrir ajustes de IA",
|
||||
"staleNoProviderHint": "Este briefing no puede actualizarse: no hay ningún proveedor de IA conectado.",
|
||||
"refreshFailedHint": "No se pudo actualizar el briefing: se muestra el anterior.",
|
||||
@@ -4057,14 +4058,17 @@
|
||||
"connectionsHeading": "Conexiones",
|
||||
"connectionsDescription": "Conecta y gestiona tus fuentes de datos y dispositivos.",
|
||||
"channelsHeading": "Canales de entrega",
|
||||
"channelsDescription": "Dónde se entregan los recordatorios y avisos."
|
||||
"channelsDescription": "Dónde se entregan los recordatorios y avisos.",
|
||||
"matrixLink": "Notificaciones por canal"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notificaciones",
|
||||
"description": "Todos los recordatorios en un solo lugar: qué se recuerda y cuándo, y dónde se entrega.",
|
||||
"subtitle": "Elige qué recordatorios recibes y dónde se entregan.",
|
||||
"remindersHeading": "Recordatorios",
|
||||
"remindersDescription": "Lo que recibes."
|
||||
"remindersDescription": "Lo que recibes.",
|
||||
"matrixLink": "Notificaciones por canal",
|
||||
"channelsLink": "Canales de entrega"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Apariencia",
|
||||
@@ -5496,6 +5500,7 @@
|
||||
"appSettingsDescription": "Valores globales por defecto para registro, idioma y zona horaria de cuentas nuevas.",
|
||||
"registrationEnabled": "Permitir el registro",
|
||||
"registrationEnabledDescription": "Cuando está desactivado, los nuevos usuarios no pueden registrarse.",
|
||||
"registrationOpenHint": "El registro está abierto: cualquiera que pueda acceder a esta instancia puede crear una cuenta. Ciérralo aquí cuando todos tengan una cuenta.",
|
||||
"defaultLanguage": "Idioma predeterminado",
|
||||
"defaultLanguageDescription": "Se usa para los nuevos usuarios y como reserva.",
|
||||
"defaultUserTimezone": "Zona horaria predeterminada para cuentas nuevas",
|
||||
@@ -6270,7 +6275,7 @@
|
||||
"eventColumn": "Evento",
|
||||
"subtitle": "Elige qué notificaciones recibes y por qué canal.",
|
||||
"loadError": "No se han podido cargar los ajustes de notificación.",
|
||||
"noChannels": "Configura primero un canal de notificación en los ajustes.",
|
||||
"noChannels": "Configura primero un canal de notificación en Ajustes → Integraciones.",
|
||||
"goToSettings": "Ir a los ajustes del canal",
|
||||
"channelDisabled": "Desactivado",
|
||||
"admin": {
|
||||
@@ -6381,7 +6386,10 @@
|
||||
"newDeviceBody": "Un nuevo inicio de sesión desde {device} ({location}). Si no fuiste tú, cambia tu contraseña de inmediato.",
|
||||
"unknownLocation": "ubicación desconocida"
|
||||
},
|
||||
"loginRequired": "Inicia sesión para gestionar las notificaciones."
|
||||
"loginRequired": "Inicia sesión para gestionar las notificaciones.",
|
||||
"someChannelsGloballyDisabled": "Uno o más canales están desactivados en los ajustes y no recibirán notificaciones.",
|
||||
"reminderSettingsLink": "Ajustes de recordatorios",
|
||||
"channelsLink": "Canales"
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
@@ -7377,7 +7385,8 @@
|
||||
"pdfNeedsAnthropic": "El escaneo de PDF necesita un proveedor de visión de Claude; sube una foto en su lugar."
|
||||
},
|
||||
"loadMoreReadings": "Cargar más mediciones",
|
||||
"documentsLink": "Documentos de resultados"
|
||||
"documentsLink": "Documentos de resultados",
|
||||
"notLinkedYet": "Aún no vinculado a un biomarcador"
|
||||
},
|
||||
"modules": {
|
||||
"cycle": {
|
||||
|
||||
+17
-8
@@ -924,7 +924,6 @@
|
||||
"recentTitle": "30 derniers jours",
|
||||
"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",
|
||||
@@ -957,7 +956,9 @@
|
||||
"gastroparesisLike": "Vidange gastrique lente",
|
||||
"dysgeusia": "Altération du goût",
|
||||
"gallbladderDiscomfort": "Inconfort de la vésicule biliaire"
|
||||
}
|
||||
},
|
||||
"deleteConfirmTitle": "Supprimer cette entrée ?",
|
||||
"deleteConfirmBody": "Cette entrée d’effet secondaire sera définitivement supprimée."
|
||||
},
|
||||
"scheduling": {
|
||||
"naturalLanguage": {
|
||||
@@ -2082,7 +2083,7 @@
|
||||
"emptyAction": "Générer le briefing",
|
||||
"loadingLabel": "Préparation du briefing",
|
||||
"noProviderTitle": "Aucun fournisseur d'IA connecté",
|
||||
"noProviderDescription": "Le briefing quotidien résume tes mesures en langage clair. HealthLog a besoin d'un fournisseur d'IA pour cela : utilise ta propre clé (OpenAI, Anthropic) ou un modèle local. Tes données restent chez toi.",
|
||||
"noProviderDescription": "Faites pointer HealthLog vers un modèle local (Ollama, LM Studio — rien ne quitte votre réseau) ou utilisez votre propre clé OpenAI ou Anthropic, et le résumé quotidien transforme vos propres relevés en un résumé en langage clair — par exemple : « poids en baisse de 1,2 kg en deux semaines, pouls au repos stable à 58, tension dans la cible 6 jours sur 7 ». La configuration prend environ une minute.",
|
||||
"noProviderAction": "Ouvrir les réglages IA",
|
||||
"staleNoProviderHint": "Ce briefing ne peut pas être actualisé — aucun fournisseur d'IA n'est connecté.",
|
||||
"refreshFailedHint": "Impossible d'actualiser le briefing — affichage du précédent.",
|
||||
@@ -4057,14 +4058,17 @@
|
||||
"connectionsHeading": "Connexions",
|
||||
"connectionsDescription": "Connectez et gérez vos sources de données et appareils.",
|
||||
"channelsHeading": "Canaux de diffusion",
|
||||
"channelsDescription": "Où sont envoyés les rappels et alertes."
|
||||
"channelsDescription": "Où sont envoyés les rappels et alertes.",
|
||||
"matrixLink": "Notifications par canal"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"description": "Tous les rappels au même endroit — quoi et quand, et où c'est envoyé.",
|
||||
"subtitle": "Choisissez les rappels que vous recevez et où ils sont envoyés.",
|
||||
"remindersHeading": "Rappels",
|
||||
"remindersDescription": "Ce que vous recevez."
|
||||
"remindersDescription": "Ce que vous recevez.",
|
||||
"matrixLink": "Notifications par canal",
|
||||
"channelsLink": "Canaux de diffusion"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Apparence",
|
||||
@@ -5496,6 +5500,7 @@
|
||||
"appSettingsDescription": "Valeurs globales par défaut pour l’inscription, la langue et le fuseau horaire des nouveaux comptes.",
|
||||
"registrationEnabled": "Autoriser l'inscription",
|
||||
"registrationEnabledDescription": "Lorsque cette option est désactivée, les nouveaux utilisateurs ne peuvent pas s'inscrire.",
|
||||
"registrationOpenHint": "L'inscription est ouverte : toute personne pouvant accéder à cette instance peut créer un compte. Fermez-la ici une fois que tout le monde a un compte.",
|
||||
"defaultLanguage": "Langue par défaut",
|
||||
"defaultLanguageDescription": "Utilisé pour les nouveaux utilisateurs et comme solution de repli.",
|
||||
"defaultUserTimezone": "Fuseau horaire par défaut pour les nouveaux comptes",
|
||||
@@ -6270,7 +6275,7 @@
|
||||
"eventColumn": "Événement",
|
||||
"subtitle": "Choisis quelles notifications tu reçois et par quel canal.",
|
||||
"loadError": "Impossible de charger les paramètres de notification.",
|
||||
"noChannels": "Configure d’abord un canal de notification dans les paramètres.",
|
||||
"noChannels": "Configure d’abord un canal de notification dans Paramètres → Intégrations.",
|
||||
"goToSettings": "Aller aux paramètres du canal",
|
||||
"channelDisabled": "Désactivé",
|
||||
"admin": {
|
||||
@@ -6381,7 +6386,10 @@
|
||||
"newDeviceBody": "Une nouvelle connexion depuis {device} ({location}). Si ce n'était pas vous, changez votre mot de passe immédiatement.",
|
||||
"unknownLocation": "lieu inconnu"
|
||||
},
|
||||
"loginRequired": "Connecte-toi pour gérer les notifications."
|
||||
"loginRequired": "Connecte-toi pour gérer les notifications.",
|
||||
"someChannelsGloballyDisabled": "Un ou plusieurs canaux sont désactivés dans les paramètres et ne recevront pas de notifications.",
|
||||
"reminderSettingsLink": "Réglages des rappels",
|
||||
"channelsLink": "Canaux"
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
@@ -7377,7 +7385,8 @@
|
||||
"pdfNeedsAnthropic": "Le scan PDF nécessite un fournisseur de vision Claude ; importez plutôt une photo."
|
||||
},
|
||||
"loadMoreReadings": "Charger plus de mesures",
|
||||
"documentsLink": "Documents de résultats"
|
||||
"documentsLink": "Documents de résultats",
|
||||
"notLinkedYet": "Pas encore lié à un biomarqueur"
|
||||
},
|
||||
"modules": {
|
||||
"cycle": {
|
||||
|
||||
+17
-8
@@ -924,7 +924,6 @@
|
||||
"recentTitle": "Ultimi 30 giorni",
|
||||
"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",
|
||||
@@ -957,7 +956,9 @@
|
||||
"gastroparesisLike": "Svuotamento gastrico lento",
|
||||
"dysgeusia": "Alterazione del gusto",
|
||||
"gallbladderDiscomfort": "Fastidio alla cistifellea"
|
||||
}
|
||||
},
|
||||
"deleteConfirmTitle": "Eliminare questa voce?",
|
||||
"deleteConfirmBody": "Questa voce di effetto collaterale verrà rimossa definitivamente."
|
||||
},
|
||||
"scheduling": {
|
||||
"naturalLanguage": {
|
||||
@@ -2082,7 +2083,7 @@
|
||||
"emptyAction": "Genera briefing",
|
||||
"loadingLabel": "Preparazione del briefing",
|
||||
"noProviderTitle": "Nessun provider IA collegato",
|
||||
"noProviderDescription": "Il briefing giornaliero riassume le tue misurazioni in linguaggio chiaro. HealthLog ha bisogno di un provider IA per questo: usa la tua chiave (OpenAI, Anthropic) o un modello locale. I tuoi dati restano tuoi.",
|
||||
"noProviderDescription": "Punta HealthLog verso un modello locale (Ollama, LM Studio: nulla lascia la tua rete) o usa la tua chiave OpenAI o Anthropic, e il riepilogo quotidiano trasforma le tue misurazioni in un riassunto in linguaggio chiaro, ad esempio: «peso in calo di 1,2 kg in due settimane, battito a riposo stabile a 58, pressione nel range 6 giorni su 7». La configurazione richiede circa un minuto.",
|
||||
"noProviderAction": "Apri impostazioni IA",
|
||||
"staleNoProviderHint": "Questo briefing non può aggiornarsi: nessun provider IA è collegato.",
|
||||
"refreshFailedHint": "Impossibile aggiornare il briefing: viene mostrato l'ultimo.",
|
||||
@@ -4057,14 +4058,17 @@
|
||||
"connectionsHeading": "Connessioni",
|
||||
"connectionsDescription": "Collega e gestisci le tue fonti di dati e i dispositivi.",
|
||||
"channelsHeading": "Canali di recapito",
|
||||
"channelsDescription": "Dove vengono recapitati promemoria e avvisi."
|
||||
"channelsDescription": "Dove vengono recapitati promemoria e avvisi.",
|
||||
"matrixLink": "Notifiche per canale"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifiche",
|
||||
"description": "Tutti i promemoria in un unico posto — cosa e quando, e dove vengono recapitati.",
|
||||
"subtitle": "Scegli quali promemoria ricevere e dove vengono recapitati.",
|
||||
"remindersHeading": "Promemoria",
|
||||
"remindersDescription": "Ciò che ricevi."
|
||||
"remindersDescription": "Ciò che ricevi.",
|
||||
"matrixLink": "Notifiche per canale",
|
||||
"channelsLink": "Canali di recapito"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Aspetto",
|
||||
@@ -5496,6 +5500,7 @@
|
||||
"appSettingsDescription": "Impostazioni globali predefinite per registrazione, lingua e fuso orario dei nuovi account.",
|
||||
"registrationEnabled": "Consentire la registrazione",
|
||||
"registrationEnabledDescription": "Se disattivato, i nuovi utenti non possono registrarsi.",
|
||||
"registrationOpenHint": "La registrazione è aperta: chiunque possa raggiungere questa istanza può creare un account. Chiudila qui quando tutti hanno un account.",
|
||||
"defaultLanguage": "Lingua predefinita",
|
||||
"defaultLanguageDescription": "Usata per i nuovi utenti e come ripiego.",
|
||||
"defaultUserTimezone": "Fuso orario predefinito per i nuovi account",
|
||||
@@ -6270,7 +6275,7 @@
|
||||
"eventColumn": "Evento",
|
||||
"subtitle": "Scegli quali notifiche ricevere e su quale canale.",
|
||||
"loadError": "Impossibile caricare le impostazioni di notifica.",
|
||||
"noChannels": "Configura prima un canale di notifica nelle impostazioni.",
|
||||
"noChannels": "Configura prima un canale di notifica in Impostazioni → Integrazioni.",
|
||||
"goToSettings": "Vai alle impostazioni del canale",
|
||||
"channelDisabled": "Disabilitato",
|
||||
"admin": {
|
||||
@@ -6381,7 +6386,10 @@
|
||||
"newDeviceBody": "Un nuovo accesso da {device} ({location}). Se non sei stato tu, cambia subito la password.",
|
||||
"unknownLocation": "posizione sconosciuta"
|
||||
},
|
||||
"loginRequired": "Accedi per gestire le notifiche."
|
||||
"loginRequired": "Accedi per gestire le notifiche.",
|
||||
"someChannelsGloballyDisabled": "Uno o più canali sono disattivati nelle impostazioni e non riceveranno notifiche.",
|
||||
"reminderSettingsLink": "Impostazioni promemoria",
|
||||
"channelsLink": "Canali"
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
@@ -7377,7 +7385,8 @@
|
||||
"pdfNeedsAnthropic": "La scansione PDF richiede un provider di visione Claude; carica invece una foto."
|
||||
},
|
||||
"loadMoreReadings": "Carica altre misurazioni",
|
||||
"documentsLink": "Documenti dei referti"
|
||||
"documentsLink": "Documenti dei referti",
|
||||
"notLinkedYet": "Non ancora collegato a un biomarcatore"
|
||||
},
|
||||
"modules": {
|
||||
"cycle": {
|
||||
|
||||
+17
-8
@@ -924,7 +924,6 @@
|
||||
"recentTitle": "Ostatnie 30 dni",
|
||||
"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",
|
||||
@@ -957,7 +956,9 @@
|
||||
"gastroparesisLike": "Spowolnione opróżnianie żołądka",
|
||||
"dysgeusia": "Zmiana smaku",
|
||||
"gallbladderDiscomfort": "Dolegliwości pęcherzyka żółciowego"
|
||||
}
|
||||
},
|
||||
"deleteConfirmTitle": "Usunąć ten wpis?",
|
||||
"deleteConfirmBody": "Ten wpis o skutku ubocznym zostanie trwale usunięty."
|
||||
},
|
||||
"scheduling": {
|
||||
"naturalLanguage": {
|
||||
@@ -2082,7 +2083,7 @@
|
||||
"emptyAction": "Wygeneruj briefing",
|
||||
"loadingLabel": "Przygotowanie briefingu",
|
||||
"noProviderTitle": "Brak połączonego dostawcy AI",
|
||||
"noProviderDescription": "Codzienny briefing podsumowuje Twoje pomiary prostym językiem. HealthLog potrzebuje do tego dostawcy AI — użyj własnego klucza (OpenAI, Anthropic) lub modelu lokalnego. Twoje dane pozostają u Ciebie.",
|
||||
"noProviderDescription": "Skieruj HealthLog na model lokalny (Ollama, LM Studio — nic nie opuszcza Twojej sieci) lub podaj własny klucz OpenAI albo Anthropic, a codzienne podsumowanie zamieni Twoje pomiary w zrozumiały opis — na przykład: „waga niższa o 1,2 kg w dwa tygodnie, tętno spoczynkowe stabilne na 58, ciśnienie w normie przez 6 z 7 dni”. Konfiguracja zajmuje około minuty.",
|
||||
"noProviderAction": "Otwórz ustawienia AI",
|
||||
"staleNoProviderHint": "Tego briefingu nie można odświeżyć — nie połączono żadnego dostawcy AI.",
|
||||
"refreshFailedHint": "Nie udało się odświeżyć briefingu — pokazujemy poprzedni.",
|
||||
@@ -4057,14 +4058,17 @@
|
||||
"connectionsHeading": "Połączenia",
|
||||
"connectionsDescription": "Połącz i zarządzaj źródłami danych oraz urządzeniami.",
|
||||
"channelsHeading": "Kanały dostarczania",
|
||||
"channelsDescription": "Gdzie dostarczane są przypomnienia i powiadomienia."
|
||||
"channelsDescription": "Gdzie dostarczane są przypomnienia i powiadomienia.",
|
||||
"matrixLink": "Powiadomienia wg kanału"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Powiadomienia",
|
||||
"description": "Wszystkie przypomnienia w jednym miejscu — co i kiedy oraz gdzie są dostarczane.",
|
||||
"subtitle": "Wybierz, które przypomnienia otrzymujesz i gdzie są dostarczane.",
|
||||
"remindersHeading": "Przypomnienia",
|
||||
"remindersDescription": "Co otrzymujesz."
|
||||
"remindersDescription": "Co otrzymujesz.",
|
||||
"matrixLink": "Powiadomienia wg kanału",
|
||||
"channelsLink": "Kanały dostarczania"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Wygląd",
|
||||
@@ -5496,6 +5500,7 @@
|
||||
"appSettingsDescription": "Globalne ustawienia domyślne rejestracji, języka i strefy czasowej nowych kont.",
|
||||
"registrationEnabled": "Zezwól na rejestrację",
|
||||
"registrationEnabledDescription": "Gdy wyłączone, nowi użytkownicy nie mogą się zarejestrować.",
|
||||
"registrationOpenHint": "Rejestracja jest otwarta — każdy, kto ma dostęp do tej instancji, może założyć konto. Zamknij ją tutaj, gdy wszyscy będą mieć konto.",
|
||||
"defaultLanguage": "Domyślny język",
|
||||
"defaultLanguageDescription": "Używany dla nowych użytkowników i jako rozwiązanie zapasowe.",
|
||||
"defaultUserTimezone": "Domyślna strefa czasowa dla nowych kont",
|
||||
@@ -6270,7 +6275,7 @@
|
||||
"eventColumn": "Zdarzenie",
|
||||
"subtitle": "Wybierz, które powiadomienia chcesz otrzymywać i którym kanałem.",
|
||||
"loadError": "Nie udało się załadować ustawień powiadomień.",
|
||||
"noChannels": "Najpierw skonfiguruj kanał powiadomień w ustawieniach.",
|
||||
"noChannels": "Najpierw skonfiguruj kanał powiadomień w Ustawienia → Integracje.",
|
||||
"goToSettings": "Przejdź do ustawień kanału",
|
||||
"channelDisabled": "Wyłączone",
|
||||
"admin": {
|
||||
@@ -6381,7 +6386,10 @@
|
||||
"newDeviceBody": "Nowe logowanie z {device} ({location}). Jeśli to nie Ty, natychmiast zmień hasło.",
|
||||
"unknownLocation": "nieznana lokalizacja"
|
||||
},
|
||||
"loginRequired": "Zaloguj się, aby zarządzać powiadomieniami."
|
||||
"loginRequired": "Zaloguj się, aby zarządzać powiadomieniami.",
|
||||
"someChannelsGloballyDisabled": "Co najmniej jeden kanał jest wyłączony w ustawieniach i nie będzie otrzymywać powiadomień.",
|
||||
"reminderSettingsLink": "Ustawienia przypomnień",
|
||||
"channelsLink": "Kanały"
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
@@ -7377,7 +7385,8 @@
|
||||
"pdfNeedsAnthropic": "Skanowanie PDF wymaga dostawcy wizji Claude; prześlij zamiast tego zdjęcie."
|
||||
},
|
||||
"loadMoreReadings": "Wczytaj więcej pomiarów",
|
||||
"documentsLink": "Dokumenty wyników badań"
|
||||
"documentsLink": "Dokumenty wyników badań",
|
||||
"notLinkedYet": "Jeszcze niepowiązane z biomarkerem"
|
||||
},
|
||||
"modules": {
|
||||
"cycle": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.27.31",
|
||||
"version": "1.27.32",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.27.31";
|
||||
/* @sw-version-fallback */ "v1.27.32";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
Heart,
|
||||
KeyRound,
|
||||
Languages,
|
||||
Loader2,
|
||||
Lock,
|
||||
LogIn,
|
||||
Moon,
|
||||
@@ -33,6 +32,8 @@ import { Progress } from "@/components/ui/progress";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDate } from "@/lib/format";
|
||||
@@ -271,16 +272,12 @@ export default function AchievementsPage() {
|
||||
// alongside `<RecentAchievementsCard>` and
|
||||
// `<AchievementUnlockNotifier>` so the three consumers never trigger
|
||||
// more than one network call on a cold dashboard mount.
|
||||
const { data, isLoading } = useAchievementsQuery({
|
||||
const { data, isLoading, isError, refetch } = useAchievementsQuery({
|
||||
enabled: isAuthenticated && achievementsEnabled,
|
||||
});
|
||||
|
||||
if (authLoading || (achievementsEnabled && isLoading)) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
return <PageAuthGate label={t("common.loading")} />;
|
||||
}
|
||||
|
||||
// v1.18.0 — module off ⇒ the surface disappears entirely. The nav entry
|
||||
@@ -302,6 +299,21 @@ export default function AchievementsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// A failed load must never fall through to the zeroed summary tiles + the
|
||||
// "none unlocked yet" empty grid (UI-STANDARDS §6). Surface an honest
|
||||
// retry instead.
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={t("achievements.title")}
|
||||
description={t("achievements.subtitle")}
|
||||
/>
|
||||
<QueryErrorCard onRetry={() => refetch()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const summary = data?.summary;
|
||||
const achievements = data?.achievements ?? [];
|
||||
const grouped = groupByCategory(achievements);
|
||||
|
||||
@@ -8,11 +8,23 @@ vi.mock("@/lib/api-handler", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { create: vi.fn(), upsert: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", () => {
|
||||
const measurement = {
|
||||
findMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
measurement,
|
||||
// Run the batched write callback against the same measurement mock so
|
||||
// createMany / updateMany calls are observable.
|
||||
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) =>
|
||||
fn({ measurement }),
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
@@ -83,6 +95,23 @@ beforeEach(() => {
|
||||
remaining: 4,
|
||||
resetAt: new Date(Date.now() + 1000),
|
||||
} as never);
|
||||
// Default: nothing pre-exists; the transaction runner is re-stubbed by the
|
||||
// db mock factory (resetAllMocks clears its implementation), so restore it.
|
||||
const measurement = prisma.measurement as unknown as {
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
createMany: ReturnType<typeof vi.fn>;
|
||||
updateMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
measurement.findMany.mockResolvedValue([]);
|
||||
measurement.createMany.mockResolvedValue({ count: 0 });
|
||||
measurement.updateMany.mockResolvedValue({ count: 0 });
|
||||
(
|
||||
prisma.$transaction as unknown as {
|
||||
mockImplementation: (f: unknown) => void;
|
||||
}
|
||||
).mockImplementation(async (fn: (tx: unknown) => unknown) =>
|
||||
fn({ measurement }),
|
||||
);
|
||||
});
|
||||
|
||||
describe("POST /api/import/csv — rate limit", () => {
|
||||
@@ -103,18 +132,23 @@ describe("POST /api/import/csv — rate limit", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const mMeasurement = () =>
|
||||
prisma.measurement as unknown as {
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
createMany: ReturnType<typeof vi.fn>;
|
||||
updateMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
describe("POST /api/import/csv — fatal header error", () => {
|
||||
it("returns 422 when a required column is missing", async () => {
|
||||
const res = await POST(csvRequest("type,value,unit\nWEIGHT,80,kg"));
|
||||
expect(res.status).toBe(422);
|
||||
expect(vi.mocked(prisma.measurement.create)).not.toHaveBeenCalled();
|
||||
expect(mMeasurement().createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/import/csv — write loop + per-row envelope", () => {
|
||||
describe("POST /api/import/csv — batched write + per-row envelope", () => {
|
||||
it("inserts valid rows, skips invalid ones, returns per-row status", async () => {
|
||||
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
|
||||
|
||||
const res = await POST(
|
||||
csvRequest(
|
||||
[
|
||||
@@ -135,12 +169,29 @@ describe("POST /api/import/csv — write loop + per-row envelope", () => {
|
||||
const reasons = body.data?.rows.map((r) => r.reason);
|
||||
expect(reasons).toContain("unknown_type");
|
||||
expect(reasons).toContain("missing_timezone_offset");
|
||||
// One create call for the single valid row.
|
||||
expect(vi.mocked(prisma.measurement.create)).toHaveBeenCalledTimes(1);
|
||||
// One bulk createMany carrying the single valid survivor.
|
||||
expect(mMeasurement().createMany).toHaveBeenCalledTimes(1);
|
||||
const arg = mMeasurement().createMany.mock.calls[0][0];
|
||||
expect(arg.data).toHaveLength(1);
|
||||
// Rollup re-fold fired for the touched (type, day).
|
||||
expect(vi.mocked(recomputeBucketsForMeasurement)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("batches many rows into a single createMany (no per-row round-trip)", async () => {
|
||||
const rows = [HEADER];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const mm = String(i).padStart(2, "0"); // unique minute → unique measuredAt
|
||||
rows.push(`WEIGHT,${80 + i * 0.1},kg,2026-05-01T08:${mm}:00Z,,,`);
|
||||
}
|
||||
const res = await POST(csvRequest(rows.join("\n")));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as CsvEnvelope;
|
||||
expect(body.data?.inserted).toBe(50);
|
||||
// 50 rows, one createMany call — the whole point of the batch.
|
||||
expect(mMeasurement().createMany).toHaveBeenCalledTimes(1);
|
||||
expect(mMeasurement().createMany.mock.calls[0][0].data).toHaveLength(50);
|
||||
});
|
||||
|
||||
it("dryRun previews without writing and reports projected inserts", async () => {
|
||||
const res = await POST(
|
||||
csvRequest(
|
||||
@@ -153,17 +204,14 @@ describe("POST /api/import/csv — write loop + per-row envelope", () => {
|
||||
expect(body.data?.dryRun).toBe(true);
|
||||
expect(body.data?.inserted).toBe(1);
|
||||
expect(body.data?.rows[0].status).toBe("inserted");
|
||||
expect(vi.mocked(prisma.measurement.create)).not.toHaveBeenCalled();
|
||||
expect(mMeasurement().createMany).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(recomputeBucketsForMeasurement)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("upserts an externalId row and surfaces it as updated when it already existed", async () => {
|
||||
const created = new Date("2026-05-01T08:00:00Z");
|
||||
const bumped = new Date("2026-05-02T08:00:00Z");
|
||||
vi.mocked(prisma.measurement.upsert).mockResolvedValue({
|
||||
createdAt: created,
|
||||
updatedAt: bumped,
|
||||
} as never);
|
||||
it("surfaces an externalId row as updated when it already existed (updateMany)", async () => {
|
||||
mMeasurement().findMany.mockResolvedValue([
|
||||
{ type: "WEIGHT", externalId: "ext-1" },
|
||||
]);
|
||||
|
||||
const res = await POST(
|
||||
csvRequest(
|
||||
@@ -174,13 +222,28 @@ describe("POST /api/import/csv — write loop + per-row envelope", () => {
|
||||
expect(body.data?.updated).toBe(1);
|
||||
expect(body.data?.inserted).toBe(0);
|
||||
expect(body.data?.rows[0].status).toBe("updated");
|
||||
expect(vi.mocked(prisma.measurement.upsert)).toHaveBeenCalledTimes(1);
|
||||
expect(mMeasurement().updateMany).toHaveBeenCalledTimes(1);
|
||||
expect(mMeasurement().createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("counts a unique-constraint duplicate as skipped/duplicate", async () => {
|
||||
vi.mocked(prisma.measurement.create).mockRejectedValue(
|
||||
new Error("Unique constraint failed"),
|
||||
it("inserts an externalId row when it does not exist yet", async () => {
|
||||
mMeasurement().findMany.mockResolvedValue([]);
|
||||
const res = await POST(
|
||||
csvRequest(
|
||||
[HEADER, "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,ext-2"].join("\n"),
|
||||
),
|
||||
);
|
||||
const body = (await res.json()) as CsvEnvelope;
|
||||
expect(body.data?.inserted).toBe(1);
|
||||
expect(body.data?.updated).toBe(0);
|
||||
expect(body.data?.rows[0].status).toBe("inserted");
|
||||
expect(mMeasurement().createMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("counts a pre-existing natural-key row as skipped/duplicate", async () => {
|
||||
mMeasurement().findMany.mockResolvedValue([
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-05-01T08:00:00Z") },
|
||||
]);
|
||||
|
||||
const res = await POST(
|
||||
csvRequest([HEADER, "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,"].join("\n")),
|
||||
@@ -192,5 +255,23 @@ describe("POST /api/import/csv — write loop + per-row envelope", () => {
|
||||
status: "skipped",
|
||||
reason: "duplicate",
|
||||
});
|
||||
expect(mMeasurement().createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("collapses an in-file duplicate (same type+measuredAt) to one insert + one duplicate", async () => {
|
||||
const res = await POST(
|
||||
csvRequest(
|
||||
[
|
||||
HEADER,
|
||||
"WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,",
|
||||
"WEIGHT,80.6,kg,2026-05-01T08:00:00Z,,,",
|
||||
].join("\n"),
|
||||
),
|
||||
);
|
||||
const body = (await res.json()) as CsvEnvelope;
|
||||
expect(body.data?.inserted).toBe(1);
|
||||
expect(body.data?.skipped).toBe(1);
|
||||
// Only the first survivor reaches the bulk insert.
|
||||
expect(mMeasurement().createMany.mock.calls[0][0].data).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
+147
-64
@@ -19,6 +19,8 @@ import type {
|
||||
MeasurementType,
|
||||
GlucoseContext,
|
||||
} from "@/generated/prisma/client";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import type { NormalisedMeasurementRow } from "@/lib/import/csv-measurements";
|
||||
|
||||
/**
|
||||
* CSV measurement import (v1.17.1).
|
||||
@@ -106,84 +108,165 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const writeOutcome = new Map<number, "inserted" | "updated" | "duplicate">();
|
||||
|
||||
if (!dryRun) {
|
||||
for (const result of okRows) {
|
||||
const m = result.row;
|
||||
try {
|
||||
if (m.externalId) {
|
||||
// Idempotent re-import keyed on the source-stable id — re-uploading
|
||||
// the same file updates in place rather than minting duplicates.
|
||||
const res = await prisma.measurement.upsert({
|
||||
// Batched write. The previous shape ran one serial `upsert` (or `create`)
|
||||
// per accepted row — N round-trips for an N-row file, which on a
|
||||
// few-thousand-row import approaches the request timeout. This adopts the
|
||||
// fast shape `/measurements/batch` uses: pre-fetch the existing rows under
|
||||
// the IMPORT source in one query per dedup-key shape, partition into bulk
|
||||
// insert / update sets, then `createMany` + grouped `updateMany`. The
|
||||
// per-line inserted/updated/duplicate envelope is reconstructed from the
|
||||
// partition, so the response contract is unchanged.
|
||||
|
||||
const buildCreateData = (
|
||||
m: NormalisedMeasurementRow,
|
||||
): Prisma.MeasurementCreateManyInput => ({
|
||||
userId,
|
||||
type: m.type as MeasurementType,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
source: "IMPORT",
|
||||
...(m.externalId ? { externalId: m.externalId } : {}),
|
||||
measuredAt: m.measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext: (m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
});
|
||||
|
||||
const extRows = okRows.filter((r) => r.row.externalId);
|
||||
const plainRows = okRows.filter((r) => !r.row.externalId);
|
||||
|
||||
// --- externalId rows: idempotent upsert on
|
||||
// (userId, type, source=IMPORT, externalId). One probe fetches every
|
||||
// pre-existing key; a hit means the row updates in place.
|
||||
const extExisting =
|
||||
extRows.length > 0
|
||||
? await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId_type_source_externalId: {
|
||||
userId,
|
||||
type: m.type as MeasurementType,
|
||||
source: "IMPORT",
|
||||
externalId: m.externalId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt: m.measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
type: m.type as MeasurementType,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
source: "IMPORT",
|
||||
externalId: m.externalId,
|
||||
measuredAt: m.measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
OR: extRows.map((r) => ({
|
||||
type: r.row.type as MeasurementType,
|
||||
externalId: r.row.externalId as string,
|
||||
})),
|
||||
},
|
||||
select: { createdAt: true, updatedAt: true },
|
||||
});
|
||||
// A fresh create has createdAt === updatedAt; an in-place update
|
||||
// bumps updatedAt past createdAt.
|
||||
if (res.updatedAt.getTime() > res.createdAt.getTime()) {
|
||||
updated++;
|
||||
writeOutcome.set(result.line, "updated");
|
||||
} else {
|
||||
inserted++;
|
||||
writeOutcome.set(result.line, "inserted");
|
||||
}
|
||||
} else {
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
select: { type: true, externalId: true },
|
||||
})
|
||||
: [];
|
||||
const extExistingSet = new Set(
|
||||
extExisting.map((e) => `${e.type}::${e.externalId}`),
|
||||
);
|
||||
|
||||
// Partition in file order, honouring in-file repeats: the first sighting
|
||||
// of a brand-new key inserts, every later sighting (or any sighting of a
|
||||
// key already in the DB) updates in place. The last value for a key wins,
|
||||
// matching the old sequential-upsert overwrite. Insert/update maps are
|
||||
// keyed by (type, externalId) so a repeated key collapses to one write.
|
||||
const seenExt = new Set<string>();
|
||||
const extInserts = new Map<string, NormalisedMeasurementRow>();
|
||||
const extUpdates = new Map<string, NormalisedMeasurementRow>();
|
||||
for (const result of extRows) {
|
||||
const m = result.row;
|
||||
const key = `${m.type}::${m.externalId}`;
|
||||
if (extExistingSet.has(key)) {
|
||||
extUpdates.set(key, m);
|
||||
updated++;
|
||||
writeOutcome.set(result.line, "updated");
|
||||
} else if (seenExt.has(key)) {
|
||||
// New-to-DB key seen again in this file: still a single create with
|
||||
// the latest value, but this line reads as an update just as the
|
||||
// sequential upsert did.
|
||||
extInserts.set(key, m);
|
||||
updated++;
|
||||
writeOutcome.set(result.line, "updated");
|
||||
} else {
|
||||
seenExt.add(key);
|
||||
extInserts.set(key, m);
|
||||
inserted++;
|
||||
writeOutcome.set(result.line, "inserted");
|
||||
}
|
||||
touchedMeasurements.push({
|
||||
type: m.type as MeasurementType,
|
||||
measuredAt: m.measuredAt,
|
||||
});
|
||||
}
|
||||
|
||||
// --- externalId-less rows: create-only, deduped on the natural unique key
|
||||
// (userId, type, measuredAt, source=IMPORT, sleepStage=null). A collision
|
||||
// — against the DB or an earlier row in this same file — is a duplicate,
|
||||
// never an overwrite (each sample is a canonical reading).
|
||||
const plainExisting =
|
||||
plainRows.length > 0
|
||||
? await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: m.type as MeasurementType,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
source: "IMPORT",
|
||||
measuredAt: m.measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
OR: plainRows.map((r) => ({
|
||||
type: r.row.type as MeasurementType,
|
||||
measuredAt: r.row.measuredAt,
|
||||
})),
|
||||
},
|
||||
});
|
||||
inserted++;
|
||||
writeOutcome.set(result.line, "inserted");
|
||||
}
|
||||
select: { type: true, measuredAt: true },
|
||||
})
|
||||
: [];
|
||||
const plainExistingSet = new Set(
|
||||
plainExisting.map((e) => `${e.type}::${e.measuredAt.getTime()}`),
|
||||
);
|
||||
const seenPlain = new Set<string>();
|
||||
const plainInserts: NormalisedMeasurementRow[] = [];
|
||||
for (const result of plainRows) {
|
||||
const m = result.row;
|
||||
const key = `${m.type}::${m.measuredAt.getTime()}`;
|
||||
if (plainExistingSet.has(key) || seenPlain.has(key)) {
|
||||
skipped++;
|
||||
writeOutcome.set(result.line, "duplicate");
|
||||
} else {
|
||||
seenPlain.add(key);
|
||||
plainInserts.push(m);
|
||||
inserted++;
|
||||
writeOutcome.set(result.line, "inserted");
|
||||
touchedMeasurements.push({
|
||||
type: m.type as MeasurementType,
|
||||
measuredAt: m.measuredAt,
|
||||
});
|
||||
} catch {
|
||||
// Unique-constraint violation (a NULL-externalId duplicate against an
|
||||
// existing row) — count as skipped rather than failing the file.
|
||||
skipped++;
|
||||
writeOutcome.set(result.line, "duplicate");
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk write: createMany the survivors (chunked under the PG parameter
|
||||
// cap, skipDuplicates to absorb a concurrent double-submit race), then
|
||||
// grouped updateMany for the pre-existing externalId rows.
|
||||
const toCreate: Prisma.MeasurementCreateManyInput[] = [
|
||||
...[...extInserts.values()].map(buildCreateData),
|
||||
...plainInserts.map(buildCreateData),
|
||||
];
|
||||
const CREATE_CHUNK = 200;
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (let i = 0; i < toCreate.length; i += CREATE_CHUNK) {
|
||||
await tx.measurement.createMany({
|
||||
data: toCreate.slice(i, i + CREATE_CHUNK),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
for (const m of extUpdates.values()) {
|
||||
await tx.measurement.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
source: "IMPORT",
|
||||
type: m.type as MeasurementType,
|
||||
externalId: m.externalId as string,
|
||||
},
|
||||
data: {
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt: m.measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// One bounded rollup re-fold per touched (type, day) — a 10 000-row CSV
|
||||
// pays at most ~N (type, day) recomputes, not 10 000 per-row hooks.
|
||||
// Best-effort: a populator hiccup never fails the importer.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -35,11 +35,7 @@ export default function CheckupsPage() {
|
||||
const pull = usePullToRefresh({ onRefresh: refresh });
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
return <PageAuthGate />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { MessagesSquare, Search, Trash2 } from "lucide-react";
|
||||
import { MessagesSquare, Search, Target, Trash2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { COACH_SCROLLBAR } from "@/components/insights/coach-panel/message-thread";
|
||||
import {
|
||||
useCoachConversations,
|
||||
@@ -92,7 +94,8 @@ function groupByRecency(conversations: CoachConversationDTO[]): RecencyGroup[] {
|
||||
function CoachConversationsBody() {
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
const { conversations, isLoading } = useCoachConversations();
|
||||
const { conversations, isLoading, isError, refetch } =
|
||||
useCoachConversations();
|
||||
const deleteMutation = useDeleteCoachConversation();
|
||||
const [filter, setFilter] = useState<string>("");
|
||||
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||||
@@ -129,6 +132,19 @@ function CoachConversationsBody() {
|
||||
{t("insights.coach.historyTitle")}
|
||||
</span>
|
||||
}
|
||||
actions={
|
||||
// Surface the sibling Plans ledger — otherwise it is reachable
|
||||
// only from the composer's `+` menu.
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link
|
||||
href="/coach/plans"
|
||||
data-slot="coach-conversations-plans-link"
|
||||
>
|
||||
<Target className="size-4" aria-hidden="true" />
|
||||
{t("coach.plans.title")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Search
|
||||
@@ -160,6 +176,11 @@ function CoachConversationsBody() {
|
||||
>
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
) : isError && conversations.length === 0 ? (
|
||||
// A load failure must not masquerade as "no conversations yet" —
|
||||
// that reads as a data-loss scare and offers no way back. Mirror
|
||||
// /coach/plans and surface a retry.
|
||||
<QueryErrorCard onRetry={() => refetch()} />
|
||||
) : groups.length === 0 ? (
|
||||
<div
|
||||
data-slot="coach-conversations-empty"
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, Loader2, Target, Trash2, X } from "lucide-react";
|
||||
import {
|
||||
Check,
|
||||
Loader2,
|
||||
MessagesSquare,
|
||||
Target,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { COACH_SCROLLBAR } from "@/components/insights/coach-panel/message-thread";
|
||||
import {
|
||||
useCoachPlans,
|
||||
@@ -204,6 +214,17 @@ function CoachPlansBody() {
|
||||
<span data-slot="coach-plans-heading">{t("coach.plans.title")}</span>
|
||||
}
|
||||
description={t("coach.plans.pageDescription")}
|
||||
actions={
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link
|
||||
href="/coach/conversations"
|
||||
data-slot="coach-plans-conversations-link"
|
||||
>
|
||||
<MessagesSquare className="size-4" aria-hidden="true" />
|
||||
{t("insights.coach.historyTitle")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -216,23 +237,22 @@ function CoachPlansBody() {
|
||||
{query.isError ? (
|
||||
<QueryErrorCard onRetry={() => query.refetch()} />
|
||||
) : query.isLoading ? (
|
||||
<p
|
||||
data-slot="coach-plans-loading"
|
||||
className="text-muted-foreground px-1 py-3 text-sm"
|
||||
>
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
) : groups.length === 0 ? (
|
||||
<div
|
||||
data-slot="coach-plans-empty"
|
||||
className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-12 text-center"
|
||||
data-slot="coach-plans-loading"
|
||||
className="flex flex-col gap-2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="bg-muted/40 text-muted-foreground flex size-12 items-center justify-center rounded-full">
|
||||
<Target className="size-6" aria-hidden="true" />
|
||||
</span>
|
||||
<p className="text-muted-foreground max-w-xs text-sm leading-relaxed">
|
||||
{t("coach.plans.empty")}
|
||||
</p>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Skeleton key={i} className="h-16 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div data-slot="coach-plans-empty">
|
||||
<EmptyState
|
||||
variant="plain"
|
||||
icon={<Target className="size-6" />}
|
||||
title={t("coach.plans.empty")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
import { use, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
|
||||
import { CustomMetricDetail } from "@/components/custom-metrics/custom-metric-detail";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
@@ -35,11 +35,7 @@ export default function CustomMetricDetailPage({
|
||||
}, [isLoading, isAuthenticated, router]);
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
return <PageAuthGate label={t("common.loading")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+3
-13
@@ -2,9 +2,9 @@
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { CycleView } from "@/components/cycle/cycle-view";
|
||||
|
||||
/**
|
||||
@@ -36,24 +36,14 @@ export default function CyclePage() {
|
||||
}, [isLoading, isAuthenticated, enabled, router]);
|
||||
|
||||
if (isLoading || !isAuthenticated || !enabled) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
return <PageAuthGate />;
|
||||
}
|
||||
|
||||
// `<CycleView>` reads `useSearchParams` for the `?tab=` deep-link; wrap it in
|
||||
// a Suspense boundary so the client-search-params bailout never de-opts the
|
||||
// build (Next's `missing-suspense-with-csr-bailout`).
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<PageAuthGate />}>
|
||||
<CycleView />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
import { use, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
|
||||
import { IllnessEpisodeDetail } from "@/components/illness/illness-episode-detail";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
@@ -40,11 +40,7 @@ export default function IllnessEpisodePage({
|
||||
}, [isLoading, isAuthenticated, enabled, router]);
|
||||
|
||||
if (!mounted || isLoading || !isAuthenticated || !enabled) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
return <PageAuthGate label={t("common.loading")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { IllnessView } from "@/components/illness/illness-view";
|
||||
|
||||
/**
|
||||
@@ -35,11 +35,7 @@ export default function IllnessPage() {
|
||||
}, [isLoading, isAuthenticated, enabled, router]);
|
||||
|
||||
if (isLoading || !isAuthenticated || !enabled) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
return <PageAuthGate />;
|
||||
}
|
||||
|
||||
return <IllnessView />;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Fragment, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { RefreshCw, SlidersHorizontal, TrendingUp } from "lucide-react";
|
||||
import { SlidersHorizontal, TrendingUp } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -20,6 +20,7 @@ import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HeroStrip } from "@/components/insights/hero-strip";
|
||||
@@ -340,23 +341,11 @@ export default function InsightsPage() {
|
||||
// mirroring the <VitalsDashboard> error pattern.
|
||||
if (isError) {
|
||||
return (
|
||||
<div
|
||||
data-slot="insights-overview-error"
|
||||
role="alert"
|
||||
className="bg-card border-border text-muted-foreground flex flex-col items-start gap-3 rounded-xl border p-4 text-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<span>{t("insights.loadError")}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
data-slot="insights-overview-retry"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{t("common.retry")}</span>
|
||||
</Button>
|
||||
<div data-slot="insights-overview-error">
|
||||
<QueryErrorCard
|
||||
description={t("insights.loadError")}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
import { use, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
|
||||
import { LabBiomarkerDetail } from "@/components/labs/lab-biomarker-detail";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
@@ -35,11 +35,7 @@ export default function LabBiomarkerDetailPage({
|
||||
}, [isLoading, isAuthenticated, router]);
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
return <PageAuthGate label={t("common.loading")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,9 +14,10 @@ import { CustomMetricList } from "@/components/custom-metrics/custom-metric-list
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Plus, Loader2 } from "lucide-react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
@@ -116,11 +117,7 @@ export default function MeasurementsPage() {
|
||||
// SSR HTML when this boundary hydrates after `/api/auth/me` settled
|
||||
// (React #418 family); see `useMounted`.
|
||||
if (!mounted || isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
return <PageAuthGate label={t("common.loading")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { use, useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -68,19 +68,7 @@ export default function MedicationDetailPage({
|
||||
});
|
||||
|
||||
if (authLoading || isLoading || !isAuthenticated) {
|
||||
return (
|
||||
<div
|
||||
className="flex h-64 items-center justify-center"
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return <PageAuthGate label={t("common.loading")} />;
|
||||
}
|
||||
|
||||
if (isError || !medication) {
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
CheckCheck,
|
||||
@@ -426,20 +427,11 @@ export default function MedicationsPage() {
|
||||
)}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="bg-card border-border flex h-64 items-center justify-center rounded-xl border">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.loadFailed")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void refetchMedications()}
|
||||
>
|
||||
{t("medications.retryLoad")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<QueryErrorCard
|
||||
description={t("medications.loadFailed")}
|
||||
retryLabel={t("medications.retryLoad")}
|
||||
onRetry={() => void refetchMedications()}
|
||||
/>
|
||||
) : !medications?.length ? (
|
||||
// v1.4.15 phase-C5: refactor the inline icon+text+button block
|
||||
// to the shared EmptyState primitive so the empty path matches
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing";
|
||||
|
||||
/**
|
||||
@@ -40,11 +40,7 @@ export default function MentalWellbeingPage() {
|
||||
}, [isLoading, isAuthenticated, enabled, router]);
|
||||
|
||||
if (isLoading || !isAuthenticated || !enabled) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
return <PageAuthGate />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,9 +10,10 @@ import { MoodForm } from "@/components/mood/mood-form";
|
||||
import { MoodList } from "@/components/mood/mood-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Plus, Loader2, Wrench } from "lucide-react";
|
||||
import { Plus, Wrench } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -54,11 +55,7 @@ export default function MoodPage() {
|
||||
// and React logged hydration error #418 — same fix as the measurements
|
||||
// page; see `useMounted`.
|
||||
if (!mounted || isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
return <PageAuthGate label={t("common.loading")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* Notification-matrix page contract (QoL/UX audit 2026-07-08):
|
||||
* - H2: the "no channels" CTA points at Settings → Integrations#channels,
|
||||
* the surface that can actually add a channel (not the reminder-types
|
||||
* screen, which no longer configures channels since v1.25.7).
|
||||
* - M3: a failed load renders the shared `QueryErrorCard` with a Retry,
|
||||
* never a bare alert box; the loading frame reserves a skeleton.
|
||||
* - L1: the globally-disabled hint reads as a full sentence, not the bare
|
||||
* "(Disabled)" inline tag.
|
||||
* - M7: the page cross-links the other two notification surfaces.
|
||||
*/
|
||||
|
||||
type AuthState = { isAuthenticated: boolean; isLoading: boolean };
|
||||
type QueryState = {
|
||||
data: unknown;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
let authState: AuthState = { isAuthenticated: true, isLoading: false };
|
||||
let queryState: QueryState = {
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
};
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => authState,
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ ...queryState, refetch: vi.fn() }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useQueryClient: () => ({
|
||||
cancelQueries: vi.fn(),
|
||||
getQueryData: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
invalidateQueries: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import NotificationsPage from "../page";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<NotificationsPage />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
authState = { isAuthenticated: true, isLoading: false };
|
||||
queryState = { data: undefined, isLoading: false, isError: false };
|
||||
});
|
||||
|
||||
describe("NotificationsPage", () => {
|
||||
it("M3: renders a skeleton while loading, not a lone spinner", () => {
|
||||
queryState.isLoading = true;
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="notifications-loading"');
|
||||
expect(html).not.toContain("animate-spin");
|
||||
});
|
||||
|
||||
it("M3: renders the shared QueryErrorCard with a retry on load failure", () => {
|
||||
queryState.isError = true;
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="query-error-card"');
|
||||
expect(html).toContain("Notification preferences could not be loaded.");
|
||||
});
|
||||
|
||||
it("H2 + L4: the no-channels CTA targets Settings → Integrations#channels", () => {
|
||||
queryState.data = { channels: [], preferences: [], eventTypes: [] };
|
||||
const html = render();
|
||||
expect(html).toContain('href="/settings/integrations#channels"');
|
||||
// The reminder-types screen no longer configures channels — never point there.
|
||||
expect(html).not.toContain('href="/settings/notifications"><');
|
||||
expect(html).toContain("Settings → Integrations");
|
||||
});
|
||||
|
||||
it("M7: the matrix cross-links the reminder-types + channels surfaces", () => {
|
||||
queryState.data = {
|
||||
channels: [
|
||||
{
|
||||
id: "c1",
|
||||
type: "telegram",
|
||||
label: "Telegram",
|
||||
enabled: true,
|
||||
globallyEnabled: true,
|
||||
},
|
||||
],
|
||||
preferences: [],
|
||||
eventTypes: ["MEDICATION_REMINDER"],
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain('href="/settings/notifications"');
|
||||
expect(html).toContain('href="/settings/integrations#channels"');
|
||||
});
|
||||
|
||||
it("L1: the globally-disabled hint is a full sentence, not the bare tag", () => {
|
||||
queryState.data = {
|
||||
channels: [
|
||||
{
|
||||
id: "c1",
|
||||
type: "telegram",
|
||||
label: "Telegram",
|
||||
enabled: true,
|
||||
globallyEnabled: true,
|
||||
},
|
||||
{
|
||||
id: "c2",
|
||||
type: "ntfy",
|
||||
label: "ntfy",
|
||||
enabled: true,
|
||||
globallyEnabled: false,
|
||||
},
|
||||
],
|
||||
preferences: [],
|
||||
eventTypes: ["MEDICATION_REMINDER"],
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain("won’t receive notifications");
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,10 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import Link from "next/link";
|
||||
import { Bell, Loader2, Settings, AlertCircle } from "lucide-react";
|
||||
import { Bell, Settings, AlertCircle } from "lucide-react";
|
||||
import {
|
||||
EVENT_DEFAULT_ENABLED,
|
||||
type EventType,
|
||||
@@ -52,7 +54,7 @@ export default function NotificationsPage() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.notificationsPreferences(),
|
||||
queryFn: async () => {
|
||||
return apiGet<PreferencesData>("/api/notifications/preferences");
|
||||
@@ -119,10 +121,50 @@ export default function NotificationsPage() {
|
||||
return EVENT_DEFAULT_ENABLED[eventType as EventType] ?? true;
|
||||
}
|
||||
|
||||
// Cross-links to the other two notification surfaces (reminder types +
|
||||
// delivery channels) so the matrix never reads as the only place to manage
|
||||
// notifications. Mirrored by back-links on those two settings screens.
|
||||
const headerActions = (
|
||||
<>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/settings/notifications">
|
||||
{t("notifications.reminderSettingsLink")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/settings/integrations#channels">
|
||||
{t("notifications.channelsLink")}
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
if (authLoading || isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={t("notifications.title")}
|
||||
description={t("notifications.subtitle")}
|
||||
/>
|
||||
{/* Skeleton mirrors the event-row matrix so the auth-resolving frame
|
||||
reserves layout instead of flashing a lone spinner. */}
|
||||
<div
|
||||
data-slot="notifications-loading"
|
||||
className="bg-card border-border overflow-hidden rounded-xl border"
|
||||
>
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-border flex items-center justify-between gap-4 border-b p-4 last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-40 rounded" />
|
||||
<Skeleton className="h-3 w-56 rounded" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-9 shrink-0 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -149,12 +191,10 @@ export default function NotificationsPage() {
|
||||
title={t("notifications.title")}
|
||||
description={t("notifications.subtitle")}
|
||||
/>
|
||||
<div
|
||||
role="alert"
|
||||
className="border-destructive/40 bg-destructive/10 text-destructive rounded-lg border p-4 text-sm"
|
||||
>
|
||||
{t("notifications.loadError")}
|
||||
</div>
|
||||
<QueryErrorCard
|
||||
description={t("notifications.loadError")}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -166,6 +206,7 @@ export default function NotificationsPage() {
|
||||
<PageHeader
|
||||
title={t("notifications.title")}
|
||||
description={t("notifications.subtitle")}
|
||||
actions={headerActions}
|
||||
/>
|
||||
<div className="bg-card border-border max-w-2xl rounded-xl border p-6">
|
||||
<div className="flex flex-col items-center gap-4 py-8 text-center">
|
||||
@@ -173,8 +214,10 @@ export default function NotificationsPage() {
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("notifications.noChannels")}
|
||||
</p>
|
||||
{/* Channel setup lives in Settings → Integrations since v1.25.7;
|
||||
point the CTA at that anchor, not the reminder-types screen. */}
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/settings/notifications">
|
||||
<Link href="/settings/integrations#channels">
|
||||
<Settings className="h-4 w-4" />
|
||||
{t("notifications.goToSettings")}
|
||||
</Link>
|
||||
@@ -190,6 +233,7 @@ export default function NotificationsPage() {
|
||||
<PageHeader
|
||||
title={t("notifications.title")}
|
||||
description={t("notifications.subtitle")}
|
||||
actions={headerActions}
|
||||
/>
|
||||
|
||||
{/* Desktop: table layout */}
|
||||
@@ -321,12 +365,13 @@ export default function NotificationsPage() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Info hint about disabled channels */}
|
||||
{/* Info hint about globally-disabled channels — a full sentence that
|
||||
explains the state, not the bare "(Disabled)" inline tag. */}
|
||||
{channels.some((ch) => !ch.globallyEnabled) && (
|
||||
<div className="flex items-start gap-2 rounded-lg p-3 text-sm">
|
||||
<AlertCircle className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p className="text-muted-foreground">
|
||||
{t("notifications.channelDisabled")}
|
||||
{t("notifications.someChannelsGloballyDisabled")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -13,18 +13,16 @@ import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
/**
|
||||
* v1.4.25 W14b — onboarding wizard step page (foundation scaffold).
|
||||
* Onboarding wizard step page.
|
||||
*
|
||||
* Routes:
|
||||
* /onboarding/0 welcome → carousel / value-prop intro
|
||||
* /onboarding/1 goals → "what do you want to track?"
|
||||
* /onboarding/2 source → Withings / Apple Health / manual cards
|
||||
* /onboarding/2 source → device/import source cards (see
|
||||
* source-card-grid.tsx for the shipped set)
|
||||
* /onboarding/3 baseline → first measurement or sync confirmation
|
||||
* /onboarding/4 done → success screen + return to dashboard
|
||||
*
|
||||
* This page ships the scaffold only — the step body is a placeholder
|
||||
* `<div>` that the W14b-Content agent replaces with real step UI.
|
||||
*
|
||||
* Step gating:
|
||||
* - Unauthenticated → `/auth/login` (the proxy also enforces this,
|
||||
* but the server-side check keeps the contract explicit).
|
||||
@@ -139,10 +137,12 @@ export default async function OnboardingStepPage({ params }: PageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Source (step 2) — four-card grid. Manual is the implicit default;
|
||||
// Withings opens OAuth in a new tab; Apple Health is rendered as a
|
||||
// "coming with v1.5" announce card; Garmin is omitted per the
|
||||
// W14b-Content brief (not on the roadmap yet).
|
||||
// Source (step 2) — card grid. Manual is the implicit default; the
|
||||
// connectable sources (Apple Health, WHOOP, Oura, Polar, Nightscout,
|
||||
// Fitbit/Pixel via Google Health) live in `source-card-grid.tsx`'s
|
||||
// SOURCE_CARDS / MORE_SOURCES lists — refer to that component for the
|
||||
// current shipped set rather than duplicating it here. The selection
|
||||
// is non-binding: nothing here auto-configures a sync.
|
||||
if (requested === 2) {
|
||||
return (
|
||||
<OnboardingShell step={2} userLocale={user.locale ?? null}>
|
||||
|
||||
+61
-18
@@ -25,6 +25,7 @@ import {
|
||||
} from "@/lib/dashboard-layout";
|
||||
import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
|
||||
@@ -131,6 +132,30 @@ import {
|
||||
resolveChartRowPlaceholderCount,
|
||||
} from "@/components/dashboard/dashboard-gates";
|
||||
|
||||
/**
|
||||
* Dashboard metric tiles are no longer dead-ends: each tile links to its
|
||||
* Insights detail page (bands, correlations, history). Keyed by the stable
|
||||
* tile `id` used in the `trendCards` builder; glucose contexts share one
|
||||
* blood-glucose destination via the `glucose-` prefix. Tiles with no
|
||||
* dedicated sub-page (body fat %) stay un-linked rather than misroute.
|
||||
*/
|
||||
const TILE_INSIGHT_HREF: Record<string, string> = {
|
||||
weight: "/insights/weight",
|
||||
"bp-sys": "/insights/blood-pressure",
|
||||
"bp-dia": "/insights/blood-pressure",
|
||||
bpInTarget: "/insights/blood-pressure",
|
||||
pulse: "/insights/pulse",
|
||||
mood: "/insights/mood",
|
||||
sleep: "/insights/sleep",
|
||||
steps: "/insights/steps",
|
||||
vo2Max: "/insights/cardio-fitness",
|
||||
};
|
||||
|
||||
function tileInsightHref(id: string): string | null {
|
||||
if (id.startsWith("glucose-")) return "/insights/blood-glucose";
|
||||
return TILE_INSIGHT_HREF[id] ?? null;
|
||||
}
|
||||
|
||||
export default function DashboardPageClient() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const mounted = useMounted();
|
||||
@@ -1611,27 +1636,45 @@ export default function DashboardPageClient() {
|
||||
data-tour-id="dashboard-tile-strip"
|
||||
data-tile-count={trendCards.length}
|
||||
>
|
||||
{trendCards.map((entry) => (
|
||||
<div key={entry.id} className="flex min-w-0">
|
||||
{/*
|
||||
* Per-tile `<Suspense>` boundary with a layout-stable
|
||||
* placeholder that mirrors the trend-card chrome. Tile
|
||||
* bodies are synchronous today so the fallback rarely
|
||||
* paints, but a future RSC hoist of any tile slot
|
||||
* would otherwise leave the grid track empty and
|
||||
* trigger CLS as the cell paints in.
|
||||
*
|
||||
* v1.4.40 W-RSC — boundary added; v1.4.41 W-FRONTEND-FACTORY — fallback hoisted to layout-stable placeholder.
|
||||
*/}
|
||||
{/* v1.16.0 — the fallback is the same structured
|
||||
silhouette the tile-strip skeleton paints, so a
|
||||
suspending tile slot previews the final shape
|
||||
instead of an empty card. */}
|
||||
{trendCards.map((entry) => {
|
||||
/*
|
||||
* Per-tile `<Suspense>` boundary with a layout-stable
|
||||
* placeholder that mirrors the trend-card chrome. Tile
|
||||
* bodies are synchronous today so the fallback rarely
|
||||
* paints, but a future RSC hoist of any tile slot
|
||||
* would otherwise leave the grid track empty and
|
||||
* trigger CLS as the cell paints in.
|
||||
*
|
||||
* v1.16.0 — the fallback is the same structured
|
||||
* silhouette the tile-strip skeleton paints, so a
|
||||
* suspending tile slot previews the final shape
|
||||
* instead of an empty card.
|
||||
*/
|
||||
const cell = (
|
||||
<Suspense fallback={<TrendCardSkeleton />}>
|
||||
{entry.node}
|
||||
</Suspense>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
// A tile is no longer a dead-end: it links to its Insights
|
||||
// detail page. The link fills the grid cell and paints a
|
||||
// ring on hover / focus so the whole tile reads as tappable.
|
||||
const href = tileInsightHref(entry.id);
|
||||
return href ? (
|
||||
<Link
|
||||
key={entry.id}
|
||||
href={href}
|
||||
data-slot="dashboard-tile-link"
|
||||
data-tile-id={entry.id}
|
||||
className="group focus-visible:ring-ring/50 hover:ring-ring/30 flex min-w-0 rounded-xl transition-[box-shadow] hover:ring-2 focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
{cell}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={entry.id} className="flex min-w-0">
|
||||
{cell}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{charts.map((entry) => (
|
||||
|
||||
@@ -192,6 +192,11 @@ export function SettingsToggle({
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
// The visible label is a sibling `<p>` with no association to the
|
||||
// Radix switch button, so a screen reader would announce a bare
|
||||
// "switch, on/off". Name it explicitly — one fix covers every admin
|
||||
// toggle that mounts this shared component.
|
||||
aria-label={label}
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -96,6 +96,19 @@ export function GeneralSettingsSection() {
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
|
||||
{/* Honest nudge: registration stays open after the first admin so a
|
||||
household can be invited, but on an internet-exposed instance that
|
||||
means anyone who can reach the URL can sign up. Surface a quiet
|
||||
reminder while it is open — no behaviour change, just a signal. */}
|
||||
{(settings?.registrationEnabled ?? true) && (
|
||||
<p
|
||||
data-slot="registration-open-hint"
|
||||
className="text-muted-foreground -mt-2 text-xs"
|
||||
>
|
||||
{t("admin.registrationOpenHint")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* v1.23 — instance-wide "require a second factor" policy. Every
|
||||
account without an active factor is sent to forced enrollment after
|
||||
sign-in. This is a web-session nudge, not an API wall: direct API /
|
||||
|
||||
@@ -56,7 +56,7 @@ export function ChartEmptyState({
|
||||
style={height === undefined ? undefined : { height }}
|
||||
>
|
||||
<LineChartIcon
|
||||
className="text-muted-foreground/60 h-8 w-8"
|
||||
className="text-muted-foreground h-8 w-8"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
|
||||
@@ -73,7 +73,7 @@ export function ChartErrorState({
|
||||
style={height === undefined ? undefined : { height }}
|
||||
>
|
||||
<AlertTriangle
|
||||
className="text-muted-foreground/60 h-8 w-8"
|
||||
className="text-muted-foreground h-8 w-8"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
|
||||
@@ -90,6 +90,7 @@ export function ComplianceLineChart({
|
||||
<Button
|
||||
key={r.label}
|
||||
variant={activeRangePoints === r.points ? "default" : "ghost"}
|
||||
aria-pressed={activeRangePoints === r.points}
|
||||
size="sm"
|
||||
className="min-h-11 px-3 text-xs"
|
||||
onClick={() => setActiveRangePoints(r.points)}
|
||||
|
||||
@@ -1666,6 +1666,7 @@ export function HealthChart({
|
||||
<Button
|
||||
key={r.labelKey}
|
||||
variant={rangePoints === r.points ? "default" : "ghost"}
|
||||
aria-pressed={rangePoints === r.points}
|
||||
size="sm"
|
||||
className="min-h-11 px-2 text-xs sm:px-3"
|
||||
onClick={() => {
|
||||
|
||||
@@ -1050,6 +1050,7 @@ export function MoodChart({
|
||||
<Button
|
||||
key={r.labelKey}
|
||||
variant={rangePoints === r.points ? "default" : "ghost"}
|
||||
aria-pressed={rangePoints === r.points}
|
||||
size="sm"
|
||||
className="min-h-11 px-2 text-xs sm:px-3"
|
||||
onClick={() => setRangePoints(r.points)}
|
||||
|
||||
@@ -106,6 +106,7 @@ export function CustomMetricChart({
|
||||
key={r.key}
|
||||
size="sm"
|
||||
variant={range === r.key ? "secondary" : "ghost"}
|
||||
aria-pressed={range === r.key}
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setRange(r.key)}
|
||||
>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { toast } from "sonner";
|
||||
import { DeleteButton } from "@/components/data-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -178,23 +179,13 @@ export function CustomMetricDetail({
|
||||
{description}
|
||||
</p>
|
||||
</header>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<p className="text-destructive text-sm">
|
||||
{t("customMetrics.loadError")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (metricError) void refetchMetric();
|
||||
if (listError) void refetchList();
|
||||
}}
|
||||
>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<QueryErrorCard
|
||||
description={t("customMetrics.loadError")}
|
||||
onRetry={() => {
|
||||
if (metricError) void refetchMetric();
|
||||
if (listError) void refetchList();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight, Gauge, Plus } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
@@ -27,10 +29,11 @@ import type { CustomMetricDto, CustomMetricListResponse } from "./types";
|
||||
*/
|
||||
export function CustomMetricList() {
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.customMetrics(),
|
||||
queryFn: () => apiGet<CustomMetricListResponse>("/api/custom-metrics"),
|
||||
});
|
||||
@@ -40,10 +43,8 @@ export function CustomMetricList() {
|
||||
function afterAdd(saved: CustomMetricDto) {
|
||||
setAddOpen(false);
|
||||
// Land the user on the new metric's detail page so the next step (logging
|
||||
// a value) is one tap away.
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.assign(`/custom-metrics/${saved.id}`);
|
||||
}
|
||||
// a value) is one tap away — client nav, not a full-page reload.
|
||||
router.push(`/custom-metrics/${saved.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -80,9 +81,10 @@ export function CustomMetricList() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : isError ? (
|
||||
<p role="alert" className="text-destructive py-4 text-center text-sm">
|
||||
{t("customMetrics.loadError")}
|
||||
</p>
|
||||
<QueryErrorCard
|
||||
description={t("customMetrics.loadError")}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : metrics.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Loader2, Plus, RefreshCw, Sparkles } from "lucide-react";
|
||||
import {
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -100,6 +106,13 @@ export function CycleView() {
|
||||
: "calendar";
|
||||
}, [searchParams]);
|
||||
|
||||
// Controlled so the header "cycle settings" wrench (parity with the
|
||||
// labs/medications/illness modules, which expose a customize glyph) can jump
|
||||
// to the settings tab. Initialised from the `?tab=` deep-link, matching the
|
||||
// prior uncontrolled `defaultValue` behaviour (the initial param is honoured;
|
||||
// a later param change does not force-switch a tab the user is reading).
|
||||
const [tab, setTab] = useState<CycleTab>(initialTab);
|
||||
|
||||
const today = useMemo(() => shiftToday(0), []);
|
||||
const from = useMemo(() => shiftToday(-90), []);
|
||||
const to = useMemo(() => shiftToday(180), []);
|
||||
@@ -158,13 +171,26 @@ export function CycleView() {
|
||||
title={<span data-tour-id="cycle-hero">{t("cycle.title")}</span>}
|
||||
description={t("cycle.subtitle")}
|
||||
actions={
|
||||
<Button
|
||||
onClick={() => openSheet(today)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("cycle.logToday")}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
aria-label={t("cycle.tabs.settings")}
|
||||
title={t("cycle.tabs.settings")}
|
||||
data-slot="cycle-settings-wrench"
|
||||
onClick={() => setTab("settings")}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => openSheet(today)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("cycle.logToday")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -266,7 +292,7 @@ export function CycleView() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue={initialTab}>
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as CycleTab)}>
|
||||
{/* v1.15.10 — the four German labels (Kalender/Prognosen/
|
||||
Erkenntnisse/Einstellungen) overflowed the `flex-1` equal-width
|
||||
cells at 375 px and forced a horizontal scroll. Below `sm` the
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
|
||||
/**
|
||||
* v1.4.32 — `<RecentWorkoutsTile>`.
|
||||
@@ -70,7 +72,7 @@ function formatDuration(seconds: number): string {
|
||||
|
||||
function formatDayLabel(iso: string, locale: string): string {
|
||||
const d = new Date(iso);
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
return getDateTimeFormat(locale, {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
@@ -107,23 +109,23 @@ export function RecentWorkoutsTile() {
|
||||
"bg-card border-border space-y-3 rounded-xl border p-4 md:p-6",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity
|
||||
className="text-muted-foreground size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("dashboard.recentWorkouts.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
href="/insights/workouts"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-xs"
|
||||
>
|
||||
{t("dashboard.recentWorkouts.viewAll")}
|
||||
</Link>
|
||||
</div>
|
||||
{/* Canonical tile header (foreground icon + title) — the same
|
||||
`TileHeader` contract the Insights tiles use, so the dashboard
|
||||
stops speaking a separate header language. `size="sm"` matches the
|
||||
compact dashboard tile. */}
|
||||
<TileHeader
|
||||
icon={Activity}
|
||||
size="sm"
|
||||
title={t("dashboard.recentWorkouts.title")}
|
||||
right={
|
||||
<Link
|
||||
href="/insights/workouts"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-xs"
|
||||
>
|
||||
{t("dashboard.recentWorkouts.viewAll")}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
// v1.4.43 W11-L6 — reserve roughly the loaded tile height
|
||||
|
||||
@@ -95,6 +95,36 @@ describe("<DailyBriefing>", () => {
|
||||
expect(html).toContain("Weight down 30 d");
|
||||
});
|
||||
|
||||
it("links the key-finding rows for metrics whose Insights sub-page exists", () => {
|
||||
// Regression guard for the stale METRIC_HREF map: hrv / resting_hr /
|
||||
// active_energy / flights / distance / body_temp / recovery all have
|
||||
// dedicated Insights sub-pages now, so their briefing rows must be
|
||||
// tappable `<a>` links, not inert rows.
|
||||
const html = render(
|
||||
<DailyBriefing
|
||||
briefing={{
|
||||
...baseBriefing,
|
||||
keyFindings: [
|
||||
{ ...baseBriefing.keyFindings[2], sourceMetric: "hrv" },
|
||||
{ ...baseBriefing.keyFindings[2], sourceMetric: "resting_hr" },
|
||||
{ ...baseBriefing.keyFindings[2], sourceMetric: "active_energy" },
|
||||
{ ...baseBriefing.keyFindings[2], sourceMetric: "flights" },
|
||||
{ ...baseBriefing.keyFindings[2], sourceMetric: "distance" },
|
||||
{ ...baseBriefing.keyFindings[2], sourceMetric: "body_temp" },
|
||||
{ ...baseBriefing.keyFindings[2], sourceMetric: "recovery" },
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('href="/insights/hrv"');
|
||||
expect(html).toContain('href="/insights/resting-pulse"');
|
||||
expect(html).toContain('href="/insights/active-energy"');
|
||||
expect(html).toContain('href="/insights/flights-climbed"');
|
||||
expect(html).toContain('href="/insights/walking-distance"');
|
||||
expect(html).toContain('href="/insights/body-temperature"');
|
||||
expect(html).toContain('href="/insights/recovery"');
|
||||
});
|
||||
|
||||
it("renders the delta string when supplied", () => {
|
||||
const html = render(<DailyBriefing briefing={baseBriefing} />);
|
||||
expect(html).toMatch(/data-slot="daily-briefing-delta"/);
|
||||
|
||||
@@ -53,28 +53,28 @@ export const METRIC_HREF: Record<
|
||||
pulse: "/insights/pulse",
|
||||
mood: "/insights/mood",
|
||||
compliance: "/insights/medications",
|
||||
// Apple Health / GLP-1 additive metrics — no dedicated sub-page yet.
|
||||
// They render as static rows; v1.4.28 can wire each as they ship.
|
||||
hrv: null,
|
||||
// Apple Health / GLP-1 additive metrics — each now routes to its
|
||||
// dedicated sub-page that shipped since the original map was written.
|
||||
hrv: "/insights/hrv",
|
||||
sleep: "/insights/sleep",
|
||||
resting_hr: null,
|
||||
resting_hr: "/insights/resting-pulse",
|
||||
// v1.12.4 — steps has no dedicated metric sub-page, so it routes to the
|
||||
// generic readings view keyed to its `MeasurementType` (the same target
|
||||
// the category pages use to drill into a single type). Every other
|
||||
// briefing finding with a real destination is already tappable; steps
|
||||
// was the lone static row.
|
||||
steps: "/insights/steps",
|
||||
active_energy: null,
|
||||
flights: null,
|
||||
distance: null,
|
||||
active_energy: "/insights/active-energy",
|
||||
flights: "/insights/flights-climbed",
|
||||
distance: "/insights/walking-distance",
|
||||
vo2_max: "/insights/pulse",
|
||||
body_temp: null,
|
||||
body_temp: "/insights/body-temperature",
|
||||
glp1_plateau: "/insights/medications",
|
||||
// ── v1.10.0 derived-wellness additive ──
|
||||
// Readiness has a score-anatomy detail page; recovery is a read-only
|
||||
// ring with no dedicated sub-page yet (renders as a static row).
|
||||
// Readiness has a score-anatomy detail page; recovery has its own
|
||||
// read-only sub-page.
|
||||
readiness: "/insights/scores/readiness",
|
||||
recovery: null,
|
||||
recovery: "/insights/recovery",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -291,8 +291,11 @@ export function HeroStrip({
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-5">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Header glyph stays in the foreground tier — UI-STANDARDS §1
|
||||
reserves primary/accent for actions, not decorative header
|
||||
icons. */}
|
||||
<Sparkles
|
||||
className="text-primary h-5 w-5 shrink-0"
|
||||
className="text-foreground h-5 w-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h1
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
|
||||
import {
|
||||
hourCycleOptions,
|
||||
@@ -73,7 +74,7 @@ function formatDuration(seconds: number): string {
|
||||
|
||||
function formatDistanceKm(meters: number, locale: string): string {
|
||||
const km = meters / 1000;
|
||||
return new Intl.NumberFormat(locale, {
|
||||
return getNumberFormat(locale, {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: km < 10 ? 2 : 1,
|
||||
}).format(km);
|
||||
@@ -84,7 +85,7 @@ function formatNumber(
|
||||
locale: string,
|
||||
fractionDigits = 0,
|
||||
): string {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
return getNumberFormat(locale, {
|
||||
maximumFractionDigits: fractionDigits,
|
||||
minimumFractionDigits: fractionDigits,
|
||||
}).format(value);
|
||||
@@ -111,13 +112,13 @@ function formatDateRange(
|
||||
): string {
|
||||
const start = new Date(startedAt);
|
||||
const end = new Date(endedAt);
|
||||
const dateFmt = new Intl.DateTimeFormat(locale, {
|
||||
const dateFmt = getDateTimeFormat(locale, {
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
const timeFmt = new Intl.DateTimeFormat(locale, {
|
||||
const timeFmt = getDateTimeFormat(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
...hourCycleOptions(timeFormat),
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
|
||||
import {
|
||||
hourCycleOptions,
|
||||
@@ -68,14 +69,14 @@ function formatDuration(seconds: number): string {
|
||||
|
||||
function formatDistanceKm(meters: number, locale: string): string {
|
||||
const km = meters / 1000;
|
||||
return new Intl.NumberFormat(locale, {
|
||||
return getNumberFormat(locale, {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: km < 10 ? 2 : 1,
|
||||
}).format(km);
|
||||
}
|
||||
|
||||
function formatEnergy(kcal: number, locale: string): string {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
return getNumberFormat(locale, {
|
||||
maximumFractionDigits: 0,
|
||||
}).format(Math.round(kcal));
|
||||
}
|
||||
@@ -86,7 +87,7 @@ function formatDate(
|
||||
timeFormat: TimeFormatPreference,
|
||||
): string {
|
||||
const d = new Date(iso);
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
return getDateTimeFormat(locale, {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
|
||||
@@ -115,6 +115,7 @@ export function LabBiomarkerChart({
|
||||
key={r.key}
|
||||
size="sm"
|
||||
variant={range === r.key ? "secondary" : "ghost"}
|
||||
aria-pressed={range === r.key}
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setRange(r.key)}
|
||||
>
|
||||
|
||||
@@ -9,9 +9,17 @@ import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { FlaskConical, ListOrdered, Pencil, Plus } from "lucide-react";
|
||||
import {
|
||||
ArrowRight,
|
||||
FlaskConical,
|
||||
FolderOpen,
|
||||
ListOrdered,
|
||||
Pencil,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { DeleteButton } from "@/components/data-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -84,6 +92,7 @@ const READINGS_PAGE_SIZE = 200;
|
||||
* it. Mirrors the measurement-detail layout.
|
||||
*/
|
||||
export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -367,6 +376,22 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
catalog-less marker still carries a description. */}
|
||||
<p className="text-foreground text-sm leading-relaxed">{description}</p>
|
||||
|
||||
{/* Bridge to the source report PDFs. There is no per-value document
|
||||
relation in the data model yet, so this links to the vault filtered
|
||||
to lab-result documents rather than the exact scan behind one value.
|
||||
Only when the documents module is enabled. */}
|
||||
{user?.modules?.inboundDocuments ? (
|
||||
<Link
|
||||
href="/documents?kind=LAB_RESULT"
|
||||
data-slot="lab-biomarker-documents-link"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-md text-sm transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<FolderOpen className="size-4" aria-hidden="true" />
|
||||
{t("labs.documentsLink")}
|
||||
<ArrowRight className="size-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
|
||||
@@ -230,11 +230,17 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
<ChevronRight className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
</Link>
|
||||
) : (
|
||||
// Un-linked group (not backfilled to a catalog biomarker):
|
||||
// render inert but say so, so it doesn't read as a broken
|
||||
// link next to its clickable neighbours.
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{inner}
|
||||
<LabTrendSparkline
|
||||
values={group.readings.map((r) => r.value)}
|
||||
/>
|
||||
<span className="text-muted-foreground shrink-0 text-xs italic">
|
||||
{t("labs.notLinkedYet")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -319,6 +325,14 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
values={group.readings.map((r) => r.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* Un-linked group: the header renders no link, so name the
|
||||
reason rather than leaving a card that silently does
|
||||
nothing on tap. */}
|
||||
{!group.biomarkerId ? (
|
||||
<p className="text-muted-foreground text-xs italic">
|
||||
{t("labs.notLinkedYet")}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function LabTrendSparkline({
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="text-muted-foreground/70 overflow-visible"
|
||||
className="text-muted-foreground overflow-visible"
|
||||
aria-hidden
|
||||
role="presentation"
|
||||
>
|
||||
|
||||
@@ -17,20 +17,13 @@
|
||||
* ad-hoc (no `scheduledFor`); an exact slot match pins directly with no nudge.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { DateTimeField } from "@/components/ui/date-time-field";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -135,6 +128,7 @@ export function LedgerAddDialog({
|
||||
}: LedgerAddDialogProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const formId = useId();
|
||||
|
||||
const [takenAt, setTakenAt] = useState(() => toDateTimeLocal(new Date()));
|
||||
const [skipped, setSkipped] = useState(false);
|
||||
@@ -200,136 +194,127 @@ export function LedgerAddDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !busy && onOpenChange(o)}>
|
||||
<DialogContent data-slot="ledger-add-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("medications.detail.verlauf.addDialog.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.detail.verlauf.addDialog.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* v1.16.4 — a real form so Enter in the datetime field submits;
|
||||
the buttons carry explicit types so cancel never submits. The
|
||||
nested nudge dialog renders through a portal, so its buttons
|
||||
never land inside this form. */}
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ledger-add-taken-at">
|
||||
{t("medications.detail.verlauf.addDialog.takenAtLabel")}
|
||||
</Label>
|
||||
<DateTimeField
|
||||
id="ledger-add-taken-at"
|
||||
value={takenAt}
|
||||
max={toDateTimeLocal(new Date())}
|
||||
onChange={(value) => setTakenAt(value)}
|
||||
disabled={skipped}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label
|
||||
htmlFor="ledger-add-skipped"
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{t("medications.detail.verlauf.addDialog.skippedLabel")}
|
||||
</span>
|
||||
<Switch
|
||||
id="ledger-add-skipped"
|
||||
checked={skipped}
|
||||
onCheckedChange={setSkipped}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* A near-miss take previews where it will land before the user
|
||||
commits — calm, not alarming. */}
|
||||
{!skipped && classification === "nudge" && nearest && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="ledger-add-nudge-hint"
|
||||
>
|
||||
{t("medications.detail.verlauf.addDialog.nudgeHint", {
|
||||
slot: nearest.slotHm,
|
||||
})}
|
||||
</p>
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={(o) => !busy && onOpenChange(o)}
|
||||
title={t("medications.detail.verlauf.addDialog.title")}
|
||||
description={t("medications.detail.verlauf.addDialog.description")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("medications.detail.verlauf.addDialog.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={busy}
|
||||
aria-busy={busy || undefined}
|
||||
data-slot="ledger-add-submit"
|
||||
>
|
||||
{busy && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("medications.detail.verlauf.addDialog.submit")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* v1.16.4 — a real form so Enter in the datetime field submits;
|
||||
the buttons carry explicit types so cancel never submits. */}
|
||||
<form
|
||||
id={formId}
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ledger-add-taken-at">
|
||||
{t("medications.detail.verlauf.addDialog.takenAtLabel")}
|
||||
</Label>
|
||||
<DateTimeField
|
||||
id="ledger-add-taken-at"
|
||||
value={takenAt}
|
||||
max={toDateTimeLocal(new Date())}
|
||||
onChange={(value) => setTakenAt(value)}
|
||||
disabled={skipped}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("medications.detail.verlauf.addDialog.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
aria-busy={busy || undefined}
|
||||
data-slot="ledger-add-submit"
|
||||
>
|
||||
{busy && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("medications.detail.verlauf.addDialog.submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
{/* The "diesem Slot zuordnen?" nudge — pin onto the slot, or keep the
|
||||
take as a standalone ad-hoc entry. */}
|
||||
{nudgeOpen && nearest && (
|
||||
<Dialog
|
||||
open={nudgeOpen}
|
||||
onOpenChange={(o) => !busy && setNudgeOpen(o)}
|
||||
<label
|
||||
htmlFor="ledger-add-skipped"
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<DialogContent data-slot="ledger-add-nudge-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("medications.detail.verlauf.nudge.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.detail.verlauf.nudge.body", {
|
||||
slot: nearest.slotHm,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void write(false)}
|
||||
disabled={busy}
|
||||
data-slot="ledger-add-nudge-adhoc"
|
||||
>
|
||||
{t("medications.detail.verlauf.nudge.keepAdHoc")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void write(true)}
|
||||
disabled={busy}
|
||||
aria-busy={busy || undefined}
|
||||
data-slot="ledger-add-nudge-pin"
|
||||
>
|
||||
{busy && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("medications.detail.verlauf.nudge.pin")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<span className="text-sm font-medium">
|
||||
{t("medications.detail.verlauf.addDialog.skippedLabel")}
|
||||
</span>
|
||||
<Switch
|
||||
id="ledger-add-skipped"
|
||||
checked={skipped}
|
||||
onCheckedChange={setSkipped}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* A near-miss take previews where it will land before the user
|
||||
commits — calm, not alarming. */}
|
||||
{!skipped && classification === "nudge" && nearest && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="ledger-add-nudge-hint"
|
||||
>
|
||||
{t("medications.detail.verlauf.addDialog.nudgeHint", {
|
||||
slot: nearest.slotHm,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* The "diesem Slot zuordnen?" nudge — pin onto the slot, or keep the
|
||||
take as a standalone ad-hoc entry. */}
|
||||
{nudgeOpen && nearest && (
|
||||
<ResponsiveSheet
|
||||
open={nudgeOpen}
|
||||
onOpenChange={(o) => !busy && setNudgeOpen(o)}
|
||||
title={t("medications.detail.verlauf.nudge.title")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void write(false)}
|
||||
disabled={busy}
|
||||
data-slot="ledger-add-nudge-adhoc"
|
||||
>
|
||||
{t("medications.detail.verlauf.nudge.keepAdHoc")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void write(true)}
|
||||
disabled={busy}
|
||||
aria-busy={busy || undefined}
|
||||
data-slot="ledger-add-nudge-pin"
|
||||
>
|
||||
{busy && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("medications.detail.verlauf.nudge.pin")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.detail.verlauf.nudge.body", {
|
||||
slot: nearest.slotHm,
|
||||
})}
|
||||
</p>
|
||||
</ResponsiveSheet>
|
||||
)}
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -927,7 +927,7 @@ function DeleteRowConfirm({
|
||||
e.preventDefault();
|
||||
onConfirm();
|
||||
}}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
|
||||
>
|
||||
{t("medications.detail.intake.deleteRow.confirmAction")}
|
||||
</AlertDialogAction>
|
||||
|
||||
@@ -21,19 +21,13 @@
|
||||
* persisted it (audit LOW-10), so the dialog no longer pretends.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { DateTimeField } from "@/components/ui/date-time-field";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -107,6 +101,7 @@ function IntakeEditDialogBody({
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
const formId = useId();
|
||||
const [takenAt, setTakenAt] = useState(() =>
|
||||
toDateTimeLocal(event?.takenAt ?? null),
|
||||
);
|
||||
@@ -145,87 +140,92 @@ function IntakeEditDialogBody({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!event} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent data-slot="intake-edit-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("medications.detail.intake.edit.dialogTitle")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{/* v1.16.4 — a real form so Enter in the datetime field submits;
|
||||
<ResponsiveSheet
|
||||
open={!!event}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
title={t("medications.detail.intake.edit.dialogTitle")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("medications.detail.intake.edit.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={busy}
|
||||
aria-busy={busy || undefined}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("medications.detail.intake.edit.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* v1.16.4 — a real form so Enter in the datetime field submits;
|
||||
the buttons carry explicit types so cancel never submits. */}
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void save();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="intake-edit-taken-at">
|
||||
{t("medications.detail.intake.edit.takenAtLabel")}
|
||||
</Label>
|
||||
<DateTimeField
|
||||
id="intake-edit-taken-at"
|
||||
value={takenAt}
|
||||
max={toDateTimeLocal(new Date())}
|
||||
onChange={(value) => setTakenAt(value)}
|
||||
disabled={skipped}
|
||||
/>
|
||||
{scheduledFor && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="intake-edit-scheduled-hint"
|
||||
>
|
||||
{t("medications.detail.intake.edit.scheduledForHint", {
|
||||
dateTime: fmt.dateTime(scheduledFor),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{/* Non-blocking: a 48h+ gap to the slot is almost always a
|
||||
<form
|
||||
id={formId}
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void save();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="intake-edit-taken-at">
|
||||
{t("medications.detail.intake.edit.takenAtLabel")}
|
||||
</Label>
|
||||
<DateTimeField
|
||||
id="intake-edit-taken-at"
|
||||
value={takenAt}
|
||||
max={toDateTimeLocal(new Date())}
|
||||
onChange={(value) => setTakenAt(value)}
|
||||
disabled={skipped}
|
||||
/>
|
||||
{scheduledFor && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="intake-edit-scheduled-hint"
|
||||
>
|
||||
{t("medications.detail.intake.edit.scheduledForHint", {
|
||||
dateTime: fmt.dateTime(scheduledFor),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{/* Non-blocking: a 48h+ gap to the slot is almost always a
|
||||
date typo, but a deliberate far edit stays saveable. */}
|
||||
{showFarHint && (
|
||||
<p
|
||||
className="text-warning text-xs"
|
||||
data-slot="intake-edit-far-hint"
|
||||
>
|
||||
{t("medications.detail.intake.edit.farFromScheduledWarning")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<label
|
||||
htmlFor="intake-edit-skipped"
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{t("medications.detail.intake.edit.skippedLabel")}
|
||||
</span>
|
||||
<Switch
|
||||
id="intake-edit-skipped"
|
||||
checked={skipped}
|
||||
onCheckedChange={(checked) => setSkipped(checked)}
|
||||
/>
|
||||
</label>
|
||||
{showFarHint && (
|
||||
<p
|
||||
className="text-warning text-xs"
|
||||
data-slot="intake-edit-far-hint"
|
||||
>
|
||||
{t("medications.detail.intake.edit.farFromScheduledWarning")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("medications.detail.intake.edit.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={busy} aria-busy={busy || undefined}>
|
||||
{busy && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("medications.detail.intake.edit.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<label
|
||||
htmlFor="intake-edit-skipped"
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{t("medications.detail.intake.edit.skippedLabel")}
|
||||
</span>
|
||||
<Switch
|
||||
id="intake-edit-skipped"
|
||||
checked={skipped}
|
||||
onCheckedChange={(checked) => setSkipped(checked)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -228,7 +228,7 @@ export function IntakeHistoryEditable({
|
||||
}}
|
||||
disabled={bulkBusy}
|
||||
aria-busy={bulkBusy || undefined}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
|
||||
>
|
||||
{t("medications.detail.intake.bulkDelete.confirmAction")}
|
||||
</AlertDialogAction>
|
||||
@@ -262,7 +262,7 @@ export function IntakeHistoryEditable({
|
||||
}}
|
||||
disabled={rowDeleteBusy}
|
||||
aria-busy={rowDeleteBusy || undefined}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
|
||||
>
|
||||
{t("medications.detail.intake.deleteRow.confirmAction")}
|
||||
</AlertDialogAction>
|
||||
|
||||
@@ -22,12 +22,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, MoreHorizontal, RotateCcw, Upload } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -143,105 +138,104 @@ export function IntakeImportDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!medicationId} onOpenChange={() => handleClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("medications.importIntakes")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.importDescription")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="intake-import-file" className="text-xs font-medium">
|
||||
{t("medications.importUploadFile")}
|
||||
</Label>
|
||||
<input
|
||||
key={fileInputKey}
|
||||
id="intake-import-file"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleFileSelect}
|
||||
className="border-input bg-background text-foreground file:bg-muted file:text-foreground w-full cursor-pointer rounded-md border text-sm file:mr-2 file:border-0 file:px-3 file:py-2"
|
||||
/>
|
||||
{selectedFileName && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.importSelected", { name: selectedFileName })}
|
||||
</p>
|
||||
<ResponsiveSheet
|
||||
open={!!medicationId}
|
||||
onOpenChange={() => handleClose()}
|
||||
title={t("medications.importIntakes")}
|
||||
contentWidth="lg"
|
||||
footer={
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="mr-auto h-9 w-9"
|
||||
disabled={importing}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={resetImportForm}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t("common.reset")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={importing}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={importing || !jsonText.trim()}
|
||||
aria-busy={importing || undefined}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{importing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<pre className="bg-muted text-muted-foreground rounded-lg p-3 text-xs">
|
||||
{`[
|
||||
{t("common.import")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.importDescription")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="intake-import-file" className="text-xs font-medium">
|
||||
{t("medications.importUploadFile")}
|
||||
</Label>
|
||||
<input
|
||||
key={fileInputKey}
|
||||
id="intake-import-file"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleFileSelect}
|
||||
className="border-input bg-background text-foreground file:bg-muted file:text-foreground w-full cursor-pointer rounded-md border text-sm file:mr-2 file:border-0 file:px-3 file:py-2"
|
||||
/>
|
||||
{selectedFileName && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.importSelected", { name: selectedFileName })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<pre className="bg-muted text-muted-foreground rounded-lg p-3 text-xs">
|
||||
{`[
|
||||
{"datum": "2026-02-14", "uhrzeit": "10:27:43", "zaehler": 523},
|
||||
{"datum": "2026-02-14", "uhrzeit": "23:33:42", "zaehler": 524}
|
||||
]`}
|
||||
</pre>
|
||||
<Textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => setJsonText(e.target.value)}
|
||||
placeholder={t("medications.importPaste")}
|
||||
rows={8}
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
className="font-mono"
|
||||
/>
|
||||
{result && (
|
||||
<p
|
||||
className={`text-sm ${resultType === "success" ? "text-success" : "text-destructive"}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{result}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
disabled={importing}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={resetImportForm}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t("common.reset")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={importing}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={importing || !jsonText.trim()}
|
||||
aria-busy={importing || undefined}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{importing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
{t("common.import")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</pre>
|
||||
<Textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => setJsonText(e.target.value)}
|
||||
placeholder={t("medications.importPaste")}
|
||||
rows={8}
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
className="font-mono"
|
||||
/>
|
||||
{result && (
|
||||
<p
|
||||
className={`text-sm ${resultType === "success" ? "text-success" : "text-destructive"}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{result}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -4,14 +4,7 @@ import { useState } from "react";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InjectionSitePicker } from "@/components/medications/injection-site-picker";
|
||||
import {
|
||||
@@ -85,7 +78,7 @@ export function LogInjectionSiteDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
// Hold the dialog while the PATCH is in flight so a backdrop tap /
|
||||
@@ -93,76 +86,12 @@ export function LogInjectionSiteDialog({
|
||||
if (submitting) return;
|
||||
if (!next) onSkip();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("medications.logInjectionSiteTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.logInjectionSiteDescription", {
|
||||
name: medicationName,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{allowed.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
{t("medications.logInjectionSiteNoneAvailable")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<InjectionSitePicker
|
||||
value={selected}
|
||||
history={history}
|
||||
allowed={allowed}
|
||||
onChange={setSelected}
|
||||
/>
|
||||
{/* Marker legend — explains the two body-map annotations so the
|
||||
dashed primary ring and the amber ring read at a glance. The
|
||||
swatches mirror the picker's own marker strokes exactly. */}
|
||||
<ul
|
||||
className="text-muted-foreground flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs"
|
||||
aria-label={t("medications.injectionSiteLegendAriaLabel")}
|
||||
>
|
||||
<li className="flex items-center gap-1.5">
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="6"
|
||||
fill="none"
|
||||
className="stroke-primary"
|
||||
strokeWidth="1.6"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
</svg>
|
||||
{t("medications.injectionSiteLegendRecommended")}
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="6"
|
||||
fill="none"
|
||||
className="stroke-[var(--warning)]"
|
||||
strokeWidth="1.8"
|
||||
/>
|
||||
</svg>
|
||||
{t("medications.injectionSiteLegendLastUsed")}
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
title={t("medications.logInjectionSiteTitle")}
|
||||
description={t("medications.logInjectionSiteDescription", {
|
||||
name: medicationName,
|
||||
})}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={onSkip} disabled={submitting}>
|
||||
{t("medications.logInjectionSiteSkip")}
|
||||
</Button>
|
||||
@@ -178,8 +107,66 @@ export function LogInjectionSiteDialog({
|
||||
) : null}
|
||||
{t("medications.logInjectionSiteConfirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{allowed.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
{t("medications.logInjectionSiteNoneAvailable")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<InjectionSitePicker
|
||||
value={selected}
|
||||
history={history}
|
||||
allowed={allowed}
|
||||
onChange={setSelected}
|
||||
/>
|
||||
{/* Marker legend — explains the two body-map annotations so the
|
||||
dashed primary ring and the amber ring read at a glance. The
|
||||
swatches mirror the picker's own marker strokes exactly. */}
|
||||
<ul
|
||||
className="text-muted-foreground flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs"
|
||||
aria-label={t("medications.injectionSiteLegendAriaLabel")}
|
||||
>
|
||||
<li className="flex items-center gap-1.5">
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="6"
|
||||
fill="none"
|
||||
className="stroke-primary"
|
||||
strokeWidth="1.6"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
</svg>
|
||||
{t("medications.injectionSiteLegendRecommended")}
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="6"
|
||||
fill="none"
|
||||
className="stroke-[var(--warning)]"
|
||||
strokeWidth="1.8"
|
||||
/>
|
||||
</svg>
|
||||
{t("medications.injectionSiteLegendLastUsed")}
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -19,20 +19,13 @@
|
||||
* the form state + the medication/slot pickers.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { DateTimeField } from "@/components/ui/date-time-field";
|
||||
@@ -119,6 +112,7 @@ export function LogIntakeDialog({
|
||||
}: LogIntakeDialogProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const formId = useId();
|
||||
|
||||
const [medicationId, setMedicationId] = useState<string>(
|
||||
() => medications[0]?.id ?? "",
|
||||
@@ -208,147 +202,146 @@ export function LogIntakeDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !busy && onOpenChange(o)}>
|
||||
<DialogContent data-slot="log-intake-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("medications.logIntake.dialogTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.logIntake.dialogDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* v1.16.4 — a real form so Enter in the dose / datetime fields
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={(o) => !busy && onOpenChange(o)}
|
||||
title={t("medications.logIntake.dialogTitle")}
|
||||
description={t("medications.logIntake.dialogDescription")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("medications.logIntake.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={busy || medications.length === 0 || !medicationId}
|
||||
aria-busy={busy || undefined}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("medications.logIntake.submit")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* v1.16.4 — a real form so Enter in the dose / datetime fields
|
||||
submits; mirrors the intake-edit and dose-history-add dialogs. */}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (busy || medications.length === 0 || !medicationId) return;
|
||||
void submit();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
{medications.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-sm">
|
||||
{t("medications.logIntake.noMedications")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (busy || medications.length === 0 || !medicationId) return;
|
||||
void submit();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
{medications.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-sm">
|
||||
{t("medications.logIntake.noMedications")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="log-intake-medication">
|
||||
{t("medications.logIntake.medicationLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="log-intake-medication"
|
||||
value={medicationId}
|
||||
onChange={(e) => handleMedicationChange(e.target.value)}
|
||||
>
|
||||
{medications.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.dose ? `${m.name} — ${formatDose(m.dose, t)}` : m.name}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
{slotTimes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="log-intake-medication">
|
||||
{t("medications.logIntake.medicationLabel")}
|
||||
<Label htmlFor="log-intake-slot">
|
||||
{t("medications.logIntake.slotLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="log-intake-medication"
|
||||
value={medicationId}
|
||||
onChange={(e) => handleMedicationChange(e.target.value)}
|
||||
id="log-intake-slot"
|
||||
value={slot}
|
||||
onChange={(e) => {
|
||||
setSlot(e.target.value);
|
||||
// The configured baseline tracks the slot's schedule
|
||||
// dose, so an untouched field re-prefills on change.
|
||||
setDoseOverride(null);
|
||||
}}
|
||||
>
|
||||
{medications.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.dose ? `${m.name} — ${formatDose(m.dose, t)}` : m.name}
|
||||
<option value={NO_SLOT}>
|
||||
{t("medications.logIntake.slotNone")}
|
||||
</option>
|
||||
{slotTimes.map((time) => (
|
||||
<option key={time} value={time}>
|
||||
{time}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{slotTimes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="log-intake-slot">
|
||||
{t("medications.logIntake.slotLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="log-intake-slot"
|
||||
value={slot}
|
||||
onChange={(e) => {
|
||||
setSlot(e.target.value);
|
||||
// The configured baseline tracks the slot's schedule
|
||||
// dose, so an untouched field re-prefills on change.
|
||||
setDoseOverride(null);
|
||||
}}
|
||||
>
|
||||
<option value={NO_SLOT}>
|
||||
{t("medications.logIntake.slotNone")}
|
||||
</option>
|
||||
{slotTimes.map((time) => (
|
||||
<option key={time} value={time}>
|
||||
{time}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="log-intake-dose">
|
||||
{t("medications.logIntake.doseLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="log-intake-dose"
|
||||
value={dose}
|
||||
maxLength={50}
|
||||
onChange={(e) => setDoseOverride(e.target.value)}
|
||||
placeholder={configuredDose}
|
||||
autoComplete="off"
|
||||
disabled={skipped}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.logIntake.doseHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="log-intake-taken-at">
|
||||
{t("medications.logIntake.takenAtLabel")}
|
||||
</Label>
|
||||
<DateTimeField
|
||||
id="log-intake-taken-at"
|
||||
value={takenAt}
|
||||
max={toDateTimeLocal(new Date())}
|
||||
onChange={(value) => setTakenAt(value)}
|
||||
disabled={skipped}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.logIntake.takenAtHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label
|
||||
htmlFor="log-intake-skipped"
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{t("medications.logIntake.skippedLabel")}
|
||||
</span>
|
||||
<Switch
|
||||
id="log-intake-skipped"
|
||||
checked={skipped}
|
||||
onCheckedChange={setSkipped}
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="log-intake-dose">
|
||||
{t("medications.logIntake.doseLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="log-intake-dose"
|
||||
value={dose}
|
||||
maxLength={50}
|
||||
onChange={(e) => setDoseOverride(e.target.value)}
|
||||
placeholder={configuredDose}
|
||||
autoComplete="off"
|
||||
disabled={skipped}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.logIntake.doseHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={busy}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="log-intake-taken-at">
|
||||
{t("medications.logIntake.takenAtLabel")}
|
||||
</Label>
|
||||
<DateTimeField
|
||||
id="log-intake-taken-at"
|
||||
value={takenAt}
|
||||
max={toDateTimeLocal(new Date())}
|
||||
onChange={(value) => setTakenAt(value)}
|
||||
disabled={skipped}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.logIntake.takenAtHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label
|
||||
htmlFor="log-intake-skipped"
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
{t("medications.logIntake.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={busy || medications.length === 0 || !medicationId}
|
||||
aria-busy={busy || undefined}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("medications.logIntake.submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<span className="text-sm font-medium">
|
||||
{t("medications.logIntake.skippedLabel")}
|
||||
</span>
|
||||
<Switch
|
||||
id="log-intake-skipped"
|
||||
checked={skipped}
|
||||
onCheckedChange={setSkipped}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { MedicationCardBody } from "@/components/medications/card-parts/medicati
|
||||
import { useWeekdayLabel } from "@/components/medications/card-parts/medication-next-last-slot";
|
||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||
import { formatDateTime, formatTime } from "@/lib/format";
|
||||
import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
|
||||
import { formatDose } from "@/lib/medications/format-dose";
|
||||
import {
|
||||
@@ -335,7 +336,7 @@ export function MedicationCard({
|
||||
// Intentionally en-CA: gives YYYY-MM-DD which is locale-independent and
|
||||
// string-comparable for the today / yesterday / older bucketing below.
|
||||
// The actual user-facing display goes through formatTime / formatDateTime.
|
||||
const dayFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||
const dayFormatter = getDateTimeFormat("en-CA", {
|
||||
timeZone: userTz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
|
||||
@@ -327,7 +327,7 @@ export function MedicationTable({
|
||||
)
|
||||
) : (
|
||||
<ChevronsUpDown
|
||||
className="text-muted-foreground/60 size-3.5"
|
||||
className="text-muted-foreground size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,16 @@ import { Loader2, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
@@ -113,6 +123,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
// into the `<ResponsiveSheet>` footer slot. The submit button keeps
|
||||
// its `<form>` association via the HTML `form` attribute.
|
||||
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
const formId = useId();
|
||||
|
||||
const listKey = queryKeys.medicationSideEffects(medicationId);
|
||||
@@ -330,14 +341,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive size-11 shrink-0"
|
||||
aria-label={t("medications.sideEffects.deleteCta")}
|
||||
onClick={() => {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
window.confirm(t("medications.sideEffects.deleteConfirm"))
|
||||
) {
|
||||
deleteMutation.mutate(row.id);
|
||||
}
|
||||
}}
|
||||
onClick={() => setPendingDeleteId(row.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
@@ -348,6 +352,43 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Themed confirm — replaces the native window.confirm so the delete
|
||||
gate matches every other destructive action in the app. */}
|
||||
<AlertDialog
|
||||
open={!!pendingDeleteId}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setPendingDeleteId(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("medications.sideEffects.deleteConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("medications.sideEffects.deleteConfirmBody")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (pendingDeleteId) deleteMutation.mutate(pendingDeleteId);
|
||||
setPendingDeleteId(null);
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
aria-busy={deleteMutation.isPending || undefined}
|
||||
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
|
||||
>
|
||||
{t("medications.sideEffects.deleteCta")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
|
||||
@@ -19,14 +19,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckCheck, Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDose } from "@/lib/medications/format-dose";
|
||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||
@@ -65,49 +58,17 @@ export function TakeAllDueDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
// Refuse to close while the take loop is in flight.
|
||||
if (submitting && !next) return;
|
||||
onOpenChange(next);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("medications.takeAllDue.dialogTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.takeAllDue.dialogDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ul className="space-y-2" data-testid="take-all-due-list">
|
||||
{dueMedications.map((med) => (
|
||||
<li
|
||||
key={med.id}
|
||||
className="flex items-baseline justify-between gap-3 text-sm"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="font-medium">{med.name}</span>
|
||||
{med.dose ? (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
— {formatDose(med.dose, t)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{med.window ? (
|
||||
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
|
||||
{formatTimeWindowRange(
|
||||
med.window.start,
|
||||
med.window.end,
|
||||
locale,
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<DialogFooter>
|
||||
title={t("medications.takeAllDue.dialogTitle")}
|
||||
description={t("medications.takeAllDue.dialogDescription")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -136,8 +97,36 @@ export function TakeAllDueDialog({
|
||||
count: dueMedications.length,
|
||||
})}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ul className="space-y-2" data-testid="take-all-due-list">
|
||||
{dueMedications.map((med) => (
|
||||
<li
|
||||
key={med.id}
|
||||
className="flex items-baseline justify-between gap-3 text-sm"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="font-medium">{med.name}</span>
|
||||
{med.dose ? (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
— {formatDose(med.dose, t)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{med.window ? (
|
||||
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
|
||||
{formatTimeWindowRange(
|
||||
med.window.start,
|
||||
med.window.end,
|
||||
locale,
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -183,7 +183,7 @@ export function TitrationTimeline({
|
||||
? "border-primary bg-primary/15 text-primary"
|
||||
: step.isPast
|
||||
? "border-border bg-muted text-muted-foreground"
|
||||
: "border-border/60 text-muted-foreground/70 border-dashed",
|
||||
: "border-border text-muted-foreground border-dashed",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
|
||||
@@ -170,7 +170,15 @@ export function SourceCardGrid() {
|
||||
<li key={item.slug}>
|
||||
<Button asChild variant="outline" size="sm" className="min-h-9">
|
||||
<Link
|
||||
href="/settings/integrations"
|
||||
// Anchor each chip to its provider card so the tap lands on
|
||||
// the card, not the top of the generic Integrations page.
|
||||
// Apple Health has no web card (it syncs from the iOS app),
|
||||
// so its chip stays on the page root.
|
||||
href={
|
||||
item.slug === "apple-health"
|
||||
? "/settings/integrations"
|
||||
: `/settings/integrations#${item.slug}`
|
||||
}
|
||||
data-testid={`source-more-${item.slug}`}
|
||||
>
|
||||
{t(item.labelKey)}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { I18nProvider, useTranslations } from "@/lib/i18n/context";
|
||||
@@ -63,59 +64,91 @@ function applyTheme(resolved: "dark" | "light") {
|
||||
document.documentElement.classList.add(resolved);
|
||||
}
|
||||
|
||||
// ── Theme as an external store ───────────────────────
|
||||
//
|
||||
// The stored preference lives in localStorage, and the OS preference in
|
||||
// matchMedia — both browser-only values that must NOT be read in a `useState`
|
||||
// initializer (server renders "dark", a client with a stored "light"/"system"
|
||||
// initializes differently → the React #418 hydration seam this repo already
|
||||
// hit with the sidebar). `useSyncExternalStore` is the sanctioned fix: it reads
|
||||
// the value with an SSR-stable server snapshot ("dark") and reconciles to the
|
||||
// real value after hydration without a setState-in-effect. The nonce-bound
|
||||
// inline script in layout.tsx stamps the correct class pre-paint, so there is
|
||||
// no visual FOUC while React reconciles.
|
||||
|
||||
const THEME_STORAGE_KEY = "healthlog-theme";
|
||||
const themeListeners = new Set<() => void>();
|
||||
|
||||
function notifyThemeListeners() {
|
||||
for (const cb of themeListeners) cb();
|
||||
}
|
||||
|
||||
function subscribeStoredTheme(onChange: () => void): () => void {
|
||||
themeListeners.add(onChange);
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === THEME_STORAGE_KEY) onChange();
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
themeListeners.delete(onChange);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
}
|
||||
|
||||
function getStoredThemeSnapshot(): Theme {
|
||||
const saved = localStorage.getItem(THEME_STORAGE_KEY) as Theme | null;
|
||||
// A stored "light"/"dark"/"system" choice wins. With no stored preference the
|
||||
// app defaults to dark rather than tracking the OS.
|
||||
if (saved === "light" || saved === "dark" || saved === "system") return saved;
|
||||
return "dark";
|
||||
}
|
||||
|
||||
function getServerThemeSnapshot(): Theme {
|
||||
return "dark";
|
||||
}
|
||||
|
||||
function subscribeSystemTheme(onChange: () => void): () => void {
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}
|
||||
|
||||
function getSystemThemeSnapshot(): "dark" | "light" {
|
||||
return getSystemTheme();
|
||||
}
|
||||
|
||||
function getServerSystemSnapshot(): "dark" | "light" {
|
||||
return "dark";
|
||||
}
|
||||
|
||||
function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
if (typeof window === "undefined") return "dark";
|
||||
const saved = localStorage.getItem("healthlog-theme") as Theme | null;
|
||||
// A stored "light"/"dark"/"system" choice wins. With no stored
|
||||
// preference the app defaults to dark rather than tracking the OS.
|
||||
if (saved === "light" || saved === "dark") return saved;
|
||||
if (saved === "system") return "system";
|
||||
return "dark";
|
||||
});
|
||||
const theme = useSyncExternalStore(
|
||||
subscribeStoredTheme,
|
||||
getStoredThemeSnapshot,
|
||||
getServerThemeSnapshot,
|
||||
);
|
||||
const systemTheme = useSyncExternalStore(
|
||||
subscribeSystemTheme,
|
||||
getSystemThemeSnapshot,
|
||||
getServerSystemSnapshot,
|
||||
);
|
||||
const resolvedTheme = theme === "system" ? systemTheme : theme;
|
||||
|
||||
const [resolvedTheme, setResolvedTheme] = useState<"dark" | "light">(() => {
|
||||
if (typeof window === "undefined") return "dark";
|
||||
const saved = localStorage.getItem("healthlog-theme") as Theme | null;
|
||||
if (saved === "light" || saved === "dark") return saved;
|
||||
if (saved === "system") return getSystemTheme();
|
||||
return "dark";
|
||||
});
|
||||
|
||||
// Apply theme on mount (inline script handles FOUC, this ensures classes are in sync)
|
||||
// Keep the <html> class in sync with the resolved theme. This is a DOM write,
|
||||
// not a setState — it runs on mount (reconciling the class the inline script
|
||||
// set to any stored preference) and whenever the resolved theme changes.
|
||||
useEffect(() => {
|
||||
applyTheme(resolvedTheme);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Listen for OS preference changes when in system mode
|
||||
useEffect(() => {
|
||||
if (theme !== "system") return;
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
const resolved = e.matches ? "dark" : "light";
|
||||
setResolvedTheme(resolved);
|
||||
applyTheme(resolved);
|
||||
};
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, [theme]);
|
||||
}, [resolvedTheme]);
|
||||
|
||||
const setTheme = useCallback((next: Theme) => {
|
||||
setThemeState(next);
|
||||
// Persist every explicit choice, including "system" — the absence of a
|
||||
// stored value is reserved for a fresh visitor and now defaults to dark.
|
||||
// stored value is reserved for a fresh visitor and defaults to dark.
|
||||
// Storing "system" verbatim keeps an explicit OS-tracking choice
|
||||
// distinguishable from "never chose", so a reload honours it.
|
||||
localStorage.setItem("healthlog-theme", next);
|
||||
if (next === "system") {
|
||||
const resolved = getSystemTheme();
|
||||
setResolvedTheme(resolved);
|
||||
applyTheme(resolved);
|
||||
} else {
|
||||
setResolvedTheme(next);
|
||||
applyTheme(next);
|
||||
}
|
||||
// distinguishable from "never chose", so a reload honours it. The write +
|
||||
// notify re-reads the external store; the effect above applies the class.
|
||||
localStorage.setItem(THEME_STORAGE_KEY, next);
|
||||
notifyThemeListeners();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -21,12 +21,14 @@
|
||||
* `connections-panel.tsx` so existing unit-test imports keep resolving.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { Link2, Send } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ConnectionsPanel } from "@/components/settings/integrations/connections-panel";
|
||||
import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export {
|
||||
parseOAuthOutcome,
|
||||
@@ -65,6 +67,15 @@ export function IntegrationsSection() {
|
||||
title={t("settings.sections.integrations.channelsHeading")}
|
||||
description={t("settings.sections.integrations.channelsDescription")}
|
||||
/>
|
||||
{/* Names the per-event notification matrix so the three notification
|
||||
surfaces (matrix, reminder types, channels) cross-reference. */}
|
||||
<div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/notifications">
|
||||
{t("settings.sections.integrations.matrixLink")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<NotificationChannelsPanel />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -262,13 +262,29 @@ export function ConnectionsPanel() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="connections-panel">
|
||||
<WithingsCard viewModel={withingsViewModel} />
|
||||
<WhoopCard viewModel={whoopViewModel} />
|
||||
<FitbitCard viewModel={fitbitViewModel} />
|
||||
<GoogleHealthCard viewModel={googleHealthViewModel} />
|
||||
<PolarCard enabled={isAuthenticated} viewModel={polarViewModel} />
|
||||
<OuraCard enabled={isAuthenticated} viewModel={ouraViewModel} />
|
||||
<NightscoutCard enabled={isAuthenticated} />
|
||||
{/* Per-provider anchors so deep-links (onboarding "more sources" chips,
|
||||
`/settings/integrations#oura`) scroll straight to the right card. */}
|
||||
<div id="withings" className="scroll-mt-28">
|
||||
<WithingsCard viewModel={withingsViewModel} />
|
||||
</div>
|
||||
<div id="whoop" className="scroll-mt-28">
|
||||
<WhoopCard viewModel={whoopViewModel} />
|
||||
</div>
|
||||
<div id="fitbit" className="scroll-mt-28">
|
||||
<FitbitCard viewModel={fitbitViewModel} />
|
||||
</div>
|
||||
<div id="google-health" className="scroll-mt-28">
|
||||
<GoogleHealthCard viewModel={googleHealthViewModel} />
|
||||
</div>
|
||||
<div id="polar" className="scroll-mt-28">
|
||||
<PolarCard enabled={isAuthenticated} viewModel={polarViewModel} />
|
||||
</div>
|
||||
<div id="oura" className="scroll-mt-28">
|
||||
<OuraCard enabled={isAuthenticated} viewModel={ouraViewModel} />
|
||||
</div>
|
||||
<div id="nightscout" className="scroll-mt-28">
|
||||
<NightscoutCard enabled={isAuthenticated} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -228,7 +228,7 @@ export function WithingsCard({
|
||||
<a
|
||||
href="/api/withings/connect"
|
||||
data-testid="withings-reconnect-banner"
|
||||
className="border-warning/30 bg-warning/10 text-warning-foreground hover:bg-warning/20 block rounded-md border px-3 py-2 text-sm transition-colors"
|
||||
className="border-warning/30 bg-warning/10 text-foreground hover:bg-warning/20 block rounded-md border px-3 py-2 text-sm transition-colors"
|
||||
>
|
||||
<span className="font-medium">
|
||||
{t("settings.integrations.withings.reconnect.banner.title")}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Bell } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -7,6 +8,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { LowStockCard } from "@/components/settings/low-stock-card";
|
||||
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* `<NotificationsSection>` — Settings → Notifications ("Benachrichtigungen").
|
||||
@@ -44,6 +46,24 @@ export function NotificationsSection() {
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{/* Cross-links to the other two notification surfaces so this screen
|
||||
names them — the per-event matrix and the delivery channels. */}
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
data-slot="notifications-crosslinks"
|
||||
>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/notifications">
|
||||
{t("settings.sections.notifications.matrixLink")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/settings/integrations#channels">
|
||||
{t("settings.sections.notifications.channelsLink")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Group 1 — Reminders ("what you receive"). Rendered only when at
|
||||
least one reminder-type card is visible, so a fully-disabled
|
||||
account never shows an empty heading. */}
|
||||
|
||||
@@ -204,7 +204,7 @@ function ShareLinksCard() {
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
|
||||
onClick={() => revokeMutation.mutate(link.id)}
|
||||
>
|
||||
{t("settings.sharing.revoke")}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { PasswordInput } from "../password-input";
|
||||
|
||||
/**
|
||||
* PasswordInput bakes the password-manager autofill guard in by default so
|
||||
* BYOK API keys / webhook secrets / ntfy tokens (config secrets, not
|
||||
* credentials) are never offered up to Bitwarden / 1Password / LastPass.
|
||||
* Credential fields that pass a real autocomplete token keep manager
|
||||
* integration.
|
||||
*/
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("PasswordInput", () => {
|
||||
it("suppresses password managers by default for config secrets", () => {
|
||||
const html = render(<PasswordInput id="ntfy-token" />);
|
||||
expect(html).toMatch(/autocomplete="off"/i);
|
||||
expect(html).toContain("data-bwignore");
|
||||
expect(html).toContain("data-1p-ignore");
|
||||
expect(html).toContain('data-lpignore="true"');
|
||||
});
|
||||
|
||||
it("treats an explicit autoComplete=off as a config secret too", () => {
|
||||
const html = render(<PasswordInput id="tg-token" autoComplete="off" />);
|
||||
expect(html).toContain("data-bwignore");
|
||||
expect(html).toContain("data-1p-ignore");
|
||||
});
|
||||
|
||||
it("keeps manager integration for credential fields", () => {
|
||||
const html = render(
|
||||
<PasswordInput id="current-password" autoComplete="current-password" />,
|
||||
);
|
||||
expect(html).toMatch(/autocomplete="current-password"/i);
|
||||
expect(html).not.toContain("data-bwignore");
|
||||
expect(html).not.toContain("data-1p-ignore");
|
||||
expect(html).not.toContain("data-lpignore");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Auth-resolving placeholder for a top-level page.
|
||||
*
|
||||
* Every module page renders while `useAuth()` / its first query settles. The
|
||||
* legacy pattern was a lone centered `Loader2` that then snapped into the full
|
||||
* layout — a reflow, and inconsistent with the skeleton-first surfaces
|
||||
* (documents, Insights tiles, dashboard hero). This reserves the page frame
|
||||
* instead: a `PageHeader`-shaped title/description block above a tile grid, so
|
||||
* the auth-resolving frame holds the same geometry the loaded page will.
|
||||
*
|
||||
* Presentational + `aria-hidden` (matching the old spinner, which announced
|
||||
* nothing); pass `label` to expose an `sr-only` status line for screen readers.
|
||||
*/
|
||||
export function PageAuthGate({
|
||||
className,
|
||||
label,
|
||||
tiles = 3,
|
||||
}: {
|
||||
className?: string;
|
||||
label?: string;
|
||||
/** Number of content tile skeletons below the header. */
|
||||
tiles?: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="page-auth-gate"
|
||||
className={cn("space-y-6", className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{label ? (
|
||||
<span className="sr-only" aria-live="polite" aria-hidden={false}>
|
||||
{label}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-8 w-48 max-w-full rounded" />
|
||||
<Skeleton className="h-4 w-72 max-w-full rounded" />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: tiles }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-32 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,14 +22,28 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
*/
|
||||
export function PasswordInput({
|
||||
className,
|
||||
autoComplete,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Config/secret fields (BYOK API keys, webhook secrets, ntfy tokens) pass no
|
||||
// `autoComplete` — they are not credentials, so a password manager offering
|
||||
// to fill or *save* them is noise and risks clobbering the field. Suppress
|
||||
// Bitwarden / 1Password / LastPass by default for those. Credential fields
|
||||
// (login, register, password change) pass a real token
|
||||
// (`current-password` / `new-password` / `username`) and keep manager
|
||||
// integration; only an explicit `"off"` opts back into suppression.
|
||||
const suppressManagers = autoComplete == null || autoComplete === "off";
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...props}
|
||||
autoComplete={autoComplete ?? "off"}
|
||||
data-bwignore={suppressManagers ? "" : undefined}
|
||||
data-1p-ignore={suppressManagers ? "" : undefined}
|
||||
data-lpignore={suppressManagers ? "true" : undefined}
|
||||
type={visible ? "text" : "password"}
|
||||
className={cn("pr-12", className)}
|
||||
/>
|
||||
|
||||
@@ -40,7 +40,7 @@ export function QueryErrorCard({
|
||||
className="flex flex-col items-center justify-center gap-3 px-6 py-10 text-center"
|
||||
>
|
||||
<AlertTriangle
|
||||
className="text-muted-foreground/60 h-8 w-8"
|
||||
className="text-muted-foreground h-8 w-8"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-sm font-medium">{title ?? t("common.loadFailed")}</p>
|
||||
|
||||
@@ -3,6 +3,18 @@ import type { Instrumentation } from "next";
|
||||
export async function register() {
|
||||
// Only start the worker on the Node.js server runtime (not Edge, not build)
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
// First-boot readiness summary: a human-readable block on the core secrets
|
||||
// (with the exact fix on a bad one) before any fail-closed loader throws,
|
||||
// plus a one-glance status of the optional channels. Runs for every process
|
||||
// type (web + worker). Never throws — the real gate stays the loaders.
|
||||
try {
|
||||
const { logReadinessSummary } =
|
||||
await import("@/lib/boot/readiness-summary");
|
||||
logReadinessSummary();
|
||||
} catch {
|
||||
// A readiness summary must never break boot.
|
||||
}
|
||||
|
||||
// Every Node process watches its own event loop — web-only
|
||||
// containers included; a stall report from the process serving
|
||||
// requests is the whole point.
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
|
||||
import { shouldEmitSecureCookie } from "../secure-cookie";
|
||||
import {
|
||||
shouldEmitSecureCookie,
|
||||
detectInsecureCookieTransport,
|
||||
} from "../secure-cookie";
|
||||
|
||||
function req(headers: Record<string, string>, url = "http://10.0.0.5:3000/x") {
|
||||
return { headers: new Headers(headers), url };
|
||||
}
|
||||
|
||||
describe("shouldEmitSecureCookie", () => {
|
||||
afterEach(() => {
|
||||
@@ -47,3 +54,56 @@ describe("shouldEmitSecureCookie", () => {
|
||||
expect(shouldEmitSecureCookie()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectInsecureCookieTransport", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("returns null when the Secure flag is off (SESSION_COOKIE_SECURE=false)", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "false");
|
||||
expect(detectInsecureCookieTransport(req({}))).toBeNull();
|
||||
});
|
||||
|
||||
it("warns when a Secure cookie will be set on a plain-HTTP request (no proxy)", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "");
|
||||
const msg = detectInsecureCookieTransport(req({}));
|
||||
expect(msg).toContain("SESSION_COOKIE_SECURE=false");
|
||||
});
|
||||
|
||||
it("returns null behind a TLS proxy that forwards X-Forwarded-Proto: https", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "");
|
||||
expect(
|
||||
detectInsecureCookieTransport(req({ "x-forwarded-proto": "https" })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("reads the left-most entry of a comma-separated proto chain", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "");
|
||||
expect(
|
||||
detectInsecureCookieTransport(
|
||||
req({ "x-forwarded-proto": "https, http" }),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("warns when the proxy forwards X-Forwarded-Proto: http explicitly", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "");
|
||||
expect(
|
||||
detectInsecureCookieTransport(req({ "x-forwarded-proto": "http" })),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the request URL is already https", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "");
|
||||
expect(
|
||||
detectInsecureCookieTransport(req({}, "https://healthlog.example.com/x")),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
} from "@/lib/auth/native-client";
|
||||
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
|
||||
import { recordSignInDevice } from "@/lib/auth/login-alert";
|
||||
import { detectInsecureCookieTransport } from "@/lib/auth/secure-cookie";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { User } from "@/generated/prisma/client";
|
||||
|
||||
export interface FinishLoginParams {
|
||||
@@ -116,8 +118,20 @@ export async function finishLogin(
|
||||
});
|
||||
}
|
||||
|
||||
// Browser path — session cookie. `createSession` anchors the onboarding
|
||||
// cookie itself; thread the user's onboarding state through.
|
||||
// Browser path — session cookie. Before minting it, surface the single
|
||||
// most common self-host snag: a Secure cookie about to be set on a plain-HTTP
|
||||
// request drops silently and loops the login. Warn-log + wide-event so an
|
||||
// operator sees the cause; the login still completes.
|
||||
const transportWarning = detectInsecureCookieTransport(request);
|
||||
if (transportWarning) {
|
||||
console.warn(
|
||||
`[auth] session-cookie transport mismatch: ${transportWarning}`,
|
||||
);
|
||||
annotate({ meta: { session_cookie_secure_transport_mismatch: true } });
|
||||
}
|
||||
|
||||
// `createSession` anchors the onboarding cookie itself; thread the user's
|
||||
// onboarding state through.
|
||||
await createSession(
|
||||
user.id,
|
||||
user.onboardingCompletedAt == null,
|
||||
|
||||
@@ -28,3 +28,57 @@ export function shouldEmitSecureCookie(): boolean {
|
||||
if (override === "true") return true;
|
||||
return process.env.NODE_ENV === "production";
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line diagnostic for the single most common self-host login-loop cause:
|
||||
* the app is about to set a `Secure` session cookie
|
||||
* (`shouldEmitSecureCookie() === true`) while the request itself arrived over
|
||||
* plain HTTP. The browser silently drops a `Secure` cookie on an `http://`
|
||||
* origin, so the next request is unauthenticated and the user bounces back to
|
||||
* the login page with no error at all — it reads like a wrong password forever.
|
||||
*
|
||||
* Returns a human-readable message when the mismatch is detected, else `null`.
|
||||
* The caller logs it at login time (`console.warn` survives the prod
|
||||
* `console.*` strip) so an operator sees WHY login loops without having to find
|
||||
* the note buried in three docs. Non-fatal: the login still completes; the
|
||||
* cookie just won't stick until `SESSION_COOKIE_SECURE=false` is set (or TLS is
|
||||
* fronted).
|
||||
*
|
||||
* A request counts as HTTPS when `x-forwarded-proto` names https (the shape
|
||||
* every documented TLS reverse proxy sets) OR the request URL is already
|
||||
* https. A proxy that terminates TLS but omits `x-forwarded-proto` can produce
|
||||
* a benign false positive — the message names that case so it stays actionable
|
||||
* either way.
|
||||
*/
|
||||
export function detectInsecureCookieTransport(req: {
|
||||
headers: Headers;
|
||||
url: string;
|
||||
}): string | null {
|
||||
if (!shouldEmitSecureCookie()) return null;
|
||||
|
||||
// `x-forwarded-proto` can be a comma-separated proto chain; the left-most
|
||||
// entry is the client-facing scheme the browser actually spoke.
|
||||
const clientProto = req.headers
|
||||
.get("x-forwarded-proto")
|
||||
?.split(",")[0]
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
if (clientProto === "https") return null;
|
||||
|
||||
let urlIsHttps = false;
|
||||
try {
|
||||
urlIsHttps = new URL(req.url).protocol === "https:";
|
||||
} catch {
|
||||
urlIsHttps = false;
|
||||
}
|
||||
if (urlIsHttps) return null;
|
||||
|
||||
return (
|
||||
"This request arrived over plain HTTP but the session cookie is being " +
|
||||
"issued with the `Secure` flag — the browser will drop it and login will " +
|
||||
"loop back to the sign-in page with no error message. If you serve HTTP " +
|
||||
"directly (LAN / NAS / Tailscale, no TLS reverse proxy), set " +
|
||||
"SESSION_COOKIE_SECURE=false. If a reverse proxy terminates TLS in front " +
|
||||
"of this container, make sure it forwards `X-Forwarded-Proto: https`."
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
collectReadiness,
|
||||
formatReadiness,
|
||||
logReadinessSummary,
|
||||
} from "../readiness-summary";
|
||||
|
||||
const HEX32 =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
function baseEnv(): Record<string, string | undefined> {
|
||||
return {
|
||||
ENCRYPTION_KEY: HEX32,
|
||||
API_TOKEN_HMAC_KEY: HEX32,
|
||||
DATABASE_URL: "postgresql://healthlog:pw@db:5432/healthlog",
|
||||
NODE_ENV: "production",
|
||||
};
|
||||
}
|
||||
|
||||
function lineFor(env: Record<string, string | undefined>, label: string) {
|
||||
return collectReadiness(env).lines.find((l) => l.label === label);
|
||||
}
|
||||
|
||||
describe("collectReadiness — core secrets", () => {
|
||||
it("reports no blocker when the three core secrets are valid", () => {
|
||||
const report = collectReadiness(baseEnv());
|
||||
expect(report.hasBlocker).toBe(false);
|
||||
expect(lineFor(baseEnv(), "ENCRYPTION_KEY")?.status).toBe("ok");
|
||||
expect(lineFor(baseEnv(), "API_TOKEN_HMAC_KEY")?.status).toBe("ok");
|
||||
expect(lineFor(baseEnv(), "DATABASE_URL")?.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("flags a missing ENCRYPTION_KEY as a blocker with the openssl fix", () => {
|
||||
const env = baseEnv();
|
||||
delete env.ENCRYPTION_KEY;
|
||||
const report = collectReadiness(env);
|
||||
expect(report.hasBlocker).toBe(true);
|
||||
const line = report.lines.find((l) => l.label === "ENCRYPTION_KEY");
|
||||
expect(line?.status).toBe("error");
|
||||
expect(line?.detail).toContain("openssl rand -hex 32");
|
||||
});
|
||||
|
||||
it("flags a too-short ENCRYPTION_KEY as a blocker", () => {
|
||||
const env = baseEnv();
|
||||
env.ENCRYPTION_KEY = "deadbeef";
|
||||
expect(collectReadiness(env).hasBlocker).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts the ENCRYPTION_KEYS rotation map instead of the single key", () => {
|
||||
const env = baseEnv();
|
||||
delete env.ENCRYPTION_KEY;
|
||||
env.ENCRYPTION_KEYS = JSON.stringify({ v1: HEX32, v2: HEX32 });
|
||||
const report = collectReadiness(env);
|
||||
expect(report.hasBlocker).toBe(false);
|
||||
expect(
|
||||
report.lines.find((l) => l.label === "ENCRYPTION_KEYS")?.status,
|
||||
).toBe("ok");
|
||||
});
|
||||
|
||||
it("flags a malformed ENCRYPTION_KEYS map", () => {
|
||||
const env = baseEnv();
|
||||
delete env.ENCRYPTION_KEY;
|
||||
env.ENCRYPTION_KEYS = '{"v1":"nothex"}';
|
||||
expect(collectReadiness(env).hasBlocker).toBe(true);
|
||||
});
|
||||
|
||||
it("flags a missing DATABASE_URL", () => {
|
||||
const env = baseEnv();
|
||||
delete env.DATABASE_URL;
|
||||
expect(collectReadiness(env).hasBlocker).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectReadiness — transport + optional", () => {
|
||||
it("warns Secure-on when NODE_ENV=production and no override", () => {
|
||||
const line = lineFor(baseEnv(), "SESSION_COOKIE_SECURE");
|
||||
expect(line?.detail).toContain("Secure cookie flag ON");
|
||||
});
|
||||
|
||||
it("notes Secure-off when SESSION_COOKIE_SECURE=false", () => {
|
||||
const env = { ...baseEnv(), SESSION_COOKIE_SECURE: "false" };
|
||||
expect(lineFor(env, "SESSION_COOKIE_SECURE")?.detail).toContain(
|
||||
"Secure cookie flag OFF",
|
||||
);
|
||||
});
|
||||
|
||||
it("marks optional subsystems as warn when unconfigured", () => {
|
||||
const smtp = lineFor(baseEnv(), "SMTP (email)");
|
||||
expect(smtp?.status).toBe("warn");
|
||||
});
|
||||
|
||||
it("marks off-host backup ok only when every required var is set", () => {
|
||||
const env = {
|
||||
...baseEnv(),
|
||||
BACKUP_S3_ENDPOINT: "https://r2",
|
||||
BACKUP_S3_BUCKET: "b",
|
||||
BACKUP_S3_ACCESS_KEY: "a",
|
||||
BACKUP_S3_SECRET_KEY: "s",
|
||||
BACKUP_ENCRYPTION_KEY: HEX32,
|
||||
};
|
||||
expect(lineFor(env, "Off-host backup")?.status).toBe("ok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatReadiness + logReadinessSummary", () => {
|
||||
it("renders a header that flags a blocker", () => {
|
||||
const env = baseEnv();
|
||||
delete env.API_TOKEN_HMAC_KEY;
|
||||
const text = formatReadiness(collectReadiness(env));
|
||||
expect(text).toContain("CORE SECRET MISSING/INVALID");
|
||||
expect(text).toContain("✗ API_TOKEN_HMAC_KEY");
|
||||
});
|
||||
|
||||
it("logs blockers to error and healthy summaries to log; never throws", () => {
|
||||
const errors: string[] = [];
|
||||
const logs: string[] = [];
|
||||
const sink = {
|
||||
error: (m: string) => errors.push(m),
|
||||
log: (m: string) => logs.push(m),
|
||||
};
|
||||
logReadinessSummary(baseEnv(), sink);
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(errors).toHaveLength(0);
|
||||
|
||||
const bad = baseEnv();
|
||||
delete bad.ENCRYPTION_KEY;
|
||||
logReadinessSummary(bad, sink);
|
||||
expect(errors).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* First-boot readiness summary.
|
||||
*
|
||||
* The bundled image promises "single `docker compose up`", but the reality is
|
||||
* three hand-generated secrets plus a handful of optional subsystems — and the
|
||||
* crypto loader is fail-closed, so a missing/short `ENCRYPTION_KEY` throws a
|
||||
* stack trace on first use rather than a sentence an operator can act on. This
|
||||
* module inspects the environment ONCE at boot and prints a compact,
|
||||
* human-readable readiness block: a clear line per core secret (with the exact
|
||||
* `openssl` fix on a bad one) before anything throws, plus a one-glance status
|
||||
* of the optional channels.
|
||||
*
|
||||
* It NEVER logs a secret value — only presence + shape validity. It never
|
||||
* throws: the fail-closed loaders remain the real gate; this is a friendlier
|
||||
* signpost in front of them.
|
||||
*/
|
||||
|
||||
export interface ReadinessLine {
|
||||
/** Short label, e.g. "ENCRYPTION_KEY". */
|
||||
label: string;
|
||||
/** ok = green, warn = optional-not-configured, error = missing/invalid core. */
|
||||
status: "ok" | "warn" | "error";
|
||||
/** One-line human detail (no secret material). */
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface ReadinessReport {
|
||||
lines: ReadinessLine[];
|
||||
/** True when at least one core secret is missing or malformed. */
|
||||
hasBlocker: boolean;
|
||||
}
|
||||
|
||||
type Env = Record<string, string | undefined>;
|
||||
|
||||
/** A 64-char hex string (= 32 bytes) or a base64 string that decodes to 32 bytes. */
|
||||
function isValid32ByteKey(raw: string | undefined): boolean {
|
||||
if (!raw) return false;
|
||||
const v = raw.trim();
|
||||
if (/^[0-9a-fA-F]{64}$/.test(v)) return true;
|
||||
if (/^[A-Za-z0-9+/=]+$/.test(v)) {
|
||||
try {
|
||||
return Buffer.from(v, "base64").length === 32;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function allSet(env: Env, keys: string[]): boolean {
|
||||
return keys.every((k) => (env[k] ?? "").trim().length > 0);
|
||||
}
|
||||
|
||||
function anySet(env: Env, keys: string[]): boolean {
|
||||
return keys.some((k) => (env[k] ?? "").trim().length > 0);
|
||||
}
|
||||
|
||||
/** Inspect the environment and build the readiness report. Pure + side-effect free. */
|
||||
export function collectReadiness(env: Env = process.env): ReadinessReport {
|
||||
const lines: ReadinessLine[] = [];
|
||||
|
||||
// --- Core secrets (boot blockers) ---------------------------------------
|
||||
// Encryption: the map form (ENCRYPTION_KEYS) supersedes the single key.
|
||||
const keysMap = (env.ENCRYPTION_KEYS ?? "").trim();
|
||||
let encryptionOk = false;
|
||||
if (keysMap) {
|
||||
try {
|
||||
const parsed = JSON.parse(keysMap) as Record<string, string>;
|
||||
const entries = Object.values(parsed ?? {});
|
||||
encryptionOk =
|
||||
entries.length > 0 && entries.every((v) => isValid32ByteKey(v));
|
||||
} catch {
|
||||
encryptionOk = false;
|
||||
}
|
||||
lines.push({
|
||||
label: "ENCRYPTION_KEYS",
|
||||
status: encryptionOk ? "ok" : "error",
|
||||
detail: encryptionOk
|
||||
? "key-rotation map present and valid"
|
||||
: "ENCRYPTION_KEYS is not a JSON map of valid 32-byte keys",
|
||||
});
|
||||
} else {
|
||||
encryptionOk = isValid32ByteKey(env.ENCRYPTION_KEY);
|
||||
lines.push({
|
||||
label: "ENCRYPTION_KEY",
|
||||
status: encryptionOk ? "ok" : "error",
|
||||
detail: encryptionOk
|
||||
? "present (64 hex chars)"
|
||||
: "missing or not 64 hex chars — generate with: openssl rand -hex 32",
|
||||
});
|
||||
}
|
||||
|
||||
const hmacOk = isValid32ByteKey(env.API_TOKEN_HMAC_KEY);
|
||||
lines.push({
|
||||
label: "API_TOKEN_HMAC_KEY",
|
||||
status: hmacOk ? "ok" : "error",
|
||||
detail: hmacOk
|
||||
? "present (64 hex chars)"
|
||||
: "missing or not 64 hex chars — generate with: openssl rand -hex 32",
|
||||
});
|
||||
|
||||
const dbOk = (env.DATABASE_URL ?? "").trim().length > 0;
|
||||
lines.push({
|
||||
label: "DATABASE_URL",
|
||||
status: dbOk ? "ok" : "error",
|
||||
detail: dbOk
|
||||
? "present"
|
||||
: "missing — the bundled compose derives it from POSTGRES_PASSWORD",
|
||||
});
|
||||
|
||||
// --- Transport hint (a warning, never a blocker) ------------------------
|
||||
const secureOverride = (env.SESSION_COOKIE_SECURE ?? "").trim().toLowerCase();
|
||||
const secureFlagOn =
|
||||
secureOverride === "true" ||
|
||||
(secureOverride !== "false" && env.NODE_ENV === "production");
|
||||
lines.push({
|
||||
label: "SESSION_COOKIE_SECURE",
|
||||
status: "ok",
|
||||
detail: secureFlagOn
|
||||
? "Secure cookie flag ON — serve HTTPS, or set SESSION_COOKIE_SECURE=false for a plain-HTTP LAN host"
|
||||
: "Secure cookie flag OFF — fine for plain-HTTP LAN; do NOT expose plain HTTP to the internet",
|
||||
});
|
||||
|
||||
// --- Optional subsystems (informational) --------------------------------
|
||||
const optional: Array<[string, boolean]> = [
|
||||
["SMTP (email)", allSet(env, ["SMTP_HOST", "SMTP_PORT", "SMTP_FROM"])],
|
||||
[
|
||||
"Off-host backup",
|
||||
allSet(env, [
|
||||
"BACKUP_S3_ENDPOINT",
|
||||
"BACKUP_S3_BUCKET",
|
||||
"BACKUP_S3_ACCESS_KEY",
|
||||
"BACKUP_S3_SECRET_KEY",
|
||||
"BACKUP_ENCRYPTION_KEY",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"APNs (native iOS push)",
|
||||
allSet(env, ["APNS_KEY_ID", "APNS_TEAM_ID", "APNS_BUNDLE_ID"]) &&
|
||||
anySet(env, ["APNS_KEY_B64", "APNS_KEY", "APNS_KEY_FILE"]),
|
||||
],
|
||||
[
|
||||
"Web Push (VAPID env)",
|
||||
allSet(env, ["VAPID_PUBLIC_KEY", "VAPID_PRIVATE_KEY", "VAPID_SUBJECT"]),
|
||||
],
|
||||
];
|
||||
for (const [label, configured] of optional) {
|
||||
lines.push({
|
||||
label,
|
||||
status: configured ? "ok" : "warn",
|
||||
detail: configured ? "configured" : "not configured (optional)",
|
||||
});
|
||||
}
|
||||
|
||||
const hasBlocker = lines.some((l) => l.status === "error");
|
||||
return { lines, hasBlocker };
|
||||
}
|
||||
|
||||
const GLYPH: Record<ReadinessLine["status"], string> = {
|
||||
ok: "✓",
|
||||
warn: "·",
|
||||
error: "✗",
|
||||
};
|
||||
|
||||
/** Render the report as a compact multi-line block. */
|
||||
export function formatReadiness(report: ReadinessReport): string {
|
||||
const body = report.lines
|
||||
.map((l) => ` ${GLYPH[l.status]} ${l.label}: ${l.detail}`)
|
||||
.join("\n");
|
||||
const header = report.hasBlocker
|
||||
? "HealthLog boot readiness — CORE SECRET MISSING/INVALID (see ✗ below):"
|
||||
: "HealthLog boot readiness:";
|
||||
return `${header}\n${body}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect + print the readiness summary. Blockers go to stderr (they precede a
|
||||
* fail-closed throw), the healthy summary to stdout. Never throws.
|
||||
*/
|
||||
export function logReadinessSummary(
|
||||
env: Env = process.env,
|
||||
log: Pick<Console, "log" | "error"> = console,
|
||||
): ReadinessReport {
|
||||
try {
|
||||
const report = collectReadiness(env);
|
||||
const text = formatReadiness(report);
|
||||
if (report.hasBlocker) {
|
||||
log.error(text);
|
||||
} else {
|
||||
log.log(text);
|
||||
}
|
||||
return report;
|
||||
} catch {
|
||||
// A readiness summary must never be the thing that breaks boot.
|
||||
return { lines: [], hasBlocker: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { getNumberFormat, getDateTimeFormat } from "../formatter-cache";
|
||||
|
||||
describe("formatter-cache", () => {
|
||||
it("returns the same NumberFormat instance for identical (locale, options)", () => {
|
||||
const a = getNumberFormat("en", { maximumFractionDigits: 2 });
|
||||
const b = getNumberFormat("en", { maximumFractionDigits: 2 });
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("returns distinct NumberFormat instances for different options", () => {
|
||||
const a = getNumberFormat("en", { maximumFractionDigits: 2 });
|
||||
const b = getNumberFormat("en", { maximumFractionDigits: 0 });
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("returns distinct instances per locale", () => {
|
||||
const a = getNumberFormat("en", { maximumFractionDigits: 1 });
|
||||
const b = getNumberFormat("de", { maximumFractionDigits: 1 });
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("caches DateTimeFormat and still formats correctly", () => {
|
||||
const a = getDateTimeFormat("en-CA", {
|
||||
timeZone: "UTC",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
const b = getDateTimeFormat("en-CA", {
|
||||
timeZone: "UTC",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
expect(a).toBe(b);
|
||||
expect(a.format(new Date("2026-05-08T12:00:00Z"))).toBe("2026-05-08");
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -6,6 +6,7 @@ import {
|
||||
runOffhostBackup,
|
||||
runOffhostRoundtripTest,
|
||||
} from "../offhost-backup";
|
||||
import { MEASUREMENT_BACKUP_PAGE_SIZE } from "../backup/measurement-page";
|
||||
|
||||
const ENC_KEY =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
@@ -138,6 +139,56 @@ describe("runOffhostBackup", () => {
|
||||
expect(parsed.userId).toBe("u1");
|
||||
});
|
||||
|
||||
it("keyset-pages the intake/mood/cycle tables across multiple pages", async () => {
|
||||
const s3 = makeS3Mock();
|
||||
// A full first page (=== page size) followed by a short page proves the
|
||||
// reader walks the id cursor instead of one unbounded findMany.
|
||||
const fullPage = Array.from(
|
||||
{ length: MEASUREMENT_BACKUP_PAGE_SIZE },
|
||||
(_, i) => ({
|
||||
id: `e${String(i).padStart(5, "0")}`,
|
||||
userId: "u1",
|
||||
takenAt: new Date("2026-05-01T00:00:00Z"),
|
||||
}),
|
||||
);
|
||||
const tailPage = [
|
||||
{ id: "z9999", userId: "u1", takenAt: new Date("2026-05-02T00:00:00Z") },
|
||||
];
|
||||
const intakeFindMany = vi.fn(
|
||||
async (args: { where?: { id?: { gt?: string } } }) =>
|
||||
args?.where?.id?.gt ? tailPage : fullPage,
|
||||
);
|
||||
|
||||
const prisma = {
|
||||
user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }]) },
|
||||
measurement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medication: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medicationIntakeEvent: { findMany: intakeFindMany },
|
||||
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
};
|
||||
|
||||
const report = await runOffhostBackup(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
prisma as any,
|
||||
s3,
|
||||
new Date("2026-05-08T00:00:00Z"),
|
||||
);
|
||||
expect(report.uploaded).toBe(1);
|
||||
// Two round-trips: the full page, then the short tail that ends the walk.
|
||||
expect(intakeFindMany).toHaveBeenCalledTimes(2);
|
||||
expect(intakeFindMany.mock.calls[0][0]).toMatchObject({
|
||||
orderBy: { id: "asc" },
|
||||
take: MEASUREMENT_BACKUP_PAGE_SIZE,
|
||||
});
|
||||
// Every paged row lands in the decrypted payload — no rows dropped.
|
||||
const ct = s3.store.get("2026-05-08/user-u1.json.enc")!;
|
||||
const parsed = JSON.parse(decryptBackup(ct, Buffer.from(ENC_KEY, "hex")));
|
||||
expect(parsed.intakeEvents).toHaveLength(MEASUREMENT_BACKUP_PAGE_SIZE + 1);
|
||||
});
|
||||
|
||||
it("counts per-user failures without aborting the whole run", async () => {
|
||||
const s3 = makeS3Mock();
|
||||
const prisma = {
|
||||
|
||||
@@ -279,16 +279,70 @@ export async function runOffhostBackup(
|
||||
where: { userId: user.id },
|
||||
include: { schedules: true },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({ where: { userId: user.id } }),
|
||||
prisma.moodEntry.findMany({ where: { userId: user.id } }),
|
||||
// The intake / mood / cycle tables scale with history depth the same
|
||||
// way measurements do (multiple intakes + a mood + a cycle-day-log per
|
||||
// day over years), so they get the same keyset-paged read: at most one
|
||||
// page is resident per round-trip instead of the entire table landing
|
||||
// in one array alongside the giant JSON.stringify below. The projection
|
||||
// keeps the full row (no select), so payload bytes are identical to the
|
||||
// prior default findMany — only peak heap during the read is bounded.
|
||||
collectPagedMeasurements({
|
||||
fetchPage: (afterId, take) =>
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(afterId ? { id: { gt: afterId } } : {}),
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
take,
|
||||
}),
|
||||
project: (row) => row,
|
||||
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
|
||||
}),
|
||||
collectPagedMeasurements({
|
||||
fetchPage: (afterId, take) =>
|
||||
prisma.moodEntry.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(afterId ? { id: { gt: afterId } } : {}),
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
take,
|
||||
}),
|
||||
project: (row) => row,
|
||||
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
|
||||
}),
|
||||
// v1.15.0 — cycle tables in the DR snapshot. Raw rows (this is a
|
||||
// disaster-recovery dump, not the canonical user-facing backup);
|
||||
// `notesEncrypted` stays ciphertext.
|
||||
// `notesEncrypted` stays ciphertext. The profile is a single row, so it
|
||||
// stays a direct read; the cycle + day-log tables are paged.
|
||||
prisma.cycleProfile.findUnique({ where: { userId: user.id } }),
|
||||
prisma.menstrualCycle.findMany({ where: { userId: user.id } }),
|
||||
prisma.cycleDayLog.findMany({
|
||||
where: { userId: user.id },
|
||||
include: { symptomLinks: true },
|
||||
collectPagedMeasurements({
|
||||
fetchPage: (afterId, take) =>
|
||||
prisma.menstrualCycle.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(afterId ? { id: { gt: afterId } } : {}),
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
take,
|
||||
}),
|
||||
project: (row) => row,
|
||||
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
|
||||
}),
|
||||
collectPagedMeasurements({
|
||||
fetchPage: (afterId, take) =>
|
||||
prisma.cycleDayLog.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(afterId ? { id: { gt: afterId } } : {}),
|
||||
},
|
||||
include: { symptomLinks: true },
|
||||
orderBy: { id: "asc" },
|
||||
take,
|
||||
}),
|
||||
project: (row) => row,
|
||||
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
Reference in new issue
Block a user