diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 70a203a70..b00c3e1a2 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -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-* diff --git a/CHANGELOG.md b/CHANGELOG.md index c9b860891..72e361355 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index a0e54917c..f1a550529 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3d9b99bbc..f184cd224 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.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: diff --git a/messages/de.json b/messages/de.json index f4059c01e..7b7e07700 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index fce7e5b4d..345a502bb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 2c6841973..06db9ed7a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 2877f4b46..bf586c78b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index dda27085d..17df2fb12 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 540f7d1c2..d1443e481 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/package.json b/package.json index 73db0d615..743890c47 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/0088_v160_delivery_form/migration.sql b/prisma/migrations/0088_v160_delivery_form/migration.sql new file mode 100644 index 000000000..e60eaf72b --- /dev/null +++ b/prisma/migrations/0088_v160_delivery_form/migration.sql @@ -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'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 016687425..11d97cfea 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/src/app/api/admin/backups/route.ts b/src/app/api/admin/backups/route.ts index 06fb9bf70..11871f979 100644 --- a/src/app/api/admin/backups/route.ts +++ b/src/app/api/admin/backups/route.ts @@ -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, }; diff --git a/src/app/api/admin/host-metrics/route.ts b/src/app/api/admin/host-metrics/route.ts index 7cb312d9c..28cf09803 100644 --- a/src/app/api/admin/host-metrics/route.ts +++ b/src/app/api/admin/host-metrics/route.ts @@ -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, diff --git a/src/app/api/admin/status-overview/__tests__/route.test.ts b/src/app/api/admin/status-overview/__tests__/route.test.ts deleted file mode 100644 index 373d62c6c..000000000 --- a/src/app/api/admin/status-overview/__tests__/route.test.ts +++ /dev/null @@ -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( - "@/lib/api-handler", - ); - return { - ...actual, - apiHandler: Promise>( - 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 }; - withingsConnection: { count: ReturnType }; - notificationChannel: { count: ReturnType }; - pushSubscription: { count: ReturnType }; - appSettings: { findUnique: ReturnType }; - auditLog: { - findFirst: ReturnType; - count: ReturnType; - }; - dataBackup: { - findFirst: ReturnType; - findMany: ReturnType; - }; -}; - -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 }; - 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); - }); -}); diff --git a/src/app/api/admin/status-overview/route.ts b/src/app/api/admin/status-overview/route.ts deleted file mode 100644 index 0b1d23255..000000000 --- a/src/app/api/admin/status-overview/route.ts +++ /dev/null @@ -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 = {}; - 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); -}); diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index c1c2a0c41..e6918e1df 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -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, }, ], }, diff --git a/src/app/api/measurements/[id]/__tests__/route.test.ts b/src/app/api/measurements/[id]/__tests__/route.test.ts index fca3c14a0..c418de739 100644 --- a/src/app/api/measurements/[id]/__tests__/route.test.ts +++ b/src/app/api/measurements/[id]/__tests__/route.test.ts @@ -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, ); diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 3a7f39384..0675527eb 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -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. diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index cf959e94b..6b790d92b 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -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, }, ], }, diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index dd599a02d..61df5ffb2 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -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. diff --git a/src/app/medications/[id]/page.tsx b/src/app/medications/[id]/page.tsx index c62d4fd34..55ff62c26 100644 --- a/src/app/medications/[id]/page.tsx +++ b/src/app/medications/[id]/page.tsx @@ -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 ? ( {}} + 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). */} + + )} diff --git a/src/app/medications/__tests__/detail-page-history-surface.test.ts b/src/app/medications/__tests__/detail-page-history-surface.test.ts index f8c17e1b4..e3556ff9f 100644 --- a/src/app/medications/__tests__/detail-page-history-surface.test.ts +++ b/src/app/medications/__tests__/detail-page-history-surface.test.ts @@ -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"); }); }); diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index 1a8fc720c..90646a8d1 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -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, diff --git a/src/app/page.tsx b/src/app/page.tsx index 9c469aa12..bde5a4540 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( <>

- + {t("charts.colorGreen")} {" "} {format(range.greenMin)}-{format(range.greenMax)} {unit}

- + {t("charts.colorOrange")} {" "} {format(range.orangeMin)}-{format(range.greenMin)} {t("common.or")}{" "} {format(range.greenMax)}-{format(range.orangeMax)} {unit}

