mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
chore(release): v1.6.0
Medication editor overhaul, route of administration, today-tile read-flip. - Route-of-administration column (ORAL | INJECTION | OTHER) decoupled from treatmentClass; injection-site picker surfaces for any injection. Migration 0088 backfills ORAL via a constant column default. - One-time injection (oneShot + deliveryForm = INJECTION) editor path. - Profile-photo upload card on Settings → Account. - Today-tile read-flip: /api/medications/intake?scope=today and /api/dashboard/summary gate through the canonical recurrence engine, matching the reminder worker for bi-weekly, rolling, RRULE, and one-time cadences. - Measurement note cap 25 → 200; dashboard range colors on semantic tokens; admin sidebar icons + shared-helper consolidation; API-tokens multi-language snippets restored. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d01d1d1dc9
commit
8ee7aaac44
53 files changed
+1049
-1017
No files matched your search
@@ -251,7 +251,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download digest artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digest-*
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
## [1.6.0] — 2026-05-30 — Medication editor overhaul, route of administration, today-tile read-flip
|
||||
|
||||
v1.5.6 settled the medication detail page into a pure history view, but the create/edit plan still funnelled every dose through the same flat shape, the injection-site picker only surfaced for GLP-1, and the today-tile on the dashboard and the Erfassen sheet still expanded daily schedules through a legacy walker that silently skipped bi-weekly, rolling, RRULE, and one-time cadences. This release reworks the medication editor and its detail surface, gives route of administration a first-class column so any injection can carry a site, adds a one-time-injection shape, and flips the today-tile onto the canonical recurrence engine the reminder worker already uses — so what the tile shows matches what the worker mints. It also restores the multi-language request snippets on the API-tokens row, adds a profile-photo upload to Account, and finishes an admin-surface polish pass.
|
||||
|
||||
### Added
|
||||
|
||||
- **Route of administration** — a new `deliveryForm` column (`ORAL` | `INJECTION` | `OTHER`) decoupled from `treatmentClass`. The injection-site picker now surfaces for any `INJECTION` dose rather than only GLP-1, and the editor carries the route through create, update, and the detail snapshot. Migration `0088` adds the enum and backfills `ORAL` onto every existing row via a constant column default (a single non-blocking metadata operation).
|
||||
- **One-time injection** — a one-off dose modelled as `oneShot = true` + `deliveryForm = INJECTION`, with its own editor path that drops the recurring-schedule step.
|
||||
- **Profile-photo upload on Account** — Settings → Account gains an avatar card backed by the v1.5.5 upload endpoint (server-side magic-byte sniff, 2 MiB stream-level cap, 2048² dimension cap, per-user rate limit, owner-scoped read).
|
||||
- **Admin global mood-log toggle** — the admin Services section can suspend mood-log reminders site-wide alongside the existing Web-Push and API toggles.
|
||||
- **Host-metrics memory detail** — the admin host-metrics memory tooltip now reads `used / total GiB` alongside the percentage.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Medication editor + detail surface overhaul** — the editor uses the available desktop width while keeping the mobile sheet, restores the edit fields the modal-wizard rework had dropped, and aligns the detail surface with the new route-of-administration and one-time shapes.
|
||||
- **Measurement note cap raised 25 → 200 characters** — a 25-character note could not hold a meaningful clinical aside; the cap lifts to 200 through a single `MEASUREMENT_NOTES_MAX_LENGTH` constant the client char-counters import. The DB column was already unbounded.
|
||||
- **Dashboard range colors route through semantic tokens** — the dashboard date-range chips read the shared `success` / `warning` / `info` / `destructive` tokens (with their light-mode contrast overrides) instead of hard-coded hues, and the range inputs gain bound-clamping.
|
||||
- **Admin surface tidy** — every sidebar section gets a distinct icon, the shared `helpfulRateColour` helper and a `usePublicVersion` hook are consolidated into `_shared`, and the orphaned `status-overview` route is removed.
|
||||
- **API-tokens row restores multi-language request snippets** — the per-token row again offers copyable cURL / JavaScript / Python examples.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Today-tile diverged from the reminder worker for non-daily cadences** — `/api/medications/intake?scope=today` and `/api/dashboard/summary` projected daily schedules through a legacy walker that read only `daysOfWeek` + `windowStart`, silently skipping `intervalWeeks > 1`, rolling, RRULE, and one-time cadences. Both routes now gate every "does this schedule emit today?" decision through the canonical recurrence engine (the same path the worker uses), anchoring the projected instant to `windowStart` so it stays byte-identical to the worker's row and dedupes against the existing unique index. The rolling-cadence baseline fetch mirrors the worker's per-medication `takenAt` query, so projector and worker resolve the same next-due instant.
|
||||
|
||||
### Tests
|
||||
|
||||
- 5635 → 5628 unit; 1 skipped. The today-tile read-flip carries new coverage on the intake and dashboard-summary projections; the orphaned status-overview route and its test are gone.
|
||||
|
||||
## [1.5.6] — 2026-05-29 — Pure-history detail page, legacy step consolidation, egress finish
|
||||
|
||||
v1.5.5 gave every retired medication feature a home on the detail page, but the page still carried the create/edit affordances that belong on the list. This release turns `/medications/[id]` into a pure history surface, collapses every setting into one advanced sheet, consolidates the pre-v1.5.0 granular step rows that bloated the per-sample read path, and finishes the outbound-fetch migration the `safeFetch` wrapper started.
|
||||
|
||||
@@ -96,7 +96,7 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
|
||||
|
||||
**Sub-second dashboard** -- A persistent rollup tier pre-aggregates every measurement at DAY / WEEK / MONTH granularity. The dashboard and analytics paths read those buckets first and fall back to live SQL only when a tenant hasn't been backfilled yet, so paint time stays under 500 ms regardless of how many years of history you've imported.
|
||||
|
||||
**Gamification** -- 30+ persistent achievements across intake streaks, compliance milestones, and healthy metric streaks.
|
||||
**Gamification** -- 59 persistent achievements across intake streaks, compliance milestones, and healthy metric streaks.
|
||||
|
||||
**Internationalization** -- English (default) and German UI with 2500+ translation keys each, guarded by a CI integrity test that fails the build on duplicate keys, drift between locales, or call-sites referencing keys that no locale ships. Numbers, dates, units, and AI prompts all locale-aware via `useFormatters()`. Browser-based detection with per-user override.
|
||||
|
||||
@@ -323,9 +323,11 @@ All mutations require authentication via session cookie. External ingest uses Be
|
||||
| `POST` | `/api/auth/register` | Create account |
|
||||
| `POST` | `/api/auth/login` | Password login |
|
||||
| `POST` | `/api/auth/logout` | Destroy session |
|
||||
| `GET` | `/api/auth/me` | Current user profile + Gravatar URL |
|
||||
| `GET` | `/api/auth/me` | Current user profile + avatar URL |
|
||||
| `POST` | `/api/auth/password` | Change password |
|
||||
| `PATCH` | `/api/auth/profile` | Update profile fields |
|
||||
| `POST` | `/api/user/avatar` | Upload a profile photo (self-hosted)|
|
||||
| `DELETE`| `/api/user/avatar` | Remove the profile photo |
|
||||
| `POST` | `/api/auth/passkey/*` | WebAuthn flows (4 sub-routes) |
|
||||
| `GET` | `/api/auth/passkeys` | List enrolled passkeys |
|
||||
| `POST` | `/api/auth/codex/device-start` | ChatGPT (Codex) device-OAuth start |
|
||||
|
||||
+14
-2
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.5.5
|
||||
version: 1.5.6
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -319,7 +319,7 @@ paths:
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
notes:
|
||||
type: string
|
||||
maxLength: 25
|
||||
maxLength: 200
|
||||
source:
|
||||
default: MANUAL
|
||||
type: string
|
||||
@@ -1876,6 +1876,12 @@ components:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
deliveryForm:
|
||||
type: string
|
||||
enum:
|
||||
- ORAL
|
||||
- INJECTION
|
||||
- OTHER
|
||||
notificationsEnabled:
|
||||
type: boolean
|
||||
startsOn:
|
||||
@@ -2023,6 +2029,12 @@ components:
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
- type: "null"
|
||||
deliveryForm:
|
||||
type: string
|
||||
enum:
|
||||
- ORAL
|
||||
- INJECTION
|
||||
- OTHER
|
||||
active:
|
||||
type: boolean
|
||||
notificationsEnabled:
|
||||
|
||||
+26
-1
@@ -831,7 +831,18 @@
|
||||
"sprays": "Sprühstoß",
|
||||
"pieces": "Stück",
|
||||
"other": "Sonstige"
|
||||
}
|
||||
},
|
||||
"deliveryFormLabel": "Darreichung",
|
||||
"deliveryForm": {
|
||||
"ORAL": "Oral",
|
||||
"INJECTION": "Injektion",
|
||||
"OTHER": "Sonstige"
|
||||
},
|
||||
"dosesPerUnitLabel": "Dosen pro Einheit",
|
||||
"dosesPerUnitPlaceholder": "z. B. 4",
|
||||
"dosesPerUnitHint": "Dosen pro Pen / Ampulle — leer lassen, wenn kein Bestand getrackt wird.",
|
||||
"injectionSiteToggle": "Injektionsstellen-Rotation",
|
||||
"injectionSiteHint": "Optionaler Rotationshelfer. Die Stelle jeder Dosis wird beim Eintragen der Einnahme erfasst."
|
||||
},
|
||||
"step4": {
|
||||
"title": "Über welchen Zeitraum nimmst du es?",
|
||||
@@ -2197,6 +2208,19 @@
|
||||
"timezoneHint": "Wird für Diagrammachsen, Erinnerungszeiten, Export-Zeitstempel und den Coach-Kontext verwendet. Gespeicherte Daten bleiben unverändert.",
|
||||
"timezoneInvalid": "Keine gültige IANA-Zeitzone.",
|
||||
"profileSaved": "Profil gespeichert",
|
||||
"avatar": {
|
||||
"title": "Profilfoto",
|
||||
"description": "Lade ein Foto hoch, das auf deinem eigenen Server gespeichert wird. Es erscheint überall in der App anstelle eines externen Avatar-Dienstes.",
|
||||
"upload": "Foto hochladen",
|
||||
"replace": "Foto ersetzen",
|
||||
"remove": "Entfernen",
|
||||
"hint": "JPEG, PNG oder WebP. Bis zu 2 MB und 2048×2048 Pixel.",
|
||||
"uploaded": "Profilfoto aktualisiert",
|
||||
"removed": "Profilfoto entfernt",
|
||||
"invalidType": "Nicht unterstützter Bildtyp. Verwende JPEG, PNG oder WebP.",
|
||||
"tooLarge": "Das Bild überschreitet das Limit von 2 MB.",
|
||||
"error": "Profilfoto konnte nicht aktualisiert werden. Bitte versuche es erneut."
|
||||
},
|
||||
"changePassword": "Passwort ändern",
|
||||
"changePasswordDescription": "Ersetze dein aktuelles Passwort durch ein neues.",
|
||||
"passwordReset": "Passwort-Reset",
|
||||
@@ -2610,6 +2634,7 @@
|
||||
"telegramGlobal": "Telegram-Benachrichtigungen erlauben.",
|
||||
"ntfyGlobal": "ntfy-Benachrichtigungen erlauben.",
|
||||
"webPushGlobal": "Browser-Push-Benachrichtigungen erlauben.",
|
||||
"moodLogGlobal": "moodLog-Erinnerungen erlauben.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug-Reports",
|
||||
"offlineGeoLabel": "Geodaten",
|
||||
|
||||
+26
-1
@@ -831,7 +831,18 @@
|
||||
"sprays": "spray",
|
||||
"pieces": "pieces",
|
||||
"other": "other"
|
||||
}
|
||||
},
|
||||
"deliveryFormLabel": "Route",
|
||||
"deliveryForm": {
|
||||
"ORAL": "Oral",
|
||||
"INJECTION": "Injection",
|
||||
"OTHER": "Other"
|
||||
},
|
||||
"dosesPerUnitLabel": "Doses per unit",
|
||||
"dosesPerUnitPlaceholder": "e.g. 4",
|
||||
"dosesPerUnitHint": "Doses per pen / vial — leave empty for no inventory tracking.",
|
||||
"injectionSiteToggle": "Injection-site rotation",
|
||||
"injectionSiteHint": "Optional rotation guide. The site for each dose is recorded when you log the intake."
|
||||
},
|
||||
"step4": {
|
||||
"title": "Over what period are you taking it?",
|
||||
@@ -2197,6 +2208,19 @@
|
||||
"timezoneHint": "Used for chart axis labels, reminder times, export timestamps, and Coach context. Stored data is unchanged.",
|
||||
"timezoneInvalid": "Not a valid IANA timezone.",
|
||||
"profileSaved": "Profile saved",
|
||||
"avatar": {
|
||||
"title": "Profile photo",
|
||||
"description": "Upload a photo stored on your own server. It appears across the app in place of an external avatar service.",
|
||||
"upload": "Upload photo",
|
||||
"replace": "Replace photo",
|
||||
"remove": "Remove",
|
||||
"hint": "JPEG, PNG, or WebP. Up to 2 MB and 2048×2048 pixels.",
|
||||
"uploaded": "Profile photo updated",
|
||||
"removed": "Profile photo removed",
|
||||
"invalidType": "Unsupported image type. Use JPEG, PNG, or WebP.",
|
||||
"tooLarge": "Image exceeds the 2 MB limit.",
|
||||
"error": "Could not update the profile photo. Please try again."
|
||||
},
|
||||
"changePassword": "Change password",
|
||||
"changePasswordDescription": "Replace your current password with a new one.",
|
||||
"passwordReset": "Password reset",
|
||||
@@ -2610,6 +2634,7 @@
|
||||
"telegramGlobal": "Allow Telegram notifications.",
|
||||
"ntfyGlobal": "Allow ntfy notifications.",
|
||||
"webPushGlobal": "Allow browser push notifications.",
|
||||
"moodLogGlobal": "Allow mood-log reminders.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug Report",
|
||||
"offlineGeoLabel": "Geo data",
|
||||
|
||||
+26
-1
@@ -831,7 +831,18 @@
|
||||
"sprays": "spray",
|
||||
"pieces": "pieces",
|
||||
"other": "other"
|
||||
}
|
||||
},
|
||||
"deliveryFormLabel": "Vía",
|
||||
"deliveryForm": {
|
||||
"ORAL": "Oral",
|
||||
"INJECTION": "Inyección",
|
||||
"OTHER": "Otra"
|
||||
},
|
||||
"dosesPerUnitLabel": "Dosis por unidad",
|
||||
"dosesPerUnitPlaceholder": "p. ej. 4",
|
||||
"dosesPerUnitHint": "Dosis por pluma / vial — déjalo vacío para no controlar el inventario.",
|
||||
"injectionSiteToggle": "Rotación del sitio de inyección",
|
||||
"injectionSiteHint": "Guía de rotación opcional. El sitio de cada dosis se registra al anotar la toma."
|
||||
},
|
||||
"step4": {
|
||||
"title": "Over what period are you taking it?",
|
||||
@@ -2197,6 +2208,19 @@
|
||||
"timezoneHint": "Used for chart axis labels, reminder times, export timestamps, and Coach context. Stored data is unchanged.",
|
||||
"timezoneInvalid": "Not a valid IANA timezone.",
|
||||
"profileSaved": "Profile saved",
|
||||
"avatar": {
|
||||
"title": "Foto de perfil",
|
||||
"description": "Sube una foto que se almacena en tu propio servidor. Aparece en toda la aplicación en lugar de un servicio de avatares externo.",
|
||||
"upload": "Subir foto",
|
||||
"replace": "Reemplazar foto",
|
||||
"remove": "Quitar",
|
||||
"hint": "JPEG, PNG o WebP. Hasta 2 MB y 2048×2048 píxeles.",
|
||||
"uploaded": "Foto de perfil actualizada",
|
||||
"removed": "Foto de perfil eliminada",
|
||||
"invalidType": "Tipo de imagen no admitido. Usa JPEG, PNG o WebP.",
|
||||
"tooLarge": "La imagen supera el límite de 2 MB.",
|
||||
"error": "No se pudo actualizar la foto de perfil. Inténtalo de nuevo."
|
||||
},
|
||||
"changePassword": "Change password",
|
||||
"changePasswordDescription": "Replace your current password with a new one.",
|
||||
"passwordReset": "Password reset",
|
||||
@@ -2610,6 +2634,7 @@
|
||||
"telegramGlobal": "Allow Telegram notifications.",
|
||||
"ntfyGlobal": "Allow ntfy notifications.",
|
||||
"webPushGlobal": "Allow browser push notifications.",
|
||||
"moodLogGlobal": "Allow mood-log reminders.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug Report",
|
||||
"offlineGeoLabel": "Datos geográficos",
|
||||
|
||||
+26
-1
@@ -831,7 +831,18 @@
|
||||
"sprays": "spray",
|
||||
"pieces": "pieces",
|
||||
"other": "other"
|
||||
}
|
||||
},
|
||||
"deliveryFormLabel": "Voie",
|
||||
"deliveryForm": {
|
||||
"ORAL": "Orale",
|
||||
"INJECTION": "Injection",
|
||||
"OTHER": "Autre"
|
||||
},
|
||||
"dosesPerUnitLabel": "Doses par unité",
|
||||
"dosesPerUnitPlaceholder": "p. ex. 4",
|
||||
"dosesPerUnitHint": "Doses par stylo / flacon — laisser vide pour ne pas suivre le stock.",
|
||||
"injectionSiteToggle": "Rotation des sites d'injection",
|
||||
"injectionSiteHint": "Guide de rotation facultatif. Le site de chaque dose est enregistré lors de la saisie de la prise."
|
||||
},
|
||||
"step4": {
|
||||
"title": "Over what period are you taking it?",
|
||||
@@ -2197,6 +2208,19 @@
|
||||
"timezoneHint": "Used for chart axis labels, reminder times, export timestamps, and Coach context. Stored data is unchanged.",
|
||||
"timezoneInvalid": "Not a valid IANA timezone.",
|
||||
"profileSaved": "Profile saved",
|
||||
"avatar": {
|
||||
"title": "Photo de profil",
|
||||
"description": "Téléverse une photo stockée sur ton propre serveur. Elle apparaît dans toute l'application à la place d'un service d'avatar externe.",
|
||||
"upload": "Téléverser une photo",
|
||||
"replace": "Remplacer la photo",
|
||||
"remove": "Supprimer",
|
||||
"hint": "JPEG, PNG ou WebP. Jusqu'à 2 Mo et 2048×2048 pixels.",
|
||||
"uploaded": "Photo de profil mise à jour",
|
||||
"removed": "Photo de profil supprimée",
|
||||
"invalidType": "Type d'image non pris en charge. Utilise JPEG, PNG ou WebP.",
|
||||
"tooLarge": "L'image dépasse la limite de 2 Mo.",
|
||||
"error": "Impossible de mettre à jour la photo de profil. Réessaie."
|
||||
},
|
||||
"changePassword": "Change password",
|
||||
"changePasswordDescription": "Replace your current password with a new one.",
|
||||
"passwordReset": "Password reset",
|
||||
@@ -2610,6 +2634,7 @@
|
||||
"telegramGlobal": "Allow Telegram notifications.",
|
||||
"ntfyGlobal": "Allow ntfy notifications.",
|
||||
"webPushGlobal": "Allow browser push notifications.",
|
||||
"moodLogGlobal": "Allow mood-log reminders.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug Report",
|
||||
"offlineGeoLabel": "Données géographiques",
|
||||
|
||||
+26
-1
@@ -831,7 +831,18 @@
|
||||
"sprays": "spray",
|
||||
"pieces": "pieces",
|
||||
"other": "other"
|
||||
}
|
||||
},
|
||||
"deliveryFormLabel": "Via",
|
||||
"deliveryForm": {
|
||||
"ORAL": "Orale",
|
||||
"INJECTION": "Iniezione",
|
||||
"OTHER": "Altro"
|
||||
},
|
||||
"dosesPerUnitLabel": "Dosi per unità",
|
||||
"dosesPerUnitPlaceholder": "es. 4",
|
||||
"dosesPerUnitHint": "Dosi per penna / fiala — lascia vuoto per non tracciare le scorte.",
|
||||
"injectionSiteToggle": "Rotazione del sito di iniezione",
|
||||
"injectionSiteHint": "Guida alla rotazione facoltativa. Il sito di ogni dose viene registrato al momento della registrazione dell'assunzione."
|
||||
},
|
||||
"step4": {
|
||||
"title": "Over what period are you taking it?",
|
||||
@@ -2197,6 +2208,19 @@
|
||||
"timezoneHint": "Used for chart axis labels, reminder times, export timestamps, and Coach context. Stored data is unchanged.",
|
||||
"timezoneInvalid": "Not a valid IANA timezone.",
|
||||
"profileSaved": "Profile saved",
|
||||
"avatar": {
|
||||
"title": "Foto del profilo",
|
||||
"description": "Carica una foto archiviata sul tuo server. Appare in tutta l'app al posto di un servizio di avatar esterno.",
|
||||
"upload": "Carica foto",
|
||||
"replace": "Sostituisci foto",
|
||||
"remove": "Rimuovi",
|
||||
"hint": "JPEG, PNG o WebP. Fino a 2 MB e 2048×2048 pixel.",
|
||||
"uploaded": "Foto del profilo aggiornata",
|
||||
"removed": "Foto del profilo rimossa",
|
||||
"invalidType": "Tipo di immagine non supportato. Usa JPEG, PNG o WebP.",
|
||||
"tooLarge": "L'immagine supera il limite di 2 MB.",
|
||||
"error": "Impossibile aggiornare la foto del profilo. Riprova."
|
||||
},
|
||||
"changePassword": "Change password",
|
||||
"changePasswordDescription": "Replace your current password with a new one.",
|
||||
"passwordReset": "Password reset",
|
||||
@@ -2610,6 +2634,7 @@
|
||||
"telegramGlobal": "Allow Telegram notifications.",
|
||||
"ntfyGlobal": "Allow ntfy notifications.",
|
||||
"webPushGlobal": "Allow browser push notifications.",
|
||||
"moodLogGlobal": "Allow mood-log reminders.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug Report",
|
||||
"offlineGeoLabel": "Dati geografici",
|
||||
|
||||
+26
-1
@@ -831,7 +831,18 @@
|
||||
"sprays": "spray",
|
||||
"pieces": "pieces",
|
||||
"other": "other"
|
||||
}
|
||||
},
|
||||
"deliveryFormLabel": "Droga podania",
|
||||
"deliveryForm": {
|
||||
"ORAL": "Doustnie",
|
||||
"INJECTION": "Iniekcja",
|
||||
"OTHER": "Inna"
|
||||
},
|
||||
"dosesPerUnitLabel": "Dawki na jednostkę",
|
||||
"dosesPerUnitPlaceholder": "np. 4",
|
||||
"dosesPerUnitHint": "Dawki na wstrzykiwacz / fiolkę — zostaw puste, aby nie śledzić zapasów.",
|
||||
"injectionSiteToggle": "Rotacja miejsc wstrzyknięcia",
|
||||
"injectionSiteHint": "Opcjonalny przewodnik rotacji. Miejsce każdej dawki zapisywane jest przy rejestrowaniu przyjęcia."
|
||||
},
|
||||
"step4": {
|
||||
"title": "Over what period are you taking it?",
|
||||
@@ -2197,6 +2208,19 @@
|
||||
"timezoneHint": "Used for chart axis labels, reminder times, export timestamps, and Coach context. Stored data is unchanged.",
|
||||
"timezoneInvalid": "Not a valid IANA timezone.",
|
||||
"profileSaved": "Profile saved",
|
||||
"avatar": {
|
||||
"title": "Zdjęcie profilowe",
|
||||
"description": "Prześlij zdjęcie przechowywane na własnym serwerze. Pojawia się w całej aplikacji zamiast zewnętrznej usługi awatarów.",
|
||||
"upload": "Prześlij zdjęcie",
|
||||
"replace": "Zmień zdjęcie",
|
||||
"remove": "Usuń",
|
||||
"hint": "JPEG, PNG lub WebP. Do 2 MB i 2048×2048 pikseli.",
|
||||
"uploaded": "Zaktualizowano zdjęcie profilowe",
|
||||
"removed": "Usunięto zdjęcie profilowe",
|
||||
"invalidType": "Nieobsługiwany typ obrazu. Użyj JPEG, PNG lub WebP.",
|
||||
"tooLarge": "Obraz przekracza limit 2 MB.",
|
||||
"error": "Nie udało się zaktualizować zdjęcia profilowego. Spróbuj ponownie."
|
||||
},
|
||||
"changePassword": "Change password",
|
||||
"changePasswordDescription": "Replace your current password with a new one.",
|
||||
"passwordReset": "Password reset",
|
||||
@@ -2610,6 +2634,7 @@
|
||||
"telegramGlobal": "Allow Telegram notifications.",
|
||||
"ntfyGlobal": "Allow ntfy notifications.",
|
||||
"webPushGlobal": "Allow browser push notifications.",
|
||||
"moodLogGlobal": "Allow mood-log reminders.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug Report",
|
||||
"offlineGeoLabel": "Dane geograficzne",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.5.6",
|
||||
"version": "1.6.0",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- v1.6.0 — route-of-administration on medications.
|
||||
--
|
||||
-- Adds `delivery_form` (ORAL | INJECTION | OTHER), decoupled from the
|
||||
-- existing `treatment_class` discriminator. This lets the injection-site
|
||||
-- picker surface for ANY injection dose (not only GLP-1) and gives the
|
||||
-- one-time-injection shape a first-class flag (`one_shot = true` +
|
||||
-- `delivery_form = INJECTION`).
|
||||
--
|
||||
-- Every existing row backfills to ORAL via the column default, so the
|
||||
-- ADD COLUMN is a single non-blocking metadata operation on Postgres 11+
|
||||
-- (the default is a constant, not a volatile expression).
|
||||
|
||||
CREATE TYPE "medication_delivery_form" AS ENUM ('ORAL', 'INJECTION', 'OTHER');
|
||||
|
||||
ALTER TABLE "medications"
|
||||
ADD COLUMN "delivery_form" "medication_delivery_form" NOT NULL DEFAULT 'ORAL';
|
||||
@@ -823,6 +823,20 @@ enum MedicationCategory {
|
||||
@@map("medication_category")
|
||||
}
|
||||
|
||||
/// v1.6.0 — route of administration, decoupled from `treatmentClass`.
|
||||
/// Drives the injection-site picker (available for any INJECTION, not
|
||||
/// only GLP-1) and the one-time-injection shape (`oneShot = true` +
|
||||
/// `deliveryForm = INJECTION`). ORAL is the backfilled default for every
|
||||
/// pre-v1.6.0 row. OTHER is the catch-all for topical / inhaled / nasal
|
||||
/// routes that don't need injection-site tracking.
|
||||
enum MedicationDeliveryForm {
|
||||
ORAL
|
||||
INJECTION
|
||||
OTHER
|
||||
|
||||
@@map("medication_delivery_form")
|
||||
}
|
||||
|
||||
model Medication {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
@@ -862,6 +876,11 @@ model Medication {
|
||||
/// the migration backfills `oneShot = false` for every existing
|
||||
/// row.
|
||||
oneShot Boolean @default(false) @map("one_shot")
|
||||
/// v1.6.0 — route of administration. Decoupled from `treatmentClass`
|
||||
/// so the injection-site picker can surface for any INJECTION dose.
|
||||
/// A one-time injection is `oneShot = true` + `deliveryForm =
|
||||
/// INJECTION`. The migration backfills ORAL for every existing row.
|
||||
deliveryForm MedicationDeliveryForm @default(ORAL) @map("delivery_form")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
|
||||
@@ -39,8 +39,8 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const payload: BackupsList = {
|
||||
rows,
|
||||
// Mirrors the value surfaced by `/api/admin/status-overview` so the
|
||||
// backups page and the status card grid agree.
|
||||
// Matches the retention window the backup-prune job enforces so the
|
||||
// backups page states the same number the worker acts on.
|
||||
retentionDays: 90,
|
||||
};
|
||||
|
||||
|
||||
@@ -135,9 +135,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
meta: {
|
||||
since: parsed.since,
|
||||
count: samples.length,
|
||||
// Memory total in bytes is useful so the chart can render an
|
||||
// absolute label ("4.2 GiB / 8 GiB") in the tooltip without a
|
||||
// second round-trip. Latest row wins; falls back to 0 on empty.
|
||||
// Memory total in bytes feeds the tooltip's absolute label
|
||||
// ("X.X / Y.Y GiB") so the percentage isn't read in a vacuum.
|
||||
// Latest row wins; falls back to 0 on empty.
|
||||
memTotalBytes: rows.length
|
||||
? Number(rows[rows.length - 1].memTotalBytes)
|
||||
: 0,
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { count: vi.fn() },
|
||||
withingsConnection: { count: vi.fn() },
|
||||
notificationChannel: { count: vi.fn() },
|
||||
pushSubscription: { count: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
auditLog: { findFirst: vi.fn(), count: vi.fn() },
|
||||
dataBackup: { findFirst: vi.fn(), findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/worker-status", () => ({
|
||||
getWorkerStatus: vi.fn(() => ({
|
||||
running: true,
|
||||
startedAt: new Date(Date.now() - 3_600_000).toISOString(),
|
||||
lastHeartbeat: null,
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
errors: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const mocks = prisma as unknown as {
|
||||
user: { count: ReturnType<typeof vi.fn> };
|
||||
withingsConnection: { count: ReturnType<typeof vi.fn> };
|
||||
notificationChannel: { count: ReturnType<typeof vi.fn> };
|
||||
pushSubscription: { count: ReturnType<typeof vi.fn> };
|
||||
appSettings: { findUnique: ReturnType<typeof vi.fn> };
|
||||
auditLog: {
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
count: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
dataBackup: {
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
describe("GET /api/admin/status-overview", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// default returns
|
||||
mocks.user.count.mockResolvedValue(0);
|
||||
mocks.withingsConnection.count.mockResolvedValue(0);
|
||||
mocks.notificationChannel.count.mockResolvedValue(0);
|
||||
mocks.pushSubscription.count.mockResolvedValue(0);
|
||||
mocks.appSettings.findUnique.mockResolvedValue(null);
|
||||
mocks.auditLog.findFirst.mockResolvedValue(null);
|
||||
mocks.auditLog.count.mockResolvedValue(0);
|
||||
mocks.dataBackup.findFirst.mockResolvedValue(null);
|
||||
mocks.dataBackup.findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("returns the 6-section overview shape", async () => {
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data).toHaveProperty("users");
|
||||
expect(body.data).toHaveProperty("integrations");
|
||||
expect(body.data).toHaveProperty("monitoring");
|
||||
expect(body.data).toHaveProperty("backups");
|
||||
expect(body.data).toHaveProperty("maintenance");
|
||||
expect(body.data).toHaveProperty("auditLog");
|
||||
});
|
||||
|
||||
it("batches DB calls in a single Promise.allSettled (no N+1)", async () => {
|
||||
await GET();
|
||||
// 3 user.count + 1 withings + 1 ntfy notifications + 1 webPush + 3
|
||||
// moodLog/telegram done via user.count → expect user.count called 4 times
|
||||
// (total, admins, newThisWeek, moodLog, telegram)
|
||||
expect(mocks.user.count).toHaveBeenCalledTimes(5);
|
||||
expect(mocks.withingsConnection.count).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.notificationChannel.count).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.pushSubscription.count).toHaveBeenCalledTimes(1);
|
||||
// The route does not loop — every aggregate is a single query.
|
||||
expect(mocks.dataBackup.findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("P20: a single failed probe still returns the rest of the grid", async () => {
|
||||
// Simulate a transient DB failure on the withings count probe only.
|
||||
// Without Promise.allSettled the whole route would 500 and the entire
|
||||
// admin overview grid would go blank — with allSettled the failure
|
||||
// is isolated to its card (severity → "alert", value → 0) and the
|
||||
// other 5 cards still render.
|
||||
mocks.withingsConnection.count.mockRejectedValueOnce(
|
||||
new Error("simulated db timeout"),
|
||||
);
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
users: { severity: string };
|
||||
integrations: { severity: string; withings: number };
|
||||
backups: { severity: string };
|
||||
};
|
||||
};
|
||||
expect(body.data.integrations.severity).toBe("alert");
|
||||
expect(body.data.integrations.withings).toBe(0);
|
||||
// Untouched cards keep their normal severity.
|
||||
expect(body.data.users.severity).toBe("good");
|
||||
});
|
||||
|
||||
it("flags backups as alert when none exist", async () => {
|
||||
mocks.dataBackup.findFirst.mockResolvedValue(null);
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { backups: { severity: string } };
|
||||
};
|
||||
expect(body.data.backups.severity).toBe("alert");
|
||||
});
|
||||
|
||||
it("flags backups as good when fresh (<8d)", async () => {
|
||||
mocks.dataBackup.findFirst.mockResolvedValue({
|
||||
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
||||
});
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { backups: { severity: string } };
|
||||
};
|
||||
expect(body.data.backups.severity).toBe("good");
|
||||
});
|
||||
|
||||
it("flags monitoring as alert on a recent error (<24h)", async () => {
|
||||
mocks.auditLog.findFirst.mockImplementation(
|
||||
({ where }: { where: { action: unknown } }) => {
|
||||
const action = where.action as { startsWith?: string } | string;
|
||||
if (
|
||||
typeof action === "object" &&
|
||||
action.startsWith === "system.error"
|
||||
) {
|
||||
return Promise.resolve({ createdAt: new Date(Date.now() - 60_000) });
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
);
|
||||
mocks.appSettings.findUnique.mockResolvedValue({
|
||||
glitchtipEnabled: true,
|
||||
umamiEnabled: true,
|
||||
});
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { monitoring: { severity: string } };
|
||||
};
|
||||
expect(body.data.monitoring.severity).toBe("alert");
|
||||
});
|
||||
|
||||
it("flags maintenance as alert when worker is stopped", async () => {
|
||||
const wsModule = await import("@/lib/jobs/worker-status");
|
||||
vi.mocked(wsModule.getWorkerStatus).mockReturnValue({
|
||||
running: false,
|
||||
startedAt: null,
|
||||
lastHeartbeat: null,
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
errors: 0,
|
||||
});
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { maintenance: { severity: string; workerRunning: boolean } };
|
||||
};
|
||||
expect(body.data.maintenance.severity).toBe("alert");
|
||||
expect(body.data.maintenance.workerRunning).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,318 +0,0 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getWorkerStatus } from "@/lib/jobs/worker-status";
|
||||
|
||||
/**
|
||||
* Severity tokens map onto §2.6 status taxonomy of `docs/ui-guidelines.md`.
|
||||
* Returned as plain strings so the client renders dots + labels — not RAW
|
||||
* colours — and never leans on color alone for state.
|
||||
*/
|
||||
export type StatusSeverity = "good" | "info" | "caution" | "alert" | "pending";
|
||||
|
||||
export interface StatusOverview {
|
||||
users: {
|
||||
severity: StatusSeverity;
|
||||
total: number;
|
||||
admins: number;
|
||||
newThisWeek: number;
|
||||
};
|
||||
integrations: {
|
||||
severity: StatusSeverity;
|
||||
withings: number;
|
||||
moodLog: number;
|
||||
telegram: number;
|
||||
ntfy: number;
|
||||
webPush: number;
|
||||
};
|
||||
monitoring: {
|
||||
severity: StatusSeverity;
|
||||
glitchtipEnabled: boolean;
|
||||
umamiEnabled: boolean;
|
||||
wideEventsEnabled: boolean;
|
||||
lastErrorAt: string | null;
|
||||
};
|
||||
backups: {
|
||||
severity: StatusSeverity;
|
||||
lastBackupAt: string | null;
|
||||
backedUpUsers: number;
|
||||
retentionDays: number;
|
||||
};
|
||||
maintenance: {
|
||||
severity: StatusSeverity;
|
||||
workerRunning: boolean;
|
||||
workerUptimeSeconds: number | null;
|
||||
lastIdempotencyCleanup: string | null;
|
||||
lastAuditLogCleanup: string | null;
|
||||
};
|
||||
auditLog: {
|
||||
severity: StatusSeverity;
|
||||
eventsLast30d: number;
|
||||
lastLoginAt: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function severityForBackup(lastBackupAt: Date | null): StatusSeverity {
|
||||
if (!lastBackupAt) return "alert";
|
||||
const ageMs = Date.now() - lastBackupAt.getTime();
|
||||
if (ageMs > 14 * ONE_DAY_MS) return "alert";
|
||||
if (ageMs > 8 * ONE_DAY_MS) return "caution";
|
||||
return "good";
|
||||
}
|
||||
|
||||
function severityForMaintenance(workerRunning: boolean): StatusSeverity {
|
||||
return workerRunning ? "good" : "alert";
|
||||
}
|
||||
|
||||
function severityForMonitoring(
|
||||
glitchtipEnabled: boolean,
|
||||
umamiEnabled: boolean,
|
||||
recentErrorAt: Date | null,
|
||||
): StatusSeverity {
|
||||
if (recentErrorAt && Date.now() - recentErrorAt.getTime() < ONE_DAY_MS) {
|
||||
return "alert";
|
||||
}
|
||||
if (glitchtipEnabled && umamiEnabled) return "good";
|
||||
if (glitchtipEnabled || umamiEnabled) return "caution";
|
||||
return "info";
|
||||
}
|
||||
|
||||
function severityForIntegrations(totalConfigured: number): StatusSeverity {
|
||||
if (totalConfigured === 0) return "info";
|
||||
return "good";
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.status-overview" } });
|
||||
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * ONE_DAY_MS);
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * ONE_DAY_MS);
|
||||
|
||||
// P20: switch from Promise.all to Promise.allSettled so a single
|
||||
// failed probe (e.g. a missing index, a transient timeout, an
|
||||
// unavailable model) doesn't blank the whole admin overview grid.
|
||||
// Each probe is labelled so we can map a rejection back to the
|
||||
// overview field it was meant to populate, then surface the failed
|
||||
// labels in a Wide Event annotation for ops triage.
|
||||
const probes = [
|
||||
["userTotal", () => prisma.user.count()],
|
||||
["userAdmins", () => prisma.user.count({ where: { role: "ADMIN" } })],
|
||||
[
|
||||
"userNewThisWeek",
|
||||
() => prisma.user.count({ where: { createdAt: { gte: sevenDaysAgo } } }),
|
||||
],
|
||||
["withingsCount", () => prisma.withingsConnection.count()],
|
||||
[
|
||||
"moodLogCount",
|
||||
() => prisma.user.count({ where: { moodLogEnabled: true } }),
|
||||
],
|
||||
[
|
||||
"telegramCount",
|
||||
() => prisma.user.count({ where: { telegramEnabled: true } }),
|
||||
],
|
||||
[
|
||||
"ntfyCount",
|
||||
() =>
|
||||
prisma.notificationChannel.count({
|
||||
where: { type: "NTFY", enabled: true },
|
||||
}),
|
||||
],
|
||||
["webPushCount", () => prisma.pushSubscription.count()],
|
||||
[
|
||||
"appSettings",
|
||||
() => prisma.appSettings.findUnique({ where: { id: "singleton" } }),
|
||||
],
|
||||
[
|
||||
"lastErrorEntry",
|
||||
() =>
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: { startsWith: "system.error" } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"latestBackup",
|
||||
() =>
|
||||
prisma.dataBackup.findFirst({
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"backedUpUsers",
|
||||
() =>
|
||||
prisma.dataBackup
|
||||
.findMany({ select: { userId: true }, distinct: ["userId"] })
|
||||
.then((rows) => rows.length),
|
||||
],
|
||||
[
|
||||
"last30dEvents",
|
||||
() =>
|
||||
prisma.auditLog.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
|
||||
],
|
||||
[
|
||||
"lastLoginEntry",
|
||||
() =>
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "auth.login" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"lastIdempotencyCleanup",
|
||||
() =>
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "system.cleanup.idempotency" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"lastAuditLogCleanup",
|
||||
() =>
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "system.cleanup.audit-log" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
],
|
||||
] as const;
|
||||
|
||||
const settled = await Promise.allSettled(probes.map(([, run]) => run()));
|
||||
|
||||
const failed: string[] = [];
|
||||
const results: Record<string, unknown> = {};
|
||||
settled.forEach((r, i) => {
|
||||
const label = probes[i][0];
|
||||
if (r.status === "fulfilled") {
|
||||
results[label] = r.value;
|
||||
} else {
|
||||
failed.push(label);
|
||||
results[label] = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Pull each probe out in the same shape the overview expects. `?? 0`
|
||||
// turns a null (failed-count probe) into a renderable zero so the
|
||||
// grid tiles still draw something instead of going blank.
|
||||
const userTotal = (results.userTotal as number | null) ?? 0;
|
||||
const userAdmins = (results.userAdmins as number | null) ?? 0;
|
||||
const userNewThisWeek = (results.userNewThisWeek as number | null) ?? 0;
|
||||
const withingsCount = (results.withingsCount as number | null) ?? 0;
|
||||
const moodLogCount = (results.moodLogCount as number | null) ?? 0;
|
||||
const telegramCount = (results.telegramCount as number | null) ?? 0;
|
||||
const ntfyCount = (results.ntfyCount as number | null) ?? 0;
|
||||
const webPushCount = (results.webPushCount as number | null) ?? 0;
|
||||
const appSettings = results.appSettings as {
|
||||
glitchtipEnabled: boolean;
|
||||
umamiEnabled: boolean;
|
||||
} | null;
|
||||
const lastErrorEntry = results.lastErrorEntry as { createdAt: Date } | null;
|
||||
const latestBackup = results.latestBackup as { createdAt: Date } | null;
|
||||
const backedUpUsers = (results.backedUpUsers as number | null) ?? 0;
|
||||
const last30dEvents = (results.last30dEvents as number | null) ?? 0;
|
||||
const lastLoginEntry = results.lastLoginEntry as { createdAt: Date } | null;
|
||||
const lastIdempotencyCleanup = results.lastIdempotencyCleanup as {
|
||||
createdAt: Date;
|
||||
} | null;
|
||||
const lastAuditLogCleanup = results.lastAuditLogCleanup as {
|
||||
createdAt: Date;
|
||||
} | null;
|
||||
|
||||
if (failed.length > 0) {
|
||||
annotate({ meta: { statusOverviewProbeFailures: failed } });
|
||||
}
|
||||
|
||||
const worker = getWorkerStatus();
|
||||
const workerUptimeSeconds = worker.startedAt
|
||||
? Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(now.getTime() - new Date(worker.startedAt).getTime()) / 1000,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
const integrationsTotal =
|
||||
withingsCount + moodLogCount + telegramCount + ntfyCount + webPushCount;
|
||||
|
||||
// Map probe-name → which overview card depends on it. If any probe
|
||||
// for a card failed, force the card's severity to "alert" so the UI
|
||||
// surfaces the failure instead of showing zeros as if they were real.
|
||||
const failedSet = new Set(failed);
|
||||
const anyFailed = (...labels: string[]) =>
|
||||
labels.some((l) => failedSet.has(l));
|
||||
|
||||
const overview: StatusOverview = {
|
||||
users: {
|
||||
severity: anyFailed("userTotal", "userAdmins", "userNewThisWeek")
|
||||
? "alert"
|
||||
: "good",
|
||||
total: userTotal,
|
||||
admins: userAdmins,
|
||||
newThisWeek: userNewThisWeek,
|
||||
},
|
||||
integrations: {
|
||||
severity: anyFailed(
|
||||
"withingsCount",
|
||||
"moodLogCount",
|
||||
"telegramCount",
|
||||
"ntfyCount",
|
||||
"webPushCount",
|
||||
)
|
||||
? "alert"
|
||||
: severityForIntegrations(integrationsTotal),
|
||||
withings: withingsCount,
|
||||
moodLog: moodLogCount,
|
||||
telegram: telegramCount,
|
||||
ntfy: ntfyCount,
|
||||
webPush: webPushCount,
|
||||
},
|
||||
monitoring: {
|
||||
severity: anyFailed("appSettings", "lastErrorEntry")
|
||||
? "alert"
|
||||
: severityForMonitoring(
|
||||
Boolean(appSettings?.glitchtipEnabled),
|
||||
Boolean(appSettings?.umamiEnabled),
|
||||
lastErrorEntry?.createdAt ?? null,
|
||||
),
|
||||
glitchtipEnabled: Boolean(appSettings?.glitchtipEnabled),
|
||||
umamiEnabled: Boolean(appSettings?.umamiEnabled),
|
||||
wideEventsEnabled: true,
|
||||
lastErrorAt: lastErrorEntry?.createdAt?.toISOString() ?? null,
|
||||
},
|
||||
backups: {
|
||||
severity: anyFailed("latestBackup", "backedUpUsers")
|
||||
? "alert"
|
||||
: severityForBackup(latestBackup?.createdAt ?? null),
|
||||
lastBackupAt: latestBackup?.createdAt?.toISOString() ?? null,
|
||||
backedUpUsers,
|
||||
retentionDays: 90,
|
||||
},
|
||||
maintenance: {
|
||||
severity: anyFailed("lastIdempotencyCleanup", "lastAuditLogCleanup")
|
||||
? "alert"
|
||||
: severityForMaintenance(worker.running),
|
||||
workerRunning: worker.running,
|
||||
workerUptimeSeconds,
|
||||
lastIdempotencyCleanup:
|
||||
lastIdempotencyCleanup?.createdAt?.toISOString() ?? null,
|
||||
lastAuditLogCleanup:
|
||||
lastAuditLogCleanup?.createdAt?.toISOString() ?? null,
|
||||
},
|
||||
auditLog: {
|
||||
severity: anyFailed("last30dEvents", "lastLoginEntry") ? "alert" : "info",
|
||||
eventsLast30d: last30dEvents,
|
||||
lastLoginAt: lastLoginEntry?.createdAt?.toISOString() ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
return apiSuccess(overview);
|
||||
});
|
||||
@@ -544,6 +544,10 @@ describe("GET /api/dashboard/summary", () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "med-ramipril",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-morgens",
|
||||
@@ -551,6 +555,10 @@ describe("GET /api/dashboard/summary", () => {
|
||||
windowStart: "07:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: [],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -632,6 +640,10 @@ describe("GET /api/dashboard/summary", () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "med-ramipril",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-morgens",
|
||||
@@ -639,6 +651,10 @@ describe("GET /api/dashboard/summary", () => {
|
||||
windowStart: "07:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: [],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -95,7 +95,7 @@ describe("PUT /api/measurements/[id] — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
putReq({
|
||||
value: "string",
|
||||
measuredAt: "not-iso",
|
||||
notes: "a".repeat(200),
|
||||
notes: "a".repeat(201),
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
|
||||
@@ -91,6 +91,7 @@ export const PUT = apiHandler(
|
||||
category,
|
||||
treatmentClass,
|
||||
dosesPerUnit,
|
||||
deliveryForm,
|
||||
active,
|
||||
notificationsEnabled,
|
||||
schedules,
|
||||
@@ -180,6 +181,7 @@ export const PUT = apiHandler(
|
||||
...(dose !== undefined && { dose }),
|
||||
...(treatmentClass !== undefined && { treatmentClass }),
|
||||
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
||||
...(deliveryForm !== undefined && { deliveryForm }),
|
||||
...(active !== undefined && { active }),
|
||||
...(notificationsEnabled !== undefined && { notificationsEnabled }),
|
||||
// v1.5 scheduling primitives — pass-through when supplied.
|
||||
|
||||
@@ -162,6 +162,10 @@ describe("GET /api/medications/intake", () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "med-ramipril",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-morgens",
|
||||
@@ -169,6 +173,10 @@ describe("GET /api/medications/intake", () => {
|
||||
windowStart: "07:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: [],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -237,6 +245,10 @@ describe("GET /api/medications/intake", () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "med-ramipril",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-morgens",
|
||||
@@ -244,6 +256,10 @@ describe("GET /api/medications/intake", () => {
|
||||
windowStart: "07:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: [],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -121,6 +121,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
category,
|
||||
treatmentClass,
|
||||
dosesPerUnit,
|
||||
deliveryForm,
|
||||
notificationsEnabled,
|
||||
schedules,
|
||||
startsOn,
|
||||
@@ -181,6 +182,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// wire schema; Prisma fills the default GENERIC when omitted.
|
||||
...(treatmentClass !== undefined && { treatmentClass }),
|
||||
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
||||
// v1.6.0 — route of administration; Prisma defaults to ORAL when omitted.
|
||||
...(deliveryForm !== undefined && { deliveryForm }),
|
||||
// v1.5 — wizard's reminders toggle now ships through the create
|
||||
// payload (was orphaned in the initial diff). Prisma defaults to
|
||||
// true when omitted, matching the legacy form's behaviour.
|
||||
|
||||
@@ -43,6 +43,8 @@ import { TitrationSection } from "@/components/medications/TitrationSection";
|
||||
import { MedicationDetailHeader } from "@/components/medications/medication-detail-header";
|
||||
import { CadenceSummaryRow } from "@/components/medications/cadence-summary-row";
|
||||
import { IntakeHistoryPreview } from "@/components/medications/sections/intake-history-preview";
|
||||
import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
|
||||
import { SchedulingSection } from "@/components/medications/SchedulingSection";
|
||||
import { AdvancedSettingsSheet } from "@/components/medications/advanced-settings-sheet";
|
||||
import { PhaseConfigSheet } from "@/components/medications/sections/phase-config-sheet";
|
||||
import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog";
|
||||
@@ -67,6 +69,8 @@ interface MedicationDetailSnapshot {
|
||||
dose: string;
|
||||
category: string;
|
||||
treatmentClass?: string;
|
||||
deliveryForm?: string;
|
||||
dosesPerUnit?: number | null;
|
||||
active: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
pausedAt: string | null;
|
||||
@@ -103,6 +107,8 @@ function snapshotToWizardPayload(
|
||||
dose: med.dose,
|
||||
category: med.category,
|
||||
treatmentClass: med.treatmentClass,
|
||||
deliveryForm: med.deliveryForm,
|
||||
dosesPerUnit: med.dosesPerUnit ?? null,
|
||||
notificationsEnabled: med.notificationsEnabled,
|
||||
startsOn: med.startsOn ? new Date(med.startsOn) : null,
|
||||
endsOn: med.endsOn ? new Date(med.endsOn) : null,
|
||||
@@ -269,9 +275,9 @@ export default function MedicationDetailPage({
|
||||
onOpenAdvanced={() => setAdvancedOpen(true)}
|
||||
/>
|
||||
|
||||
{/* G-1 §3.3 — Static cadence line. One-shot collapses to the
|
||||
`Einmalig am …` card; recurring renders the summary row with
|
||||
`hideEdit` (cadence editing lives in the wizard). */}
|
||||
{/* G-1 §3.3 — Cadence line. One-shot collapses to the
|
||||
`Einmalig am …` card; recurring renders the summary row whose
|
||||
edit affordance opens the editor directly. */}
|
||||
{oneShot ? (
|
||||
<Card
|
||||
className="p-5 sm:p-6"
|
||||
@@ -288,8 +294,7 @@ export default function MedicationDetailPage({
|
||||
) : (
|
||||
<CadenceSummaryRow
|
||||
medication={payload}
|
||||
hideEdit
|
||||
onEdit={() => {}}
|
||||
onEdit={() => setWizardOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -304,6 +309,14 @@ export default function MedicationDetailPage({
|
||||
dose: medication.dose,
|
||||
}}
|
||||
/>
|
||||
{/* v1.6.0 — side-effect logbook + cadence/compliance section
|
||||
restored onto the detail page (they previously lived only
|
||||
on the legacy `/history` route). */}
|
||||
<SideEffectsSection medicationId={id} />
|
||||
<SchedulingSection
|
||||
medicationId={id}
|
||||
reminderEnabled={medication.notificationsEnabled}
|
||||
/>
|
||||
<TitrationSection medicationId={id} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
* - no `landingIntent` / `wizardIntent` / `openWizardWithIntent`
|
||||
* plumbing (the header dropdown is the only wizard entry, landing
|
||||
* on Step 1);
|
||||
* - the page hosts the `AdvancedSettingsSheet` and renders the
|
||||
* cadence row with `hideEdit`.
|
||||
* - the page hosts the `AdvancedSettingsSheet` and renders an
|
||||
* editable cadence row (the v1.6.0 editor opens directly from the
|
||||
* row, so `hideEdit` is gone).
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
@@ -41,8 +42,10 @@ describe("medication detail page is a pure history surface (G-1 §3)", () => {
|
||||
expect(source).not.toContain("openWizardWithIntent");
|
||||
});
|
||||
|
||||
it("hosts the AdvancedSettingsSheet and the static cadence row", () => {
|
||||
it("hosts the AdvancedSettingsSheet and an editable cadence row", () => {
|
||||
expect(source).toContain("AdvancedSettingsSheet");
|
||||
expect(source).toContain("hideEdit");
|
||||
// v1.6.0 — the cadence row opens the editor directly; the dead
|
||||
// `hideEdit` prop is gone.
|
||||
expect(source).not.toContain("hideEdit");
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,8 @@ interface Medication {
|
||||
treatmentClass?: string;
|
||||
/** v1.4.25 W4d — doses per pen/vial for inventory math. */
|
||||
dosesPerUnit?: number | null;
|
||||
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
|
||||
deliveryForm?: string;
|
||||
active: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
pausedAt: string | null;
|
||||
@@ -284,6 +286,8 @@ function medicationToPayload(med: Medication): MedicationPayload {
|
||||
dose: med.dose,
|
||||
category: med.category,
|
||||
treatmentClass: med.treatmentClass,
|
||||
deliveryForm: med.deliveryForm,
|
||||
dosesPerUnit: med.dosesPerUnit ?? null,
|
||||
notificationsEnabled: med.notificationsEnabled,
|
||||
startsOn: med.startsOn ? new Date(med.startsOn) : null,
|
||||
endsOn: med.endsOn ? new Date(med.endsOn) : null,
|
||||
|
||||
+8
-6
@@ -123,9 +123,9 @@ function getRangeColorClass(
|
||||
const inOrange =
|
||||
!inGreen && value >= range.orangeMin && value <= range.orangeMax;
|
||||
|
||||
if (inGreen) return "text-green-400";
|
||||
if (inOrange) return "text-orange-400";
|
||||
return "text-red-400";
|
||||
if (inGreen) return "text-success";
|
||||
if (inOrange) return "text-warning";
|
||||
return "text-destructive";
|
||||
}
|
||||
|
||||
function getRangeHint(
|
||||
@@ -142,20 +142,22 @@ function getRangeHint(
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<span className="font-bold text-green-400">
|
||||
<span className="text-success font-bold">
|
||||
{t("charts.colorGreen")}
|
||||
</span>{" "}
|
||||
{format(range.greenMin)}-{format(range.greenMax)} {unit}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-bold text-orange-400">
|
||||
<span className="text-warning font-bold">
|
||||
{t("charts.colorOrange")}
|
||||
</span>{" "}
|
||||
{format(range.orangeMin)}-{format(range.greenMin)} {t("common.or")}{" "}
|
||||
{format(range.greenMax)}-{format(range.orangeMax)} {unit}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-bold text-red-400">{t("charts.colorRed")}</span>{" "}
|
||||
<span className="text-destructive font-bold">
|
||||
{t("charts.colorRed")}
|
||||
</span>{" "}
|
||||
{"< "}
|
||||
{format(range.orangeMin)} {t("common.or")} {"> "}
|
||||
{format(range.orangeMax)} {unit}
|
||||
|
||||
@@ -236,9 +236,9 @@ export default function TargetsPage() {
|
||||
|
||||
{/* Profile incomplete hint */}
|
||||
{profileIncomplete && (
|
||||
<Card className="border-l-4 border-l-orange-500">
|
||||
<Card className="border-warning border-l-4">
|
||||
<CardContent className="flex gap-3 py-3">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-orange-500" />
|
||||
<AlertCircle className="text-warning mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("targets.profileIncomplete")}
|
||||
|
||||
@@ -532,3 +532,32 @@ export function useSystemStatus() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Helpful-rate tint shared by the AI-quality + Coach-feedback tables.
|
||||
* Green ≥80 %, yellow ≥50 %, orange below. */
|
||||
export function helpfulRateColour(rate: number): string {
|
||||
if (rate >= 0.8) return "text-dracula-green";
|
||||
if (rate >= 0.5) return "text-dracula-yellow";
|
||||
return "text-dracula-orange";
|
||||
}
|
||||
|
||||
export interface PublicVersion {
|
||||
version: string;
|
||||
buildSha: string | null;
|
||||
builtAt: string | null;
|
||||
offlineGeoEnabled?: boolean;
|
||||
}
|
||||
|
||||
/** Shared `/api/version` reader — the system-status section + overview
|
||||
* summary both surface app version / build SHA / offline-geo state. */
|
||||
export function usePublicVersion() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.publicVersion(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/version");
|
||||
if (!res.ok) throw new Error("Failed to load version");
|
||||
return (await res.json()).data as PublicVersion;
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
@@ -17,18 +17,20 @@ import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
Database,
|
||||
FileText,
|
||||
Info,
|
||||
KeyRound,
|
||||
Inbox,
|
||||
MessageSquare,
|
||||
Plug,
|
||||
Radio,
|
||||
ScrollText,
|
||||
Server,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
@@ -65,12 +67,12 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
|
||||
{
|
||||
slug: "services",
|
||||
titleKey: "admin.section.services.title",
|
||||
icon: Plug,
|
||||
icon: Radio,
|
||||
},
|
||||
{
|
||||
slug: "integrations",
|
||||
titleKey: "admin.section.integrations.title",
|
||||
icon: AlertTriangle,
|
||||
icon: Plug,
|
||||
},
|
||||
{
|
||||
slug: "ai-quality",
|
||||
@@ -80,12 +82,12 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
|
||||
{
|
||||
slug: "assistant",
|
||||
titleKey: "admin.section.assistant.title",
|
||||
icon: Sparkles,
|
||||
icon: SlidersHorizontal,
|
||||
},
|
||||
{
|
||||
slug: "coach-feedback",
|
||||
titleKey: "admin.section.coach-feedback.title",
|
||||
icon: Sparkles,
|
||||
icon: MessageSquare,
|
||||
},
|
||||
{
|
||||
slug: "feedback",
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Sparkles, Loader2 } from "lucide-react";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { helpfulRateColour } from "./_shared";
|
||||
|
||||
interface FeedbackBucket {
|
||||
severity: string;
|
||||
@@ -53,12 +54,6 @@ const SEVERITY_TINT: Record<string, string> = {
|
||||
urgent: "text-dracula-red",
|
||||
};
|
||||
|
||||
function helpfulRateColour(rate: number): string {
|
||||
if (rate >= 0.8) return "text-dracula-green";
|
||||
if (rate >= 0.5) return "text-dracula-yellow";
|
||||
return "text-dracula-orange";
|
||||
}
|
||||
|
||||
export function AiQualitySection() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Loader2, Sparkles } from "lucide-react";
|
||||
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { helpfulRateColour } from "./_shared";
|
||||
|
||||
interface CoachFeedbackBucket {
|
||||
promptVersion: string;
|
||||
@@ -45,12 +46,6 @@ interface CoachFeedbackResponse {
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
function helpfulRateColour(rate: number): string {
|
||||
if (rate >= 0.8) return "text-dracula-green";
|
||||
if (rate >= 0.5) return "text-dracula-yellow";
|
||||
return "text-dracula-orange";
|
||||
}
|
||||
|
||||
export function CoachFeedbackSection() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
@@ -148,6 +148,10 @@ export function HostMetricsChart() {
|
||||
[data?.samples],
|
||||
);
|
||||
|
||||
// Total physical memory (latest sample) — lets the tooltip pair the
|
||||
// percentage with an absolute "X.X GiB / Y.Y GiB" label. 0 ⇒ omit.
|
||||
const memTotalGiB = (data?.meta.memTotalBytes ?? 0) / 1_073_741_824;
|
||||
|
||||
// Loading skeleton: same footprint as the chart so the panel doesn't
|
||||
// jump when data lands. Animated only when motion isn't reduced.
|
||||
if (isLoading) {
|
||||
@@ -282,6 +286,13 @@ export function HostMetricsChart() {
|
||||
return [fmt.number(value, 2), name];
|
||||
}
|
||||
if (name === t("admin.hostMetrics.memUsedPercent")) {
|
||||
if (memTotalGiB > 0) {
|
||||
const usedGiB = (value / 100) * memTotalGiB;
|
||||
return [
|
||||
`${fmt.number(value, 1)} % · ${fmt.number(usedGiB, 1)} / ${fmt.number(memTotalGiB, 1)} GiB`,
|
||||
name,
|
||||
];
|
||||
}
|
||||
return [`${fmt.number(value, 1)} %`, name];
|
||||
}
|
||||
if (name === t("admin.hostMetrics.diskBusyPercent")) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Bell, Globe, Key, MessageCircle } from "lucide-react";
|
||||
import { Bell, Globe, Key, MessageCircle, Smile } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
|
||||
@@ -49,6 +49,16 @@ export function ServicesSection() {
|
||||
}
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label="moodLog"
|
||||
description={t("admin.moodLogGlobal")}
|
||||
icon={Smile}
|
||||
checked={settings?.moodLogGlobal ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSettings.mutate({ moodLogGlobal: checked })
|
||||
}
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label="API"
|
||||
description={t("admin.apiGlobal")}
|
||||
|
||||
@@ -18,31 +18,10 @@ import {
|
||||
Server,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { StatusItem, useSystemStatus } from "./_shared";
|
||||
|
||||
interface VersionResponse {
|
||||
version: string;
|
||||
buildSha: string | null;
|
||||
builtAt: string | null;
|
||||
offlineGeoEnabled?: boolean;
|
||||
}
|
||||
|
||||
function useOfflineGeoState() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.publicVersion(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/version");
|
||||
if (!res.ok) throw new Error("Failed to load version");
|
||||
return (await res.json()).data as VersionResponse;
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
|
||||
|
||||
// v1.4.16 phase B3: Recharts is ~108 KiB Brotli — defer-load the host
|
||||
// metrics chart so the system-status page only ships it when an admin
|
||||
@@ -66,7 +45,7 @@ export function SystemStatusSection() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { data: status, isError, refetch, isFetching } = useSystemStatus();
|
||||
const { data: version } = useOfflineGeoState();
|
||||
const { data: version } = usePublicVersion();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -22,35 +22,14 @@ import {
|
||||
Server,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { StatusItem, useSystemStatus } from "./_shared";
|
||||
|
||||
interface VersionResponse {
|
||||
version: string;
|
||||
buildSha: string | null;
|
||||
builtAt: string | null;
|
||||
offlineGeoEnabled?: boolean;
|
||||
}
|
||||
|
||||
function useVersion() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.publicVersion(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/version");
|
||||
if (!res.ok) throw new Error("Failed to load version");
|
||||
return (await res.json()).data as VersionResponse;
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
|
||||
|
||||
export function SystemStatusSummary() {
|
||||
const { t } = useTranslations();
|
||||
const { data: status, isError } = useSystemStatus();
|
||||
const { data: version } = useVersion();
|
||||
const { data: version } = usePublicVersion();
|
||||
|
||||
return (
|
||||
<section
|
||||
|
||||
@@ -24,8 +24,9 @@ import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
|
||||
import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/validations/measurement";
|
||||
|
||||
const MAX_COMMENT_LENGTH = 25;
|
||||
const MAX_COMMENT_LENGTH = MEASUREMENT_NOTES_MAX_LENGTH;
|
||||
|
||||
const MEASUREMENT_TYPES = [
|
||||
{
|
||||
|
||||
@@ -60,6 +60,7 @@ import { formatDateOrRelative, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum";
|
||||
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
|
||||
import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/validations/measurement";
|
||||
import { DateTimeInput } from "@/components/ui/date-input";
|
||||
import {
|
||||
MEASUREMENT_TYPE_LABEL_KEYS as TYPE_LABEL_KEYS,
|
||||
@@ -105,11 +106,14 @@ interface MeasurementListProps {
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const MAX_COMMENT_LENGTH = 25;
|
||||
// Input cap mirrors the server Zod bound. The list preview truncates at
|
||||
// a shorter width so a long note does not stretch a list row.
|
||||
const MAX_COMMENT_LENGTH = MEASUREMENT_NOTES_MAX_LENGTH;
|
||||
const COMMENT_PREVIEW_LENGTH = 40;
|
||||
|
||||
function truncateComment(comment: string): string {
|
||||
if (comment.length <= MAX_COMMENT_LENGTH) return comment;
|
||||
return `${comment.slice(0, MAX_COMMENT_LENGTH - 1)}…`;
|
||||
if (comment.length <= COMMENT_PREVIEW_LENGTH) return comment;
|
||||
return `${comment.slice(0, COMMENT_PREVIEW_LENGTH - 1)}…`;
|
||||
}
|
||||
|
||||
function toDateTimeLocalValue(isoString: string): string {
|
||||
|
||||
@@ -26,35 +26,6 @@ vi.mock("@/lib/i18n/context", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// `<DropdownMenu>` wraps a Radix portal that `renderToStaticMarkup`
|
||||
// does not materialise while closed; mock the primitives to
|
||||
// passthroughs so the trigger + menu items are reachable in static
|
||||
// markup and the `onSelect` wiring is assertable (G-1 §4).
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-slot="mock-dropdown">{children}</div>
|
||||
),
|
||||
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-slot="mock-dropdown-trigger">{children}</div>
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-slot="mock-dropdown-content">{children}</div>
|
||||
),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
...rest
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onSelect?: () => void;
|
||||
[key: string]: unknown;
|
||||
}) => (
|
||||
<button onClick={onSelect} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import { MedicationDetailHeader } from "@/components/medications/medication-detail-header";
|
||||
|
||||
function render(props: {
|
||||
@@ -109,20 +80,22 @@ describe("MedicationDetailHeader (D-3 §9.1)", () => {
|
||||
expect(headingIndex).toBeLessThan(buttonIndex);
|
||||
});
|
||||
|
||||
it("renders the two-option Bearbeiten picker (G-1 §4)", () => {
|
||||
it("renders the edit button beside the advanced-settings gear (v1.6.0)", () => {
|
||||
const html = render({ active: true });
|
||||
expect(html).toContain("medications.detail.edit.planOption");
|
||||
expect(html).toContain("medications.detail.edit.advancedOption");
|
||||
expect(html).toContain('data-slot="medication-detail-edit-plan"');
|
||||
expect(html).toContain('data-slot="medication-detail-edit-advanced"');
|
||||
expect(html).toContain("common.edit");
|
||||
expect(html).toContain('data-slot="medication-detail-edit-button"');
|
||||
expect(html).toContain('data-slot="medication-detail-advanced-button"');
|
||||
expect(html).toContain('aria-label="medications.detail.edit.advancedOption"');
|
||||
});
|
||||
|
||||
it("orders Plan bearbeiten above Erweiterte Einstellungen", () => {
|
||||
it("orders the primary edit button before the advanced gear", () => {
|
||||
const html = render({ active: true });
|
||||
const plan = html.indexOf("medications.detail.edit.planOption");
|
||||
const advanced = html.indexOf("medications.detail.edit.advancedOption");
|
||||
expect(plan).toBeGreaterThan(-1);
|
||||
expect(advanced).toBeGreaterThan(plan);
|
||||
const edit = html.indexOf('data-slot="medication-detail-edit-button"');
|
||||
const advanced = html.indexOf(
|
||||
'data-slot="medication-detail-advanced-button"',
|
||||
);
|
||||
expect(edit).toBeGreaterThan(-1);
|
||||
expect(advanced).toBeGreaterThan(edit);
|
||||
});
|
||||
|
||||
it("status pill text always renders (no colour-only state)", () => {
|
||||
|
||||
@@ -63,7 +63,7 @@ export function AdvancedSettingsSheet({
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("medications.detail.advanced.title")}
|
||||
className="sm:max-w-lg"
|
||||
contentWidth="lg"
|
||||
bodyClassName="gap-6"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -18,16 +18,10 @@
|
||||
* destructive zone, not on the header (E-2 M-1).
|
||||
*/
|
||||
|
||||
import { ChevronDown, Pencil } from "lucide-react";
|
||||
import { Pencil, Settings2 } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export interface MedicationDetailHeaderProps {
|
||||
@@ -36,13 +30,15 @@ export interface MedicationDetailHeaderProps {
|
||||
active: boolean;
|
||||
endsOn?: string | null;
|
||||
/**
|
||||
* v1.5.6 G-1 §4 — "Plan bearbeiten" opens the wizard (mode="edit",
|
||||
* Step 1). Fired from the Bearbeiten dropdown's first item.
|
||||
* v1.6.0 — the single "Bearbeiten" button opens the unified editor
|
||||
* (mode="edit"). The two-option dropdown the v1.5.6 header carried
|
||||
* collapsed to one direct button so editing is a single tap.
|
||||
*/
|
||||
onEditPlan: () => void;
|
||||
/**
|
||||
* v1.5.6 G-1 §4 — "Erweiterte Einstellungen" opens the
|
||||
* `<AdvancedSettingsSheet>`. Fired from the dropdown's second item.
|
||||
* v1.6.0 — the gear button opens the `<AdvancedSettingsSheet>`
|
||||
* (notifications / API tokens / Phasen / destructive zone). Kept as
|
||||
* a discreet secondary affordance beside the primary edit button.
|
||||
*/
|
||||
onOpenAdvanced: () => void;
|
||||
}
|
||||
@@ -109,34 +105,28 @@ export function MedicationDetailHeader({
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="medication-detail-edit-button"
|
||||
>
|
||||
<Pencil aria-hidden="true" className="h-4 w-4" />
|
||||
<span>{t("common.edit")}</span>
|
||||
<ChevronDown aria-hidden="true" className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={onEditPlan}
|
||||
data-slot="medication-detail-edit-plan"
|
||||
>
|
||||
{t("medications.detail.edit.planOption")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={onOpenAdvanced}
|
||||
data-slot="medication-detail-edit-advanced"
|
||||
>
|
||||
{t("medications.detail.edit.advancedOption")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={onEditPlan}
|
||||
data-slot="medication-detail-edit-button"
|
||||
>
|
||||
<Pencil aria-hidden="true" className="h-4 w-4" />
|
||||
<span>{t("common.edit")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
onClick={onOpenAdvanced}
|
||||
aria-label={t("medications.detail.edit.advancedOption")}
|
||||
data-slot="medication-detail-advanced-button"
|
||||
>
|
||||
<Settings2 aria-hidden="true" className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,14 @@ import { toast } from "sonner";
|
||||
import { Copy, KeyRound } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
invalidateKeys,
|
||||
@@ -34,6 +42,8 @@ export interface ApiTokensRowProps {
|
||||
medicationName: string;
|
||||
}
|
||||
|
||||
type ExampleType = "curl" | "wget" | "fetch" | "powershell";
|
||||
|
||||
interface ApiEndpointStatus {
|
||||
enabled: boolean;
|
||||
activeTokenCount: number;
|
||||
@@ -47,6 +57,7 @@ export function ApiTokensRow({
|
||||
const queryClient = useQueryClient();
|
||||
const [mintedToken, setMintedToken] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [exampleType, setExampleType] = useState<ExampleType>("curl");
|
||||
|
||||
// v1.5.5 F-1 H-2 — single source of truth for the per-medication
|
||||
// api-endpoint key. The earlier inline `useMemo` minted the same
|
||||
@@ -74,6 +85,52 @@ export function ApiTokensRow({
|
||||
typeof window !== "undefined" ? window.location.origin : "https://...";
|
||||
const endpoint = `${baseUrl}/api/ingest/medication`;
|
||||
|
||||
// The minted token is wired straight into the snippets the moment it
|
||||
// exists; before that the templates carry a `YOUR_TOKEN` placeholder.
|
||||
// The idempotency key uses an `intake-` prefix so a re-POST of the
|
||||
// same logical intake collapses to one row.
|
||||
const tokenForExample = mintedToken ?? "YOUR_TOKEN";
|
||||
const examplePayload = `{"medicationName":"${medicationName}","idempotencyKey":"intake-202602191230"}`;
|
||||
const exampleMap: Record<ExampleType, { label: string; value: string }> = {
|
||||
curl: {
|
||||
label: "cURL",
|
||||
value: `curl -X POST ${endpoint} \\
|
||||
-H "Authorization: Bearer ${tokenForExample}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '${examplePayload}'`,
|
||||
},
|
||||
wget: {
|
||||
label: "wget",
|
||||
value: `wget --method=POST "${endpoint}" \\
|
||||
--header="Authorization: Bearer ${tokenForExample}" \\
|
||||
--header="Content-Type: application/json" \\
|
||||
--body-data='${examplePayload}' \\
|
||||
-O -`,
|
||||
},
|
||||
fetch: {
|
||||
label: "JavaScript fetch",
|
||||
value: `await fetch("${endpoint}", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": "Bearer ${tokenForExample}",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
medicationName: "${medicationName}",
|
||||
idempotencyKey: "intake-" + Date.now()
|
||||
})
|
||||
});`,
|
||||
},
|
||||
powershell: {
|
||||
label: "PowerShell",
|
||||
value: `Invoke-RestMethod -Method Post -Uri "${endpoint}" \`
|
||||
-Headers @{ Authorization = "Bearer ${tokenForExample}" } \`
|
||||
-ContentType "application/json" \`
|
||||
-Body '${examplePayload}'`,
|
||||
},
|
||||
};
|
||||
const selectedExample = exampleMap[exampleType];
|
||||
|
||||
async function copy(text: string, label: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
@@ -199,6 +256,47 @@ export function ApiTokensRow({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* v1.6.0 — multi-language request snippets (restored from the
|
||||
v1.5.4 endpoint dialog). The minted token is interpolated
|
||||
live; until then the snippet carries a `YOUR_TOKEN` stand-in. */}
|
||||
<div className="space-y-1.5 pt-1" data-slot="api-tokens-examples">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs font-medium">
|
||||
{t("medications.requestExample")}
|
||||
</Label>
|
||||
<Select
|
||||
value={exampleType}
|
||||
onValueChange={(value) => setExampleType(value as ExampleType)}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-[170px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="curl">cURL</SelectItem>
|
||||
<SelectItem value="wget">wget</SelectItem>
|
||||
<SelectItem value="fetch">JavaScript fetch</SelectItem>
|
||||
<SelectItem value="powershell">PowerShell</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<pre className="bg-muted rounded-lg p-3 pr-10 font-mono text-xs break-all whitespace-pre-wrap">
|
||||
{selectedExample.value}
|
||||
</pre>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-1 right-1 h-7 w-7"
|
||||
onClick={() =>
|
||||
void copy(selectedExample.value, t("common.copied"))
|
||||
}
|
||||
aria-label={t("common.copy")}
|
||||
>
|
||||
<Copy aria-hidden="true" className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.5.5 D-3 §9.2 — Today's-dose card.
|
||||
*
|
||||
* Two buttons: `[Genommen]` posts a confirmed-taken intake;
|
||||
* `[Übersprungen]` posts a skipped one. The wire shape is the one the
|
||||
* intake POST already understands:
|
||||
*
|
||||
* `{ scheduledFor: <today-iso>, takenAt: <now-iso>, skipped: boolean }`
|
||||
*
|
||||
* (The C-E2-1 fix dropped the "Verschoben / DEFERRED" third branch that
|
||||
* an earlier draft proposed but the schema never carried.)
|
||||
*
|
||||
* Optimistic update: the moment the request fires, the card flips into
|
||||
* read-only `Heute genommen um HH:MM` (or its skipped twin). On error
|
||||
* the read-only state rolls back and an inline destructive note
|
||||
* surfaces. The polite live region (the parent's `<Toaster>`) carries
|
||||
* the toast announcement; this card additionally persists state
|
||||
* visibly in the same paint as the toast (E-4 C-1).
|
||||
*
|
||||
* Paused state: when the medication is inactive both buttons render
|
||||
* `aria-disabled` + visually muted with a single helper line.
|
||||
*
|
||||
* Cache: invalidates the bundle via `medicationDependentKeys` so every
|
||||
* downstream tile (inline compliance chart, dashboard rollup tile)
|
||||
* picks the new event up in one tick.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Check, Loader2, SkipForward } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
|
||||
export interface TodaysDoseCardProps {
|
||||
medicationId: string;
|
||||
active: boolean;
|
||||
oneShot: boolean;
|
||||
/**
|
||||
* Server-side "due today" decision. The detail page derives it from
|
||||
* the medication's cadence + the cached intake list and passes it
|
||||
* in so the card stays presentation-only.
|
||||
*/
|
||||
scheduledForToday: boolean;
|
||||
/**
|
||||
* If the user already logged today's intake, the time the row
|
||||
* captured. The card uses it to render the read-only state
|
||||
* immediately on mount (rather than waiting for the optimistic
|
||||
* branch to populate it).
|
||||
*/
|
||||
alreadyTakenAt?: string | null;
|
||||
alreadySkipped?: boolean;
|
||||
}
|
||||
|
||||
type Optimistic =
|
||||
| { kind: "idle" }
|
||||
| { kind: "submitting"; skipped: boolean }
|
||||
| { kind: "done"; at: string; skipped: boolean }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
export function TodaysDoseCard({
|
||||
medicationId,
|
||||
active,
|
||||
oneShot,
|
||||
scheduledForToday,
|
||||
alreadyTakenAt,
|
||||
alreadySkipped,
|
||||
}: TodaysDoseCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const formatters = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const initialState: Optimistic = alreadyTakenAt
|
||||
? { kind: "done", at: alreadyTakenAt, skipped: false }
|
||||
: alreadySkipped
|
||||
? { kind: "done", at: new Date().toISOString(), skipped: true }
|
||||
: { kind: "idle" };
|
||||
|
||||
const [state, setState] = useState<Optimistic>(initialState);
|
||||
|
||||
const liveLabel = useMemo(() => {
|
||||
if (state.kind === "done") {
|
||||
const timeLabel = formatters.dateTime(state.at);
|
||||
if (state.skipped) {
|
||||
return t("medications.detail.today.recordedSkipped", {
|
||||
time: timeLabel,
|
||||
});
|
||||
}
|
||||
return oneShot
|
||||
? t("medications.detail.today.recordedOneShot", { time: timeLabel })
|
||||
: t("medications.detail.today.recordedTaken", { time: timeLabel });
|
||||
}
|
||||
return null;
|
||||
}, [formatters, oneShot, state, t]);
|
||||
|
||||
async function submit(skipped: boolean) {
|
||||
if (state.kind === "submitting") return;
|
||||
setState({ kind: "submitting", skipped });
|
||||
const now = new Date();
|
||||
const body = {
|
||||
scheduledFor: now.toISOString(),
|
||||
takenAt: skipped ? null : now.toISOString(),
|
||||
skipped,
|
||||
};
|
||||
try {
|
||||
const res = await fetch(`/api/medications/${medicationId}/intake`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
const message = json.error ?? t("medications.detail.today.error");
|
||||
setState({ kind: "error", message });
|
||||
return;
|
||||
}
|
||||
setState({ kind: "done", at: now.toISOString(), skipped });
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
toast.success(
|
||||
skipped
|
||||
? t("medications.detail.today.toastSkipped")
|
||||
: t("medications.detail.today.toastTaken"),
|
||||
);
|
||||
} catch {
|
||||
setState({
|
||||
kind: "error",
|
||||
message: t("medications.detail.today.error"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Paused / inactive state: both buttons sit muted with one helper.
|
||||
if (!active) {
|
||||
return (
|
||||
<Card
|
||||
className="p-5 sm:p-6 space-y-4"
|
||||
data-slot="todays-dose-card"
|
||||
data-state="paused"
|
||||
>
|
||||
<p
|
||||
className="text-muted-foreground text-sm"
|
||||
data-slot="todays-dose-paused"
|
||||
>
|
||||
{t("medications.detail.today.pausedHint")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
className="min-h-11 sm:min-h-9 opacity-60"
|
||||
>
|
||||
<Check aria-hidden="true" className="mr-2 h-4 w-4" />
|
||||
{t("medications.detail.today.taken")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
className="min-h-11 sm:min-h-9 opacity-60"
|
||||
>
|
||||
<SkipForward aria-hidden="true" className="mr-2 h-4 w-4" />
|
||||
{t("medications.detail.today.skipped")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Read-only post-log state — switches in the same paint as the toast
|
||||
// so the user sees the dose persisted without a refresh round-trip.
|
||||
if (state.kind === "done") {
|
||||
return (
|
||||
<Card
|
||||
className="p-5 sm:p-6 space-y-2"
|
||||
data-slot="todays-dose-card"
|
||||
data-state="done"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<p className="text-foreground text-sm font-medium">{liveLabel}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty: nothing scheduled for today (recurring path only).
|
||||
if (!scheduledForToday && !oneShot) {
|
||||
return (
|
||||
<Card
|
||||
className="p-5 sm:p-6"
|
||||
data-slot="todays-dose-card"
|
||||
data-state="empty"
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.detail.today.noneScheduled")}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const submitting = state.kind === "submitting";
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="p-5 sm:p-6 space-y-4"
|
||||
data-slot="todays-dose-card"
|
||||
data-state="idle"
|
||||
>
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
aria-label={t("medications.detail.today.groupLabel")}
|
||||
>
|
||||
<Button
|
||||
onClick={() => void submit(false)}
|
||||
disabled={submitting}
|
||||
aria-busy={submitting && !state.skipped ? true : undefined}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="todays-dose-taken"
|
||||
>
|
||||
{submitting && !state.skipped ? (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
) : (
|
||||
<Check aria-hidden="true" className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("medications.detail.today.taken")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void submit(true)}
|
||||
disabled={submitting}
|
||||
aria-busy={submitting && state.skipped ? true : undefined}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="todays-dose-skipped"
|
||||
>
|
||||
{submitting && state.skipped ? (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
) : (
|
||||
<SkipForward aria-hidden="true" className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("medications.detail.today.skipped")}
|
||||
</Button>
|
||||
</div>
|
||||
{state.kind === "error" && (
|
||||
<p
|
||||
className="text-destructive text-sm"
|
||||
role="alert"
|
||||
data-slot="todays-dose-error"
|
||||
>
|
||||
{state.message}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -338,11 +338,14 @@ function WizardDialogShell({
|
||||
title={headerTitle}
|
||||
hideHeader
|
||||
showCloseButton
|
||||
// v1.5.5 D-3 §8 — desktop shell widens from `sm:max-w-md`
|
||||
// (448 px) to the 560 px ceiling the rest of the v1.5
|
||||
// surfaces use; mobile bottom-sheet keeps the `40dvh` floor
|
||||
// so the sheet never collapses to a stub on short screens.
|
||||
className="sm:max-w-[560px] min-h-[40dvh] md:min-h-0"
|
||||
// v1.6.0 — the unified editor widens to the 2xl ceiling on
|
||||
// desktop so the sectioned form (Identity / Plan / Reminders /
|
||||
// Specific) reads comfortably without the cramped 560 px
|
||||
// column the v1.5 wizard used. The mobile bottom-sheet keeps
|
||||
// the `40dvh` floor so the sheet never collapses to a stub on
|
||||
// short screens.
|
||||
contentWidth="2xl"
|
||||
className="min-h-[40dvh] md:min-h-0"
|
||||
bodyClassName="gap-0 p-0"
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
|
||||
@@ -72,7 +72,16 @@ export function Step2Class({ payload, applyPartial }: StepProps) {
|
||||
name="wizard-class"
|
||||
value={row}
|
||||
checked={selected}
|
||||
onChange={() => applyPartial({ treatmentRow: row })}
|
||||
onChange={() =>
|
||||
applyPartial(
|
||||
// The GLP-1 row is always injected; nudge the
|
||||
// delivery form so the common case needs no extra
|
||||
// tap. The user can still flip it on Step 3.
|
||||
row === "glp1"
|
||||
? { treatmentRow: row, deliveryForm: "INJECTION" }
|
||||
: { treatmentRow: row },
|
||||
)
|
||||
}
|
||||
className="sr-only"
|
||||
aria-label={label}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
@@ -9,6 +12,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InjectionSitePicker } from "@/components/medications/injection-site-picker";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import type { MedicationDeliveryForm } from "@/lib/validations/medication";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { StepProps } from "./Step1Name";
|
||||
@@ -33,47 +40,155 @@ const DOSE_UNIT_KEYS = [
|
||||
"other",
|
||||
] as const;
|
||||
|
||||
const DELIVERY_FORM_KEYS: readonly MedicationDeliveryForm[] = [
|
||||
"ORAL",
|
||||
"INJECTION",
|
||||
"OTHER",
|
||||
];
|
||||
|
||||
export function Step3Dose({ payload, applyPartial }: StepProps) {
|
||||
const { t } = useTranslations();
|
||||
// The injection-site rotation map is a visual aide on the editor: the
|
||||
// chosen site is persisted per intake-event (no medication-level
|
||||
// column), so the picker selection here is a local preview the user
|
||||
// collapses when not needed.
|
||||
const [siteOpen, setSiteOpen] = useState(false);
|
||||
const [previewSite, setPreviewSite] = useState<InjectionSiteKey | null>(null);
|
||||
const isInjection = payload.deliveryForm === "INJECTION";
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3" data-slot="wizard-step3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wizard-dose-amount" className="text-sm">
|
||||
{t("medications.wizard.steps.step3.amountLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="wizard-dose-amount"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={payload.doseAmount}
|
||||
onChange={(e) => applyPartial({ doseAmount: e.target.value })}
|
||||
placeholder={t("medications.wizard.steps.step3.amountPlaceholder")}
|
||||
maxLength={20}
|
||||
autoComplete="off"
|
||||
enterKeyHint="next"
|
||||
className="h-11"
|
||||
/>
|
||||
<div className="space-y-6" data-slot="wizard-step3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wizard-dose-amount" className="text-sm">
|
||||
{t("medications.wizard.steps.step3.amountLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="wizard-dose-amount"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={payload.doseAmount}
|
||||
onChange={(e) => applyPartial({ doseAmount: e.target.value })}
|
||||
placeholder={t("medications.wizard.steps.step3.amountPlaceholder")}
|
||||
maxLength={20}
|
||||
autoComplete="off"
|
||||
enterKeyHint="next"
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wizard-dose-unit" className="text-sm">
|
||||
{t("medications.wizard.steps.step3.unitLabel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={payload.doseUnit}
|
||||
onValueChange={(v) => applyPartial({ doseUnit: v })}
|
||||
>
|
||||
<SelectTrigger id="wizard-dose-unit" className="h-11 w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DOSE_UNIT_KEYS.map((u) => (
|
||||
<SelectItem key={u} value={u}>
|
||||
{t(`medications.wizard.steps.step3.unit.${u}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wizard-dose-unit" className="text-sm">
|
||||
{t("medications.wizard.steps.step3.unitLabel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={payload.doseUnit}
|
||||
onValueChange={(v) => applyPartial({ doseUnit: v })}
|
||||
|
||||
{/* v1.6.0 — route of administration + inventory. Decoupled from
|
||||
the Step 2 treatment row so an INJECTION delivery surfaces the
|
||||
rotation map for any class, not only GLP-1. */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wizard-delivery-form" className="text-sm">
|
||||
{t("medications.wizard.steps.step3.deliveryFormLabel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={payload.deliveryForm}
|
||||
onValueChange={(v) =>
|
||||
applyPartial({ deliveryForm: v as MedicationDeliveryForm })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="wizard-delivery-form" className="h-11 w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DELIVERY_FORM_KEYS.map((d) => (
|
||||
<SelectItem key={d} value={d}>
|
||||
{t(`medications.wizard.steps.step3.deliveryForm.${d}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wizard-doses-per-unit" className="text-sm">
|
||||
{t("medications.wizard.steps.step3.dosesPerUnitLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="wizard-doses-per-unit"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={payload.dosesPerUnit}
|
||||
onChange={(e) =>
|
||||
applyPartial({
|
||||
dosesPerUnit: e.target.value.replace(/[^0-9]/g, "").slice(0, 3),
|
||||
})
|
||||
}
|
||||
placeholder={t(
|
||||
"medications.wizard.steps.step3.dosesPerUnitPlaceholder",
|
||||
)}
|
||||
maxLength={3}
|
||||
autoComplete="off"
|
||||
className="h-11"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.wizard.steps.step3.dosesPerUnitHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* v1.6.0 — collapsible injection-site rotation map. Shown only for
|
||||
INJECTION delivery; the per-dose site is logged at intake time
|
||||
so this surface is an optional rotation aide, not a persisted
|
||||
medication field. */}
|
||||
{isInjection && (
|
||||
<div
|
||||
className="border-border/60 rounded-lg border"
|
||||
data-slot="wizard-injection-site"
|
||||
>
|
||||
<SelectTrigger id="wizard-dose-unit" className="h-11 w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DOSE_UNIT_KEYS.map((u) => (
|
||||
<SelectItem key={u} value={u}>
|
||||
{t(`medications.wizard.steps.step3.unit.${u}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-11 w-full justify-between px-3"
|
||||
aria-expanded={siteOpen}
|
||||
onClick={() => setSiteOpen((prev) => !prev)}
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{t("medications.wizard.steps.step3.injectionSiteToggle")}
|
||||
</span>
|
||||
{siteOpen ? (
|
||||
<ChevronUp aria-hidden="true" className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown aria-hidden="true" className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
{siteOpen && (
|
||||
<div className="space-y-2 px-3 pb-4">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.wizard.steps.step3.injectionSiteHint")}
|
||||
</p>
|
||||
<InjectionSitePicker
|
||||
value={previewSite}
|
||||
onChange={setPreviewSite}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
import {
|
||||
type MedicationTreatmentClass,
|
||||
type MedicationCategoryValue,
|
||||
type MedicationDeliveryForm,
|
||||
} from "@/lib/validations/medication";
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
@@ -193,6 +194,19 @@ export interface WizardPayload {
|
||||
doseAmount: string;
|
||||
doseUnit: string;
|
||||
treatmentRow: WizardTreatmentRow | null;
|
||||
/**
|
||||
* v1.6.0 — route of administration. Drives the injection-site
|
||||
* rotation preview (shown when `INJECTION`) and, paired with
|
||||
* `mode === "oneShot"`, the one-time-injection shape. Defaults to
|
||||
* `ORAL`; the GLP-1 treatment row nudges it to `INJECTION` on
|
||||
* selection so the common case needs no extra tap.
|
||||
*/
|
||||
deliveryForm: MedicationDeliveryForm;
|
||||
/**
|
||||
* v1.6.0 — doses per pen / vial for inventory math. Empty string =
|
||||
* inventory tracking off (maps to `undefined` / NULL on the wire).
|
||||
*/
|
||||
dosesPerUnit: string;
|
||||
/** Set in Step 5 — mirrors `schedules[activeScheduleIndex].mode`. */
|
||||
mode: "oneShot" | "recurring" | null;
|
||||
/** Mirrors `schedules[activeScheduleIndex].cadence`. */
|
||||
@@ -237,6 +251,8 @@ export function emptyWizardPayload(): WizardPayload {
|
||||
doseAmount: "",
|
||||
doseUnit: "mg",
|
||||
treatmentRow: null,
|
||||
deliveryForm: "ORAL",
|
||||
dosesPerUnit: "",
|
||||
mode: draft.mode,
|
||||
cadence: draft.cadence,
|
||||
subControls: draft.subControls,
|
||||
@@ -443,6 +459,10 @@ export interface CreateMedicationBody {
|
||||
dose: string;
|
||||
category: MedicationCategoryValue;
|
||||
treatmentClass: MedicationTreatmentClass;
|
||||
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
|
||||
deliveryForm: MedicationDeliveryForm;
|
||||
/** v1.6.0 — doses per pen / vial. Omitted when inventory is off. */
|
||||
dosesPerUnit?: number;
|
||||
/**
|
||||
* v1.5.5 D-3 §10 invariant 16 — the wizard owns the create-time
|
||||
* default for `notificationsEnabled` but never overwrites it on
|
||||
@@ -571,11 +591,17 @@ export function buildCreateBody(
|
||||
? [committed.schedules[committed.activeScheduleIndex] ?? committed.schedules[0]]
|
||||
: committed.schedules;
|
||||
|
||||
const parsedDosesPerUnit = Number.parseInt(committed.dosesPerUnit, 10);
|
||||
const body: CreateMedicationBody = {
|
||||
name: committed.name.trim(),
|
||||
dose,
|
||||
category: mapping.category,
|
||||
treatmentClass: mapping.treatmentClass,
|
||||
deliveryForm: committed.deliveryForm,
|
||||
...(Number.isFinite(parsedDosesPerUnit) &&
|
||||
parsedDosesPerUnit >= 1 && {
|
||||
dosesPerUnit: parsedDosesPerUnit,
|
||||
}),
|
||||
// v1.5.5 D-3 §10 invariant 16 — `notificationsEnabled` only on
|
||||
// create. Edits never round-trip the wizard's default because the
|
||||
// detail page's notifications switch is the single source of
|
||||
@@ -777,6 +803,10 @@ export interface MedicationPayload {
|
||||
dose: string;
|
||||
category: string;
|
||||
treatmentClass?: string;
|
||||
/** v1.6.0 — route of administration. Defaults to ORAL when absent. */
|
||||
deliveryForm?: string;
|
||||
/** v1.6.0 — doses per pen / vial. NULL = inventory tracking off. */
|
||||
dosesPerUnit?: number | null;
|
||||
notificationsEnabled: boolean;
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
@@ -795,6 +825,11 @@ export interface MedicationPayload {
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Coerce a persisted `deliveryForm` string onto the closed enum. */
|
||||
function normaliseDeliveryForm(value: string | undefined): MedicationDeliveryForm {
|
||||
return value === "INJECTION" || value === "OTHER" ? value : "ORAL";
|
||||
}
|
||||
|
||||
const DOSE_EXPR_RE = /^\s*(\d+(?:[.,]\d+)?)\s*(.*)$/;
|
||||
|
||||
/** Try to split "5 mg" into ["5", "mg"]; falls back to ["", input]. */
|
||||
@@ -866,6 +901,11 @@ export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload
|
||||
doseAmount: parsedDose.amount,
|
||||
doseUnit: parsedDose.unit || base.doseUnit,
|
||||
treatmentRow: rowFromTreatment(initial.treatmentClass, initial.category),
|
||||
deliveryForm: normaliseDeliveryForm(initial.deliveryForm),
|
||||
dosesPerUnit:
|
||||
typeof initial.dosesPerUnit === "number" && initial.dosesPerUnit >= 1
|
||||
? String(initial.dosesPerUnit)
|
||||
: "",
|
||||
mode: first.mode,
|
||||
cadence: first.cadence,
|
||||
subControls: first.subControls,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Compass,
|
||||
ImageUp,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Save,
|
||||
@@ -25,6 +26,11 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -376,6 +382,9 @@ export function AccountSection() {
|
||||
</header>
|
||||
|
||||
{/* Profile card */}
|
||||
{/* Profile photo card */}
|
||||
<AvatarSection />
|
||||
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<User className="text-primary h-5 w-5" />
|
||||
@@ -955,3 +964,167 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Accepted upload types + byte cap mirror the server contract in
|
||||
// `src/app/api/user/avatar/route.ts` so the client rejects an obvious
|
||||
// bad file before the round-trip. The server still re-validates by
|
||||
// magic-byte sniff — this is a UX shortcut, not the security boundary.
|
||||
const ACCEPTED_AVATAR_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
||||
const AVATAR_MAX_UPLOAD_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
function getAvatarInitials(name: string): string {
|
||||
return name
|
||||
.split(/[\s._-]+/)
|
||||
.slice(0, 2)
|
||||
.map((s) => s[0]?.toUpperCase() ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
function AvatarSection() {
|
||||
const { t } = useTranslations();
|
||||
const { user, refetch } = useAuth();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
const res = await fetch("/api/user/avatar", { method: "POST", body });
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
if (res.status === 415) throw new Error(t("settings.avatar.invalidType"));
|
||||
if (res.status === 413) throw new Error(t("settings.avatar.tooLarge"));
|
||||
throw new Error(json.error || t("settings.avatar.error"));
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setMsgType("success");
|
||||
setMsg(t("settings.avatar.uploaded"));
|
||||
await refetch();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setMsgType("error");
|
||||
setMsg(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/user/avatar", { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => ({}));
|
||||
throw new Error(json.error || t("settings.avatar.error"));
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setMsgType("success");
|
||||
setMsg(t("settings.avatar.removed"));
|
||||
await refetch();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setMsgType("error");
|
||||
setMsg(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
// Reset so re-selecting the same file still fires `onChange`.
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
if (!ACCEPTED_AVATAR_TYPES.includes(file.type)) {
|
||||
setMsgType("error");
|
||||
setMsg(t("settings.avatar.invalidType"));
|
||||
return;
|
||||
}
|
||||
if (file.size > AVATAR_MAX_UPLOAD_BYTES) {
|
||||
setMsgType("error");
|
||||
setMsg(t("settings.avatar.tooLarge"));
|
||||
return;
|
||||
}
|
||||
upload.mutate(file);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
const avatarUrl = user.avatarUrl ?? null;
|
||||
const busy = upload.isPending || remove.isPending;
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<ImageUp className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("settings.avatar.title")}</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-4 text-sm">
|
||||
{t("settings.avatar.description")}
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="size-16">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
|
||||
<AvatarFallback className="text-base">
|
||||
{getAvatarInitials(user.username)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleFile}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{upload.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<ImageUp className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{avatarUrl
|
||||
? t("settings.avatar.replace")
|
||||
: t("settings.avatar.upload")}
|
||||
</Button>
|
||||
{avatarUrl && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
remove.mutate();
|
||||
}}
|
||||
>
|
||||
{remove.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("settings.avatar.remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-3 text-xs">
|
||||
{t("settings.avatar.hint")}
|
||||
</p>
|
||||
{msg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`mt-3 text-sm ${
|
||||
msgType === "success" ? "text-dracula-green" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface InsightsSettings {
|
||||
codexStatus: string;
|
||||
@@ -1796,12 +1797,20 @@ function RuntimeActionsRow({
|
||||
|
||||
async function togglePrivacy() {
|
||||
const next = privacyMode === "raw" ? "aggregated" : "raw";
|
||||
await fetch("/api/insights/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ privacyMode: next }),
|
||||
});
|
||||
onPrivacyChanged();
|
||||
// Privacy mode is a sensitive control: only reflect the new mode
|
||||
// once the server confirms it. A swallowed 4xx/5xx would leave the
|
||||
// UI showing a setting the server never accepted.
|
||||
try {
|
||||
const res = await fetch("/api/insights/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ privacyMode: next }),
|
||||
});
|
||||
if (!res.ok) throw new Error("request failed");
|
||||
onPrivacyChanged();
|
||||
} catch {
|
||||
toast.error(t("settings.savingError"));
|
||||
}
|
||||
}
|
||||
|
||||
const lastInsightLine = lastInsightAt
|
||||
|
||||
@@ -58,9 +58,31 @@ export interface ResponsiveSheetProps {
|
||||
* consumer renders its own close affordance inside the header.
|
||||
*/
|
||||
showCloseButton?: boolean;
|
||||
/**
|
||||
* Desktop (Dialog branch) content width. The bottom-sheet branch
|
||||
* always spans the viewport, so this prop only affects the `md+`
|
||||
* Dialog. Defaults to `"md"` (448 px) to preserve every existing
|
||||
* consumer; pass `"4xl"` for the wide medication editor's
|
||||
* two-column layout. The class is the only width utility applied —
|
||||
* the prior `cn("sm:max-w-md", className)` ordering let a
|
||||
* consumer-passed width silently collide with the hardcoded
|
||||
* default, so the width now resolves from this single source.
|
||||
*/
|
||||
contentWidth?: "md" | "lg" | "2xl" | "3xl" | "4xl";
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const CONTENT_WIDTH_CLASS: Record<
|
||||
NonNullable<ResponsiveSheetProps["contentWidth"]>,
|
||||
string
|
||||
> = {
|
||||
md: "sm:max-w-md",
|
||||
lg: "sm:max-w-lg",
|
||||
"2xl": "sm:max-w-2xl",
|
||||
"3xl": "sm:max-w-3xl",
|
||||
"4xl": "sm:max-w-4xl",
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.4.27 R3d MB1 — shared mount primitive that flips between a
|
||||
* bottom-anchored `<Sheet>` on narrow viewports and the existing
|
||||
@@ -89,6 +111,7 @@ export function ResponsiveSheet({
|
||||
className,
|
||||
bodyClassName,
|
||||
showCloseButton = true,
|
||||
contentWidth = "md",
|
||||
children,
|
||||
}: ResponsiveSheetProps) {
|
||||
const isMobile = useIsMobile();
|
||||
@@ -160,7 +183,7 @@ export function ResponsiveSheet({
|
||||
showCloseButton={showCloseButton}
|
||||
data-slot="responsive-sheet-content"
|
||||
data-variant="dialog"
|
||||
className={cn("sm:max-w-md", className)}
|
||||
className={cn(CONTENT_WIDTH_CLASS[contentWidth], className)}
|
||||
>
|
||||
{hideHeader ? (
|
||||
<DialogHeader className="sr-only">
|
||||
|
||||
@@ -45,7 +45,6 @@ export const DOSE_UNITS = [
|
||||
"puffs",
|
||||
"sprays",
|
||||
] as const;
|
||||
export type DoseUnit = (typeof DOSE_UNITS)[number];
|
||||
|
||||
/** `CadenceKind` from the wizard types, mirrored as a Zod-friendly enum. */
|
||||
export const CADENCE_KINDS = [
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
* new schedule cadence, audit metadata) only have to land once.
|
||||
*
|
||||
* The helper is intentionally side-effect-only:
|
||||
* 1. Projects the active schedules through `expandTodayIntakes`.
|
||||
* 1. Projects the active schedules through the canonical recurrence
|
||||
* engine (`scheduleEmitsInWindow`), minting at `windowStart`.
|
||||
* 2. Reads existing rows in the today-window.
|
||||
* 3. Inserts the missing rows (`skipDuplicates: true` for the
|
||||
* `(userId, medicationId, scheduledFor, source)` unique index).
|
||||
@@ -28,7 +29,12 @@
|
||||
* Returns the counts so the caller can surface them on `annotate(...)`.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { expandTodayIntakes } from "@/lib/medication-schedule";
|
||||
import {
|
||||
buildCanonicalSchedule,
|
||||
buildRecurrenceContext,
|
||||
scheduleEmitsInWindow,
|
||||
} from "@/lib/medications/scheduling/worker-helpers";
|
||||
import { localHmAsUtc } from "@/lib/timezone";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
|
||||
|
||||
export interface ProjectTodayIntakesResult {
|
||||
@@ -48,6 +54,10 @@ export async function projectTodayIntakesAndRecompute(input: {
|
||||
where: { userId, active: true },
|
||||
select: {
|
||||
id: true,
|
||||
startsOn: true,
|
||||
endsOn: true,
|
||||
oneShot: true,
|
||||
createdAt: true,
|
||||
schedules: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -55,16 +65,61 @@ export async function projectTodayIntakesAndRecompute(input: {
|
||||
windowStart: true,
|
||||
windowEnd: true,
|
||||
daysOfWeek: true,
|
||||
timesOfDay: true,
|
||||
reminderGraceMinutes: true,
|
||||
rrule: true,
|
||||
rollingIntervalDays: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const projected = expandTodayIntakes(
|
||||
activeMedications.flatMap((m) => m.schedules),
|
||||
new Date(),
|
||||
userTz,
|
||||
);
|
||||
// v1.6.0 read-flip — gate every "does this schedule emit today?"
|
||||
// decision through the canonical recurrence engine (the same path the
|
||||
// reminder worker uses). The legacy `expandTodayIntakes` walker read
|
||||
// only `daysOfWeek` + `windowStart` and silently skipped
|
||||
// `intervalWeeks > 1`, rolling, RRULE, and one-shot cadences — so the
|
||||
// dashboard / intake today-tile diverged from what the worker minted
|
||||
// for bi-weekly (GLP-1), rolling, and RRULE-only schedules. The
|
||||
// `scheduledFor` instant stays anchored to `windowStart` so it remains
|
||||
// byte-identical to the worker's RED-phase row and dedupes against the
|
||||
// `@@unique([userId, medicationId, scheduledFor, source])` index.
|
||||
const now = new Date();
|
||||
const projected: Array<{ medicationId: string; scheduledFor: Date }> = [];
|
||||
|
||||
for (const med of activeMedications) {
|
||||
if (med.schedules.length === 0) continue;
|
||||
|
||||
// Rolling cadence anchors off the last logged intake. One findFirst
|
||||
// per medication, scoped to `takenAt IS NOT NULL` — byte-identical to
|
||||
// the reminder worker's baseline (`reminder-worker.ts`), so projector
|
||||
// and worker resolve the same next-due instant and never mint
|
||||
// divergent `(med, scheduledFor)` rows.
|
||||
let lastIntakeAt: Date | null = null;
|
||||
if (med.schedules.some((s) => s.rollingIntervalDays !== null)) {
|
||||
const lastIntake = await prisma.medicationIntakeEvent.findFirst({
|
||||
where: { userId, medicationId: med.id, takenAt: { not: null } },
|
||||
orderBy: { takenAt: "desc" },
|
||||
select: { takenAt: true },
|
||||
});
|
||||
lastIntakeAt = lastIntake?.takenAt ?? null;
|
||||
}
|
||||
|
||||
const ctx = buildRecurrenceContext({ medication: med, userTz, lastIntakeAt });
|
||||
|
||||
for (const schedule of med.schedules) {
|
||||
const canonical = buildCanonicalSchedule(schedule);
|
||||
if (!scheduleEmitsInWindow(canonical, ctx, todayStart, todayEnd)) {
|
||||
continue;
|
||||
}
|
||||
const [h, m] = schedule.windowStart.split(":").map(Number);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(m)) continue;
|
||||
projected.push({
|
||||
medicationId: schedule.medicationId,
|
||||
scheduledFor: localHmAsUtc(now, userTz, h, m),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (projected.length === 0) {
|
||||
return { projected: 0, backfilled: 0 };
|
||||
|
||||
@@ -252,12 +252,20 @@ export function validateMeasurementRange(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.6.0 — notes cap raised from 25 to 200. A 25-char limit could not
|
||||
* hold a meaningful clinical note ("took after large meal, felt dizzy
|
||||
* standing up"). The DB column is unbounded `String?`; this is the
|
||||
* single source of truth the client char-counters import.
|
||||
*/
|
||||
export const MEASUREMENT_NOTES_MAX_LENGTH = 200;
|
||||
|
||||
export const createMeasurementSchema = z
|
||||
.object({
|
||||
type: measurementTypeEnum,
|
||||
value: z.number(),
|
||||
measuredAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||||
notes: z.string().max(25).optional(),
|
||||
notes: z.string().max(MEASUREMENT_NOTES_MAX_LENGTH).optional(),
|
||||
source: measurementSourceEnum.optional().default("MANUAL"),
|
||||
// Only applies when type === BLOOD_GLUCOSE. Mirrored by a CHECK
|
||||
// constraint in Postgres (see migration 0021).
|
||||
@@ -293,7 +301,10 @@ export const updateMeasurementSchema = z.object({
|
||||
.optional(),
|
||||
notes: z
|
||||
.string()
|
||||
.max(25, "Note cannot exceed 25 characters")
|
||||
.max(
|
||||
MEASUREMENT_NOTES_MAX_LENGTH,
|
||||
`Note cannot exceed ${MEASUREMENT_NOTES_MAX_LENGTH} characters`,
|
||||
)
|
||||
.nullable()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
@@ -38,6 +38,20 @@ export const MEDICATION_TREATMENT_CLASS_VALUES = ["GENERIC", "GLP1"] as const;
|
||||
export type MedicationTreatmentClass =
|
||||
(typeof MEDICATION_TREATMENT_CLASS_VALUES)[number];
|
||||
|
||||
/**
|
||||
* v1.6.0 — route of administration. Decoupled from `treatmentClass`:
|
||||
* the injection-site picker surfaces for any `INJECTION` dose, and a
|
||||
* one-time injection is `oneShot: true` + `deliveryForm: "INJECTION"`.
|
||||
* ORAL is the default the migration backfilled onto every existing row.
|
||||
*/
|
||||
export const MEDICATION_DELIVERY_FORM_VALUES = [
|
||||
"ORAL",
|
||||
"INJECTION",
|
||||
"OTHER",
|
||||
] as const;
|
||||
export type MedicationDeliveryForm =
|
||||
(typeof MEDICATION_DELIVERY_FORM_VALUES)[number];
|
||||
|
||||
/**
|
||||
* v1.5 — RRULE string shape check.
|
||||
*
|
||||
@@ -225,6 +239,8 @@ export const createMedicationSchema = z
|
||||
treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(),
|
||||
/** v1.4.25 W4d — doses per pen/vial for inventory tracking. */
|
||||
dosesPerUnit: z.number().int().min(1).max(100).optional(),
|
||||
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
|
||||
deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(),
|
||||
notificationsEnabled: z.boolean().optional(),
|
||||
...courseWindowFields,
|
||||
schedules: z.array(scheduleSchema).min(1, "Mindestens ein Zeitfenster"),
|
||||
@@ -254,6 +270,8 @@ export const updateMedicationSchema = z
|
||||
category: z.enum(MEDICATION_CATEGORY_VALUES).optional(),
|
||||
treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(),
|
||||
dosesPerUnit: z.number().int().min(1).max(100).nullable().optional(),
|
||||
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
|
||||
deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(),
|
||||
active: z.boolean().optional(),
|
||||
notificationsEnabled: z.boolean().optional(),
|
||||
...courseWindowFields,
|
||||
|
||||
Reference in new issue
Block a user