diff --git a/.env.example b/.env.example index 2f39edac0..2f200f2fd 100644 --- a/.env.example +++ b/.env.example @@ -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" # ----------------------------------------------------------------------------- diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fa57fdec..67a2e1b4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6c119b6ff..c250fab17 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.27.31 + version: 1.27.32 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index 6e59c8a7f..c24150fd4 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -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 diff --git a/messages/de.json b/messages/de.json index 8c9518cf2..1891ebcc2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 3e0e5458c..3fb5eaf38 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index b1a4fe79c..a47925492 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index cc4ca0e6c..c271324c2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 5c40b6fdf..b983b8a06 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 3652ca629..a41d97b3c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/package.json b/package.json index 9c9af1049..f01295653 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index 9f019eac2..deac6519e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*` diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index 6e4c2fae1..603726acd 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -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 `` and // `` 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 ( -
- -
- ); + return ; } // 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 ( +
+ + refetch()} /> +
+ ); + } + const summary = data?.summary; const achievements = data?.achievements ?? []; const grouped = groupByCategory(achievements); diff --git a/src/app/api/import/csv/__tests__/route.test.ts b/src/app/api/import/csv/__tests__/route.test.ts index b663f67c3..a1bb577a0 100644 --- a/src/app/api/import/csv/__tests__/route.test.ts +++ b/src/app/api/import/csv/__tests__/route.test.ts @@ -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; + createMany: ReturnType; + updateMany: ReturnType; + }; + 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; + createMany: ReturnType; + updateMany: ReturnType; + }; + 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); }); }); diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts index ae6a53de9..7fdd868d7 100644 --- a/src/app/api/import/csv/route.ts +++ b/src/app/api/import/csv/route.ts @@ -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(); 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(); + const extInserts = new Map(); + const extUpdates = new Map(); + 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(); + 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. diff --git a/src/app/checkups/page.tsx b/src/app/checkups/page.tsx index 162843a3c..2105fb54b 100644 --- a/src/app/checkups/page.tsx +++ b/src/app/checkups/page.tsx @@ -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 ( -
- -
- ); + return ; } return ( diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index 6110786cb..e5df84226 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -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(""); const [confirmId, setConfirmId] = useState(null); @@ -129,6 +132,19 @@ function CoachConversationsBody() { {t("insights.coach.historyTitle")} } + actions={ + // Surface the sibling Plans ledger — otherwise it is reachable + // only from the composer's `+` menu. + + } />
{t("common.loading")}

+ ) : 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. + refetch()} /> ) : groups.length === 0 ? (
{t("coach.plans.title")} } description={t("coach.plans.pageDescription")} + actions={ + + } />
query.refetch()} /> ) : query.isLoading ? ( -

- {t("common.loading")} -

- ) : groups.length === 0 ? ( + ) : groups.length === 0 ? ( +
+ } + title={t("coach.plans.empty")} + />
) : ( groups.map((group) => ( diff --git a/src/app/custom-metrics/[id]/page.tsx b/src/app/custom-metrics/[id]/page.tsx index 39e787634..4f884e645 100644 --- a/src/app/custom-metrics/[id]/page.tsx +++ b/src/app/custom-metrics/[id]/page.tsx @@ -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 ( -
- -
- ); + return ; } return ( diff --git a/src/app/cycle/page.tsx b/src/app/cycle/page.tsx index 680ea2223..a4ab8c9aa 100644 --- a/src/app/cycle/page.tsx +++ b/src/app/cycle/page.tsx @@ -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 ( -
- -
- ); + return ; } // `` 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 ( - - -
- } - > + }> ); diff --git a/src/app/illness/[id]/page.tsx b/src/app/illness/[id]/page.tsx index 8186871ab..98e4c174c 100644 --- a/src/app/illness/[id]/page.tsx +++ b/src/app/illness/[id]/page.tsx @@ -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 ( -
- -
- ); + return ; } return ( diff --git a/src/app/illness/page.tsx b/src/app/illness/page.tsx index ec16578b3..6fef680cc 100644 --- a/src/app/illness/page.tsx +++ b/src/app/illness/page.tsx @@ -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 ( -
- -
- ); + return ; } return ; diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 66ef264ed..8340bd4b5 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -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 error pattern. if (isError) { return ( -
- {t("insights.loadError")} - +
+ refetch()} + />
); } diff --git a/src/app/labs/[biomarkerId]/page.tsx b/src/app/labs/[biomarkerId]/page.tsx index 29189ca39..fc01acfcd 100644 --- a/src/app/labs/[biomarkerId]/page.tsx +++ b/src/app/labs/[biomarkerId]/page.tsx @@ -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 ( -
- -
- ); + return ; } return ( diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx index 6d6aed90d..3244bfc8e 100644 --- a/src/app/measurements/page.tsx +++ b/src/app/measurements/page.tsx @@ -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 ( -
- -
- ); + return ; } return ( diff --git a/src/app/medications/[id]/page.tsx b/src/app/medications/[id]/page.tsx index dd1365992..2eb9488a8 100644 --- a/src/app/medications/[id]/page.tsx +++ b/src/app/medications/[id]/page.tsx @@ -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 ( -
-
- ); + return ; } if (isError || !medication) { diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index bc2121edf..bc8a5b0e2 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -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() { )}
) : isError ? ( -
-
-

- {t("medications.loadFailed")} -

- -
-
+ 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 diff --git a/src/app/mental-wellbeing/page.tsx b/src/app/mental-wellbeing/page.tsx index 56b2e2d47..970860c68 100644 --- a/src/app/mental-wellbeing/page.tsx +++ b/src/app/mental-wellbeing/page.tsx @@ -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 ( -
- -
- ); + return ; } return ( diff --git a/src/app/mood/page.tsx b/src/app/mood/page.tsx index 231eae7f9..54b0d893f 100644 --- a/src/app/mood/page.tsx +++ b/src/app/mood/page.tsx @@ -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 ( -
- -
- ); + return ; } return ( diff --git a/src/app/notifications/__tests__/page.test.tsx b/src/app/notifications/__tests__/page.test.tsx new file mode 100644 index 000000000..c2743c816 --- /dev/null +++ b/src/app/notifications/__tests__/page.test.tsx @@ -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( + + + , + ); +} + +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"); + }); +}); diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index 4078419e7..f5c64067d 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -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("/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 = ( + <> + + + + ); + if (authLoading || isLoading) { return ( -
- +
+ + {/* Skeleton mirrors the event-row matrix so the auth-resolving frame + reserves layout instead of flashing a lone spinner. */} +
+ {[0, 1, 2, 3, 4].map((i) => ( +
+
+ + +
+ +
+ ))} +
); } @@ -149,12 +191,10 @@ export default function NotificationsPage() { title={t("notifications.title")} description={t("notifications.subtitle")} /> -
- {t("notifications.loadError")} -
+ refetch()} + />
); } @@ -166,6 +206,7 @@ export default function NotificationsPage() {
@@ -173,8 +214,10 @@ export default function NotificationsPage() {

{t("notifications.noChannels")}

+ {/* Channel setup lives in Settings → Integrations since v1.25.7; + point the CTA at that anchor, not the reminder-types screen. */}
- {/* 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) && (

- {t("notifications.channelDisabled")} + {t("notifications.someChannelsGloballyDisabled")}

)} diff --git a/src/app/onboarding/[step]/page.tsx b/src/app/onboarding/[step]/page.tsx index ff0edadc8..9bcfb193f 100644 --- a/src/app/onboarding/[step]/page.tsx +++ b/src/app/onboarding/[step]/page.tsx @@ -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 - * `
` 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 ( diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 158f85062..61e734eab 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -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 = { + 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) => ( -
- {/* - * Per-tile `` 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 `` 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 = ( }> {entry.node} -
- ))} + ); + // 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 ? ( + + {cell} + + ) : ( +
+ {cell} +
+ ); + })}
)} {charts.map((entry) => ( diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index b9c8ff1aa..38d907201 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -192,6 +192,11 @@ export function SettingsToggle({
` 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} diff --git a/src/components/admin/general-settings-section.tsx b/src/components/admin/general-settings-section.tsx index 84bda6238..ff6a671cf 100644 --- a/src/components/admin/general-settings-section.tsx +++ b/src/components/admin/general-settings-section.tsx @@ -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) && ( +

+ {t("admin.registrationOpenHint")} +

+ )} + {/* 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 / diff --git a/src/components/charts/chart-empty-state.tsx b/src/components/charts/chart-empty-state.tsx index fe71164ad..790521df9 100644 --- a/src/components/charts/chart-empty-state.tsx +++ b/src/components/charts/chart-empty-state.tsx @@ -56,7 +56,7 @@ export function ChartEmptyState({ style={height === undefined ? undefined : { height }} >
); } diff --git a/src/components/custom-metrics/custom-metric-list.tsx b/src/components/custom-metrics/custom-metric-list.tsx index e9492c6df..b7669cc0c 100644 --- a/src/components/custom-metrics/custom-metric-list.tsx +++ b/src/components/custom-metrics/custom-metric-list.tsx @@ -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(null); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.customMetrics(), queryFn: () => apiGet("/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() { ) : isError ? ( -

- {t("customMetrics.loadError")} -

+ refetch()} + /> ) : metrics.length === 0 ? ( diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 8665fe1e4..a085ffb33 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -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(initialTab); + const today = useMemo(() => shiftToday(0), []); const from = useMemo(() => shiftToday(-90), []); const to = useMemo(() => shiftToday(180), []); @@ -158,13 +171,26 @@ export function CycleView() { title={{t("cycle.title")}} description={t("cycle.subtitle")} actions={ - + <> + + + } /> @@ -266,7 +292,7 @@ export function CycleView() { ) : null} - + 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 diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx index a4557b5c0..db30b451e 100644 --- a/src/components/dashboard/recent-workouts-tile.tsx +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -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 — ``. @@ -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", )} > -
-
-
- - {t("dashboard.recentWorkouts.viewAll")} - -
+ {/* 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. */} + + {t("dashboard.recentWorkouts.viewAll")} + + } + /> {isLoading ? ( // v1.4.43 W11-L6 — reserve roughly the loaded tile height diff --git a/src/components/insights/__tests__/daily-briefing.test.tsx b/src/components/insights/__tests__/daily-briefing.test.tsx index 8db6630e4..25aa60033 100644 --- a/src/components/insights/__tests__/daily-briefing.test.tsx +++ b/src/components/insights/__tests__/daily-briefing.test.tsx @@ -95,6 +95,36 @@ describe("", () => { 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 `` links, not inert rows. + const html = render( + , + ); + 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(); expect(html).toMatch(/data-slot="daily-briefing-delta"/); diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx index ce3da3cff..65eebed90 100644 --- a/src/components/insights/daily-briefing.tsx +++ b/src/components/insights/daily-briefing.tsx @@ -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", }; /** diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index 7047ed596..b88bfdff8 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -291,8 +291,11 @@ export function HeroStrip({
+ {/* Header glyph stays in the foreground tier — UI-STANDARDS §1 + reserves primary/accent for actions, not decorative header + icons. */}
@@ -319,6 +325,14 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { values={group.readings.map((r) => r.value)} />
+ {/* 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 ? ( +

+ {t("labs.notLinkedYet")} +

+ ) : null} diff --git a/src/components/labs/lab-trend-sparkline.tsx b/src/components/labs/lab-trend-sparkline.tsx index 1395e1dc9..4633dc400 100644 --- a/src/components/labs/lab-trend-sparkline.tsx +++ b/src/components/labs/lab-trend-sparkline.tsx @@ -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" > diff --git a/src/components/medications/dose-history-add-dialog.tsx b/src/components/medications/dose-history-add-dialog.tsx index 49587bea0..db7fc5f4c 100644 --- a/src/components/medications/dose-history-add-dialog.tsx +++ b/src/components/medications/dose-history-add-dialog.tsx @@ -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 ( - !busy && onOpenChange(o)}> - - - - {t("medications.detail.verlauf.addDialog.title")} - - - {t("medications.detail.verlauf.addDialog.description")} - - - - {/* 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. */} -
{ - e.preventDefault(); - submit(); - }} - > -
-
- - setTakenAt(value)} - disabled={skipped} - /> -
- - - - {/* A near-miss take previews where it will land before the user - commits — calm, not alarming. */} - {!skipped && classification === "nudge" && nearest && ( -

- {t("medications.detail.verlauf.addDialog.nudgeHint", { - slot: nearest.slotHm, - })} -

+ !busy && onOpenChange(o)} + title={t("medications.detail.verlauf.addDialog.title")} + description={t("medications.detail.verlauf.addDialog.description")} + footer={ + <> + + + + } + > + {/* v1.16.4 — a real form so Enter in the datetime field submits; + the buttons carry explicit types so cancel never submits. */} + { + e.preventDefault(); + submit(); + }} + > +
+
+ + setTakenAt(value)} + disabled={skipped} + />
- - - - - - - {/* The "diesem Slot zuordnen?" nudge — pin onto the slot, or keep the - take as a standalone ad-hoc entry. */} - {nudgeOpen && nearest && ( - !busy && setNudgeOpen(o)} + - )} - -
+ + {t("medications.detail.verlauf.addDialog.skippedLabel")} + + + + + {/* A near-miss take previews where it will land before the user + commits — calm, not alarming. */} + {!skipped && classification === "nudge" && nearest && ( +

+ {t("medications.detail.verlauf.addDialog.nudgeHint", { + slot: nearest.slotHm, + })} +

+ )} +
+ + + {/* The "diesem Slot zuordnen?" nudge — pin onto the slot, or keep the + take as a standalone ad-hoc entry. */} + {nudgeOpen && nearest && ( + !busy && setNudgeOpen(o)} + title={t("medications.detail.verlauf.nudge.title")} + footer={ + <> + + + + } + > +

+ {t("medications.detail.verlauf.nudge.body", { + slot: nearest.slotHm, + })} +

+
+ )} + ); } diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index 09e95806b..2ef0a1a32 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -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")} diff --git a/src/components/medications/intake-edit-dialog.tsx b/src/components/medications/intake-edit-dialog.tsx index e01ab2feb..9f542e273 100644 --- a/src/components/medications/intake-edit-dialog.tsx +++ b/src/components/medications/intake-edit-dialog.tsx @@ -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 ( - !open && onClose()}> - - - - {t("medications.detail.intake.edit.dialogTitle")} - - - {/* v1.16.4 — a real form so Enter in the datetime field submits; + !open && onClose()} + title={t("medications.detail.intake.edit.dialogTitle")} + footer={ + <> + + + + } + > + {/* v1.16.4 — a real form so Enter in the datetime field submits; the buttons carry explicit types so cancel never submits. */} -
{ - e.preventDefault(); - void save(); - }} - > -
-
- - setTakenAt(value)} - disabled={skipped} - /> - {scheduledFor && ( -

- {t("medications.detail.intake.edit.scheduledForHint", { - dateTime: fmt.dateTime(scheduledFor), - })} -

- )} - {/* Non-blocking: a 48h+ gap to the slot is almost always a + { + e.preventDefault(); + void save(); + }} + > +
+
+ + setTakenAt(value)} + disabled={skipped} + /> + {scheduledFor && ( +

+ {t("medications.detail.intake.edit.scheduledForHint", { + dateTime: fmt.dateTime(scheduledFor), + })} +

+ )} + {/* Non-blocking: a 48h+ gap to the slot is almost always a date typo, but a deliberate far edit stays saveable. */} - {showFarHint && ( -

- {t("medications.detail.intake.edit.farFromScheduledWarning")} -

- )} -
- + {showFarHint && ( +

+ {t("medications.detail.intake.edit.farFromScheduledWarning")} +

+ )}
- - - - - - -
+ + + + ); } diff --git a/src/components/medications/intake-history-editable.tsx b/src/components/medications/intake-history-editable.tsx index 0e162e955..87e6d852a 100644 --- a/src/components/medications/intake-history-editable.tsx +++ b/src/components/medications/intake-history-editable.tsx @@ -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")} @@ -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")} diff --git a/src/components/medications/intake-import-dialog.tsx b/src/components/medications/intake-import-dialog.tsx index 591bf85eb..c9c3a16b6 100644 --- a/src/components/medications/intake-import-dialog.tsx +++ b/src/components/medications/intake-import-dialog.tsx @@ -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 ( - handleClose()}> - - - {t("medications.importIntakes")} - -
-

- {t("medications.importDescription")} -

-
- - - {selectedFileName && ( -

- {t("medications.importSelected", { name: selectedFileName })} -

+ handleClose()} + title={t("medications.importIntakes")} + contentWidth="lg" + footer={ + <> + + + + + + + + {t("common.reset")} + + + + +
-
-            {`[
+            {t("common.import")}
+          
+        
+      }
+    >
+      
+

+ {t("medications.importDescription")} +

+
+ + + {selectedFileName && ( +

+ {t("medications.importSelected", { name: selectedFileName })} +

+ )} +
+
+          {`[
   {"datum": "2026-02-14", "uhrzeit": "10:27:43", "zaehler": 523},
   {"datum": "2026-02-14", "uhrzeit": "23:33:42", "zaehler": 524}
 ]`}
-          
-