- {t("charts.colorRed")}{" "} + + {t("charts.colorRed")} + {" "} {"< "} {format(range.orangeMin)} {t("common.or")} {"> "} {format(range.orangeMax)} {unit} diff --git a/src/app/targets/page.tsx b/src/app/targets/page.tsx index ec42546f2..2775fd27b 100644 --- a/src/app/targets/page.tsx +++ b/src/app/targets/page.tsx @@ -236,9 +236,9 @@ export default function TargetsPage() { {/* Profile incomplete hint */} {profileIncomplete && ( - + - +

{t("targets.profileIncomplete")} diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index a88ddc549..975da512e 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -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, + }); +} diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx index f50f9d560..a0e092608 100644 --- a/src/components/admin/admin-shell.tsx +++ b/src/components/admin/admin-shell.tsx @@ -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", diff --git a/src/components/admin/ai-quality-section.tsx b/src/components/admin/ai-quality-section.tsx index 99bc14c4a..2233c9c36 100644 --- a/src/components/admin/ai-quality-section.tsx +++ b/src/components/admin/ai-quality-section.tsx @@ -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 = { 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(); diff --git a/src/components/admin/coach-feedback-section.tsx b/src/components/admin/coach-feedback-section.tsx index 27bf6c62e..55cc42bbb 100644 --- a/src/components/admin/coach-feedback-section.tsx +++ b/src/components/admin/coach-feedback-section.tsx @@ -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(); diff --git a/src/components/admin/host-metrics-chart.tsx b/src/components/admin/host-metrics-chart.tsx index 972196949..4f2331a1c 100644 --- a/src/components/admin/host-metrics-chart.tsx +++ b/src/components/admin/host-metrics-chart.tsx @@ -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")) { diff --git a/src/components/admin/services-section.tsx b/src/components/admin/services-section.tsx index 8fc203e99..6854cb1f5 100644 --- a/src/components/admin/services-section.tsx +++ b/src/components/admin/services-section.tsx @@ -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} /> + + updateSettings.mutate({ moodLogGlobal: checked }) + } + disabled={updateSettings.isPending} + /> { - 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 (

diff --git a/src/components/admin/system-status-summary.tsx b/src/components/admin/system-status-summary.tsx index e5742156e..299530e0c 100644 --- a/src/components/admin/system-status-summary.tsx +++ b/src/components/admin/system-status-summary.tsx @@ -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 (
({ }), })); -// `` 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 }) => ( -
{children}
- ), - DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - DropdownMenuContent: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - DropdownMenuItem: ({ - children, - onSelect, - ...rest - }: { - children: React.ReactNode; - onSelect?: () => void; - [key: string]: unknown; - }) => ( - - ), -})); - 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)", () => { diff --git a/src/components/medications/advanced-settings-sheet.tsx b/src/components/medications/advanced-settings-sheet.tsx index 9877a559b..d13c6c8fe 100644 --- a/src/components/medications/advanced-settings-sheet.tsx +++ b/src/components/medications/advanced-settings-sheet.tsx @@ -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" >
void; /** - * v1.5.6 G-1 §4 — "Erweiterte Einstellungen" opens the - * ``. Fired from the dropdown's second item. + * v1.6.0 — the gear button opens the `` + * (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({
- - - - - - - {t("medications.detail.edit.planOption")} - - - {t("medications.detail.edit.advancedOption")} - - - +
+ + +
); } diff --git a/src/components/medications/sections/api-tokens-row.tsx b/src/components/medications/sections/api-tokens-row.tsx index e79468ce8..9d1e9417d 100644 --- a/src/components/medications/sections/api-tokens-row.tsx +++ b/src/components/medications/sections/api-tokens-row.tsx @@ -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(null); const [busy, setBusy] = useState(false); + const [exampleType, setExampleType] = useState("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 = { + 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({ )} + + {/* 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. */} +
+
+ + +
+
+
+            {selectedExample.value}
+          
+ +
+
); } diff --git a/src/components/medications/todays-dose-card.tsx b/src/components/medications/todays-dose-card.tsx deleted file mode 100644 index 83daa484f..000000000 --- a/src/components/medications/todays-dose-card.tsx +++ /dev/null @@ -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: , takenAt: , 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 ``) 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(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 ( - -

- {t("medications.detail.today.pausedHint")} -

-
- - -
-
- ); - } - - // 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 ( - -

{liveLabel}

-
- ); - } - - // Empty: nothing scheduled for today (recurring path only). - if (!scheduledForToday && !oneShot) { - return ( - -

- {t("medications.detail.today.noneScheduled")} -

