chore(release): v1.6.0

Medication editor overhaul, route of administration, today-tile read-flip.

- Route-of-administration column (ORAL | INJECTION | OTHER) decoupled from
  treatmentClass; injection-site picker surfaces for any injection.
  Migration 0088 backfills ORAL via a constant column default.
- One-time injection (oneShot + deliveryForm = INJECTION) editor path.
- Profile-photo upload card on Settings → Account.
- Today-tile read-flip: /api/medications/intake?scope=today and
  /api/dashboard/summary gate through the canonical recurrence engine,
  matching the reminder worker for bi-weekly, rolling, RRULE, and one-time
  cadences.
- Measurement note cap 25 → 200; dashboard range colors on semantic tokens;
  admin sidebar icons + shared-helper consolidation; API-tokens multi-language
  snippets restored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-30 00:10:12 +02:00
1 parent d01d1d1dc9
commit 8ee7aaac44
53 files changed
+1049 -1017

No files matched your search

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