-
- ); - } - - const submitting = state.kind === "submitting"; - - return ( - -
- - -
- {state.kind === "error" && ( -

- {state.message} -

- )} -
- ); -} diff --git a/src/components/medications/wizard/MedicationWizardDialog.tsx b/src/components/medications/wizard/MedicationWizardDialog.tsx index a901ae5e5..0d0f7625b 100644 --- a/src/components/medications/wizard/MedicationWizardDialog.tsx +++ b/src/components/medications/wizard/MedicationWizardDialog.tsx @@ -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={
diff --git a/src/components/medications/wizard/steps/Step2Class.tsx b/src/components/medications/wizard/steps/Step2Class.tsx index 49a9c9903..52e2644b7 100644 --- a/src/components/medications/wizard/steps/Step2Class.tsx +++ b/src/components/medications/wizard/steps/Step2Class.tsx @@ -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} /> diff --git a/src/components/medications/wizard/steps/Step3Dose.tsx b/src/components/medications/wizard/steps/Step3Dose.tsx index 06740eb93..170a7fa58 100644 --- a/src/components/medications/wizard/steps/Step3Dose.tsx +++ b/src/components/medications/wizard/steps/Step3Dose.tsx @@ -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(null); + const isInjection = payload.deliveryForm === "INJECTION"; + return ( -
-
- - applyPartial({ doseAmount: e.target.value })} - placeholder={t("medications.wizard.steps.step3.amountPlaceholder")} - maxLength={20} - autoComplete="off" - enterKeyHint="next" - className="h-11" - /> +
+
+
+ + applyPartial({ doseAmount: e.target.value })} + placeholder={t("medications.wizard.steps.step3.amountPlaceholder")} + maxLength={20} + autoComplete="off" + enterKeyHint="next" + className="h-11" + /> +
+
+ + +
-
- - + applyPartial({ deliveryForm: v as MedicationDeliveryForm }) + } + > + + + + + {DELIVERY_FORM_KEYS.map((d) => ( + + {t(`medications.wizard.steps.step3.deliveryForm.${d}`)} + + ))} + + +
+
+ + + 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" + /> +

+ {t("medications.wizard.steps.step3.dosesPerUnitHint")} +

+
+
+ + {/* 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 && ( +
- - - - - {DOSE_UNIT_KEYS.map((u) => ( - - {t(`medications.wizard.steps.step3.unit.${u}`)} - - ))} - - -
+ + {siteOpen && ( +
+

+ {t("medications.wizard.steps.step3.injectionSiteHint")} +

+ +
+ )} +
+ )}
); } diff --git a/src/components/medications/wizard/wizard-payload.ts b/src/components/medications/wizard/wizard-payload.ts index f089e9338..4ba7b9b73 100644 --- a/src/components/medications/wizard/wizard-payload.ts +++ b/src/components/medications/wizard/wizard-payload.ts @@ -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, diff --git a/src/components/settings/account-section.tsx b/src/components/settings/account-section.tsx index 64459b3de..f15c44991 100644 --- a/src/components/settings/account-section.tsx +++ b/src/components/settings/account-section.tsx @@ -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() { {/* Profile card */} + {/* Profile photo card */} + +
@@ -955,3 +964,167 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
); } + +// 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(null); + const [msg, setMsg] = useState(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) { + 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 ( +
+
+ +

{t("settings.avatar.title")}

+
+

+ {t("settings.avatar.description")} +

+
+ + {avatarUrl && } + + {getAvatarInitials(user.username)} + + +
+ + + {avatarUrl && ( + + )} +
+
+

+ {t("settings.avatar.hint")} +

+ {msg && ( +

+ {msg} +

+ )} +
+ ); +} diff --git a/src/components/settings/ai-section.tsx b/src/components/settings/ai-section.tsx index 9a967bdc7..4326312ea 100644 --- a/src/components/settings/ai-section.tsx +++ b/src/components/settings/ai-section.tsx @@ -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 diff --git a/src/components/ui/responsive-sheet.tsx b/src/components/ui/responsive-sheet.tsx index a3bfaae0c..77c4683b2 100644 --- a/src/components/ui/responsive-sheet.tsx +++ b/src/components/ui/responsive-sheet.tsx @@ -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, + 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 `` 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 ? ( diff --git a/src/lib/ai/coach/medication-extract-prompt.ts b/src/lib/ai/coach/medication-extract-prompt.ts index 0dd3b860a..0efde7e6c 100644 --- a/src/lib/ai/coach/medication-extract-prompt.ts +++ b/src/lib/ai/coach/medication-extract-prompt.ts @@ -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 = [ diff --git a/src/lib/medications/scheduling/project-today-intakes.ts b/src/lib/medications/scheduling/project-today-intakes.ts index ee0dc045b..3e99a079a 100644 --- a/src/lib/medications/scheduling/project-today-intakes.ts +++ b/src/lib/medications/scheduling/project-today-intakes.ts @@ -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 }; diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 38fe9d8a0..114212994 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -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(), }); diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index 8f075549f..eafbd3c02 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -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,