diff --git a/CHANGELOG.md b/CHANGELOG.md index 4058e701d..c45030898 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## [Unreleased] +### Added + +- **A note on a medication.** Directions such as "30 to 60 minutes before + food" now have a place of their own: type them into the last step of the + medication wizard, and they show under the medication's name on its + page, above the Taken and Skip buttons on its card, under the medication + you pick when logging a dose, and in the list of doses when you take all + due ones at once. The note is up to 500 characters, encrypted like your + other notes, and travels with backups, restores and the data export. The + MCP medication schedule and medication resources include it. It is not + part of the doctor report or the FHIR export. `POST /api/medications` + and `PUT /api/medications/{id}` take an optional `note` (trimmed, at + most 500 characters; an empty string or `null` clears it, leaving it + out keeps it), and the list and detail reads answer `note`, `null` when + there is none. Migration `0498_medication_note`. (#1371) + ## [1.47.5] — 2026-11-12 ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b5974f168..2c0ca48af 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -30694,6 +30694,15 @@ components: maxLength: 20 pattern: ^\d+$ - type: "null" + note: + description: "#1371 — the person's own note on the medication, for directions such as \"30 to 60 minutes before food\". + Free text, trimmed, at most 500 characters (counted after trimming). An empty string or NULL clears it; + absent leaves it untouched. Encrypted at rest and returned decrypted as `note` on the medication read + paths." + anyOf: + - type: string + maxLength: 500 + - type: "null" externalSource: description: "Marks the medication as MIRRORED from an external medication list. Only `APPLE_HEALTH` (the iOS 26+ HealthKit Medications sync) is accepted. Must be supplied together with `externalId`. A mirrored medication @@ -31122,6 +31131,15 @@ components: maxLength: 20 pattern: ^\d+$ - type: "null" + note: + description: "#1371 — the person's own note on the medication, for directions such as \"30 to 60 minutes before food\". + Free text, trimmed, at most 500 characters (counted after trimming). An empty string or NULL clears it; + absent leaves it untouched. Encrypted at rest and returned decrypted as `note` on the medication read + paths." + anyOf: + - type: string + maxLength: 500 + - type: "null" startsOn: description: Date the medication course begins (ISO `YYYY-MM-DD`). Anchors RRULE BYDAY / BYMONTHDAY patterns and the rolling-interval countdown's first window. NULL = active from creation (the legacy implicit behaviour). @@ -43003,6 +43021,16 @@ components: type: - string - "null" + note: + anyOf: + - type: string + maxLength: 500 + - type: "null" + noteEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" lowStockNotifiedAt: anyOf: - type: string @@ -55621,6 +55649,14 @@ components: type: - string - "null" + note: + description: "#1371 — the person's own note on the medication, for directions such as \"30 to 60 minutes before food\". + Free text, at most 500 characters, decrypted on the way out (encrypted at rest). NULL = no note. Show it + with the medication and wherever a dose is logged. Set or cleared through `note` on the create and update + bodies." + type: + - string + - "null" pausedAt: anyOf: - type: string @@ -55848,6 +55884,7 @@ components: - criticalAlarmEnabled - atcCode - rxNormCode + - note - pausedAt - snoozedUntil - remindersActive @@ -56078,6 +56115,14 @@ components: type: - string - "null" + note: + description: "#1371 — the person's own note on the medication, for directions such as \"30 to 60 minutes before food\". + Free text, at most 500 characters, decrypted on the way out (encrypted at rest). NULL = no note. Show it + with the medication and wherever a dose is logged. Set or cleared through `note` on the create and update + bodies." + type: + - string + - "null" pausedAt: anyOf: - type: string @@ -56290,6 +56335,7 @@ components: - criticalAlarmEnabled - atcCode - rxNormCode + - note - pausedAt - snoozedUntil - remindersActive @@ -96838,6 +96884,14 @@ components: type: - string - "null" + note: + description: "#1371 — the person's own note on the medication, for directions such as \"30 to 60 minutes before food\". + Free text, at most 500 characters, decrypted on the way out (encrypted at rest). NULL = no note. Show it + with the medication and wherever a dose is logged. Set or cleared through `note` on the create and update + bodies." + type: + - string + - "null" pausedAt: anyOf: - type: string @@ -96969,6 +97023,7 @@ components: - criticalAlarmEnabled - atcCode - rxNormCode + - note - pausedAt - snoozedUntil - startsOn diff --git a/e2e/medications-wizard-daily.spec.ts b/e2e/medications-wizard-daily.spec.ts index fb6b1dd7d..4477edd0b 100644 --- a/e2e/medications-wizard-daily.spec.ts +++ b/e2e/medications-wizard-daily.spec.ts @@ -42,6 +42,7 @@ test.describe("medication wizard — daily", () => { rrule: "FREQ=DAILY", rollingIntervalDays: null, oneShot: false, + note: "Mit dem Frühstück", }); await openCreateWizard(page); @@ -91,6 +92,8 @@ test.describe("medication wizard — daily", () => { await expectStep(page, 7, 7); const summary = page.locator('[data-slot="wizard-summary"]'); await expect(summary).toContainText(/Jeden Tag/i); + // #1371 — the medication's own note, typed on the last step. + await page.locator("#wizard-note").fill(" Mit dem Frühstück "); await clickSave(page); @@ -98,6 +101,8 @@ test.describe("medication wizard — daily", () => { expect(capture.postBody).toMatchObject({ name: "Vitamin D", oneShot: false, + // Trimmed on the way out. + note: "Mit dem Frühstück", schedules: [{ rrule: "FREQ=DAILY" }], }); @@ -105,5 +110,9 @@ test.describe("medication wizard — daily", () => { name: "Vitamin D", withNextDueChip: true, }); + // The note sits on the card, above the dose actions. + await expect( + page.locator('main [data-slot="medication-card-note"]').first(), + ).toHaveText(/Mit dem Frühstück/); }); }); diff --git a/e2e/medications-wizard-helpers.ts b/e2e/medications-wizard-helpers.ts index 921fa101a..e958f1c9e 100644 --- a/e2e/medications-wizard-helpers.ts +++ b/e2e/medications-wizard-helpers.ts @@ -27,6 +27,8 @@ interface StubbedMedication { rrule: string | null; rollingIntervalDays: number | null; oneShot: boolean; + /** #1371 — the medication's own note, echoed on the list. */ + note?: string | null; } export function mockMedicationsApi(page: Page, stubbed: StubbedMedication) { @@ -57,6 +59,7 @@ export function mockMedicationsApi(page: Page, stubbed: StubbedMedication) { startsOn: null, endsOn: null, oneShot: stubbed.oneShot, + note: stubbed.note ?? null, schedules: [ { id: "sch_e2e_1", diff --git a/messages/de.json b/messages/de.json index 77b23e200..c44aeeebb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1506,6 +1506,11 @@ "disabledToast": "Einnahmen werden nicht erfasst", "toggleFailed": "Einnahme-Erfassung konnte nicht geändert werden." }, + "note": { + "label": "Notiz", + "placeholder": "z. B. 30 bis 60 Minuten vor dem Essen", + "helper": "Wird beim Medikament und beim Erfassen einer Einnahme angezeigt." + }, "autoMarkTaken": { "label": "Einnahmen automatisch erfassen", "helper": "HealthLog erfasst jede geplante Dosis zu ihrer Uhrzeit als eingenommen. Du kannst eine Dosis weiterhin selbst ändern oder überspringen. Erinnerungen entfallen.", diff --git a/messages/en.json b/messages/en.json index 6705917dc..3bf85c752 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1506,6 +1506,11 @@ "disabledToast": "Intake tracking off", "toggleFailed": "Could not change intake tracking." }, + "note": { + "label": "Note", + "placeholder": "e.g. 30 to 60 minutes before food", + "helper": "Shown on the medication and when you log a dose." + }, "autoMarkTaken": { "label": "Record doses automatically", "helper": "HealthLog records each scheduled dose as taken at its time. You can still change or skip a dose by hand. No reminders are sent.", diff --git a/messages/es.json b/messages/es.json index 791a45a5b..e4725eee4 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1506,6 +1506,11 @@ "disabledToast": "Registro de tomas desactivado", "toggleFailed": "No se pudo cambiar el registro de tomas." }, + "note": { + "label": "Nota", + "placeholder": "p. ej. de 30 a 60 minutos antes de comer", + "helper": "Se muestra en el medicamento y al registrar una toma." + }, "autoMarkTaken": { "label": "Registrar las tomas automáticamente", "helper": "HealthLog registra cada dosis programada como tomada a su hora. Puedes seguir cambiando u omitiendo una dosis a mano. No se envían recordatorios.", diff --git a/messages/fr.json b/messages/fr.json index 574570c36..57a0eb527 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1506,6 +1506,11 @@ "disabledToast": "Suivi des prises désactivé", "toggleFailed": "Impossible de modifier le suivi des prises." }, + "note": { + "label": "Note", + "placeholder": "p. ex. 30 à 60 minutes avant le repas", + "helper": "Affichée sur le médicament et lors de l'enregistrement d'une prise." + }, "autoMarkTaken": { "label": "Enregistrer les prises automatiquement", "helper": "HealthLog enregistre chaque dose prévue comme prise à son heure. Vous pouvez toujours modifier ou sauter une dose à la main. Aucun rappel n'est envoyé.", diff --git a/messages/it.json b/messages/it.json index c0158d044..0b5ae69d1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1506,6 +1506,11 @@ "disabledToast": "Registrazione assunzioni disattivata", "toggleFailed": "Impossibile modificare la registrazione delle assunzioni." }, + "note": { + "label": "Nota", + "placeholder": "ad es. da 30 a 60 minuti prima dei pasti", + "helper": "Mostrata sul farmaco e quando registri un'assunzione." + }, "autoMarkTaken": { "label": "Registra le assunzioni automaticamente", "helper": "HealthLog registra ogni dose programmata come assunta al suo orario. Puoi comunque modificare o saltare una dose a mano. Non vengono inviati promemoria.", diff --git a/messages/ko.json b/messages/ko.json index 5ff7cafa4..91b641672 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -1506,6 +1506,11 @@ "disabledToast": "복용 기록 꺼짐", "toggleFailed": "복용 기록 설정을 바꾸지 못했어요." }, + "note": { + "label": "메모", + "placeholder": "예: 식사 30~60분 전", + "helper": "약 정보와 복용을 기록할 때 표시됩니다." + }, "autoMarkTaken": { "label": "복용 자동 기록", "helper": "HealthLog가 예정된 각 복용을 해당 시간에 복용한 것으로 기록합니다. 복용은 언제든 직접 변경하거나 건너뛸 수 있습니다. 알림은 보내지 않습니다.", diff --git a/messages/nl.json b/messages/nl.json index 6c21d8933..46faa876f 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -1506,6 +1506,11 @@ "disabledToast": "Inname bijhouden uit", "toggleFailed": "Inname bijhouden kon niet worden gewijzigd." }, + "note": { + "label": "Notitie", + "placeholder": "bijv. 30 tot 60 minuten voor het eten", + "helper": "Zichtbaar bij het medicijn en wanneer je een inname vastlegt." + }, "autoMarkTaken": { "label": "Doses automatisch registreren", "helper": "HealthLog registreert elke geplande dosis op het geplande tijdstip als ingenomen. Je kunt een dosis nog steeds handmatig wijzigen of overslaan. Er worden geen herinneringen verstuurd.", diff --git a/messages/pl.json b/messages/pl.json index d99f6a64d..97ca12000 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1506,6 +1506,11 @@ "disabledToast": "Rejestrowanie przyjęć wyłączone", "toggleFailed": "Nie udało się zmienić rejestrowania przyjęć." }, + "note": { + "label": "Notatka", + "placeholder": "np. od 30 do 60 minut przed posiłkiem", + "helper": "Widoczna przy leku i podczas zapisywania przyjęcia." + }, "autoMarkTaken": { "label": "Zapisuj przyjęcia automatycznie", "helper": "HealthLog zapisuje każdą zaplanowaną dawkę jako przyjętą o jej porze. Nadal możesz ręcznie zmienić lub pominąć dawkę. Przypomnienia nie są wysyłane.", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 1ea7da102..68616d43d 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -1506,6 +1506,11 @@ "disabledToast": "Acompanhamento de tomadas desligado", "toggleFailed": "Não foi possível alterar o acompanhamento de tomadas." }, + "note": { + "label": "Observação", + "placeholder": "ex.: 30 a 60 minutos antes de comer", + "helper": "Aparece no medicamento e quando você registra uma tomada." + }, "autoMarkTaken": { "label": "Registrar doses automaticamente", "helper": "O HealthLog registra cada dose programada como tomada no horário dela. Você ainda pode alterar ou pular uma dose manualmente. Nenhum lembrete é enviado.", diff --git a/messages/ru.json b/messages/ru.json index 0d451a0c5..0eeee6f6f 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -1506,6 +1506,11 @@ "disabledToast": "Учёт приёма выключен", "toggleFailed": "Не удалось изменить учёт приёма." }, + "note": { + "label": "Заметка", + "placeholder": "например, от 30 до 60 минут до еды", + "helper": "Показывается у лекарства и при записи приёма." + }, "autoMarkTaken": { "label": "Записывать дозы автоматически", "helper": "HealthLog записывает каждую запланированную дозу как принятую в её время. Дозу по-прежнему можно изменить или пропустить вручную. Напоминания не отправляются.", diff --git a/prisma/migrations/0498_medication_note/migration.sql b/prisma/migrations/0498_medication_note/migration.sql new file mode 100644 index 000000000..f83f66985 --- /dev/null +++ b/prisma/migrations/0498_medication_note/migration.sql @@ -0,0 +1,19 @@ +-- A free-text note on a medication (#1371), for directions such as "30 to 60 +-- minutes before food". Shown on the medication and when a dose is logged. +-- +-- Sealed at rest like every other free-text column (AES-256-GCM, the shared +-- Bytes codec); there is no plaintext twin and nothing to backfill. Nullable, +-- no default: every existing medication reads as "no note", and adding the +-- column rewrites nothing. IF NOT EXISTS keeps a replay on a database that +-- already ran a build carrying the column harmless. Reversibility: dropping +-- the column restores the previous schema. +-- +-- @retry-on-lock-timeout + +DO $migration$ +BEGIN + PERFORM set_config('lock_timeout', '5s', true); + ALTER TABLE "medications" + ADD COLUMN IF NOT EXISTS "note_encrypted" BYTEA; +END +$migration$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d5943dc37..c1077c52d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3057,6 +3057,12 @@ model Medication { /// under `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any /// ATC code, never instead of the free-text `.text` anchor. rxNormCode String? @map("rxnorm_code") + /// #1371 — the person's own note on the medication, for directions such as + /// "30 to 60 minutes before food". Shown on the medication and wherever a + /// dose is logged. AES-256-GCM at rest; there is no plaintext column. NULL = + /// no note. Written by the create and edit routes and the restore only, + /// trimmed, at most 500 characters. + noteEncrypted Bytes? @map("note_encrypted") /// v1.16.11 — low-stock notification stamp. Set when the daily /// low-stock pass dispatched a notification for the current /// threshold crossing; cleared (re-armed) when the projected runway diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 9a4754f7a..0152f6e82 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -734,6 +734,10 @@ async function main() { prisma.medicationDoseChange, ), ); + // #1371 — the note on the medication itself. + results.push( + await rotateBytesColumn("Medication", "noteEncrypted", prisma.medication), + ); // v1.40 (#1024) — the note on a medication course. results.push( await rotateBytesColumn( diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index a4e3f316c..8511c398b 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -3,7 +3,7 @@ "count": 84, "total": 93816, "entries": { - "app/medications/page-client.tsx": 839, + "app/medications/page-client.tsx": 842, "components/charts/mood-chart.tsx": 1166, "components/cycle/log-day-sheet.tsx": 1646, "components/insights/coach-panel/chat-bubble.tsx": 821, @@ -13,9 +13,9 @@ "components/insights/health-score-card.tsx": 1023, "components/insights/insights-tab-strip.tsx": 1084, "components/measurements/measurement-form.tsx": 815, - "components/medications/detail/medication-detail-tabs.tsx": 1129, + "components/medications/detail/medication-detail-tabs.tsx": 1133, "components/medications/medication-table.tsx": 853, - "components/medications/wizard/wizard-payload.ts": 1414, + "components/medications/wizard/wizard-payload.ts": 1431, "components/mood/mood-list.tsx": 1408, "components/records/life-circumstances-manager.tsx": 828, "components/timeline/timeline-chart.tsx": 1093, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index e785bbee3..4fe2dbf58 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -46,7 +46,7 @@ "app/insights/medications/page.tsx :: InsightsMedikamentePage": 265, "app/insights/page-client.tsx :: InsightsPageClient": 375, "app/labs/page.tsx :: LabsPage": 217, - "app/medications/page-client.tsx :: MedicationsPageClient": 590, + "app/medications/page-client.tsx :: MedicationsPageClient": 591, "app/notifications/page.tsx :: NotificationsPage": 327, "components/activity/activity-calendar-card.tsx :: ActivityCalendarCard": 470, "components/admin/admin-shell.tsx :: AdminShell": 254, @@ -145,15 +145,15 @@ "components/measurement-reminders/vorsorge-section.tsx :: VorsorgeSection": 354, "components/measurements/measurement-form.tsx :: MeasurementForm": 496, "components/medications/detail/efficacy/efficacy-chart.tsx :: EfficacyChart": 361, - "components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 760, + "components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 761, "components/medications/dose-history-add-dialog.tsx :: LedgerAddDialog": 221, "components/medications/dose-strength-curve.tsx :: DoseStrengthCurve": 266, - "components/medications/glp1-medication-card.tsx :: Glp1MedicationCard": 396, + "components/medications/glp1-medication-card.tsx :: Glp1MedicationCard": 397, "components/medications/injection-site-picker.tsx :: InjectionSitePicker": 287, "components/medications/intake-history-list-v2.tsx :: IntakeHistoryListV2": 403, "components/medications/intake-import-dialog.tsx :: IntakeImportDialog": 201, - "components/medications/log-intake-dialog.tsx :: LogIntakeDialog": 267, - "components/medications/medication-card.tsx :: MedicationCard": 413, + "components/medications/log-intake-dialog.tsx :: LogIntakeDialog": 268, + "components/medications/medication-card.tsx :: MedicationCard": 414, "components/medications/medication-table.tsx :: MedicationTableRowItem": 377, "components/medications/scheduling/natural-language-extractor.tsx :: NaturalLanguageExtractor": 211, "components/medications/scheduling/schedule-history-timeline.tsx :: EraDialog": 219, @@ -165,7 +165,7 @@ "components/medications/side-effects-section.tsx :: SideEffectsSection": 518, "components/medications/wizard/medication-wizard-dialog.tsx :: WizardDialogShell": 514, "components/medications/wizard/steps/step3-dose.tsx :: Step3Dose": 222, - "components/medications/wizard/steps/step8-summary.tsx :: Step8Summary": 230, + "components/medications/wizard/steps/step8-summary.tsx :: Step8Summary": 231, "components/mood/manage/tag-groups-card.tsx :: TagGroupsCard": 252, "components/mood/manage/tag-manager-card.tsx :: TagManagerCard": 261, "components/mood/mood-form.tsx :: MoodForm": 514, @@ -318,7 +318,7 @@ "lib/measurements/import-apple-health-export.ts :: streamParseExportXml > (anonymous) #2": 323, "lib/medications/efficacy/build-efficacy.ts :: buildMedicationEfficacy": 268, "lib/medications/intake-slot-dedup.ts :: dedupeUserIntakeSlots": 319, - "lib/medications/list-read.ts :: buildMedicationsList": 308, + "lib/medications/list-read.ts :: buildMedicationsList": 310, "lib/medications/scheduling/dose-history.ts :: reconstructDoseHistory": 218, "lib/multipart/stream-to-disk.ts :: streamMultipartToDisk": 272, "lib/notifications/dispatcher.ts :: dispatchNotification": 366, diff --git a/src/app/api/export/medications/route.ts b/src/app/api/export/medications/route.ts index 05c0dd6ca..c356acbde 100644 --- a/src/app/api/export/medications/route.ts +++ b/src/app/api/export/medications/route.ts @@ -32,6 +32,7 @@ import { formatIntakeEventsForExport, } from "@/lib/export"; import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { withMedicationNote } from "@/lib/medications/medication-note"; import { NextRequest, NextResponse } from "next/server"; export const GET = apiHandler(async (request: NextRequest) => { @@ -67,6 +68,7 @@ export const GET = apiHandler(async (request: NextRequest) => { dose: true, active: true, trackIntake: true, + noteEncrypted: true, schedules: { select: { windowStart: true, @@ -90,7 +92,8 @@ export const GET = apiHandler(async (request: NextRequest) => { const sections: string[] = [ "# Medications", - toCSV(formatMedicationsForExport(medications)), + // The note travels decrypted (#1371); the ciphertext never does. + toCSV(formatMedicationsForExport(medications.map(withMedicationNote))), ]; let intakeCount = 0; diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index f3c6c89ec..44a917ad0 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -20,6 +20,7 @@ import { resolveUserTimezone } from "@/lib/tz/resolver"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { NextRequest, NextResponse } from "next/server"; import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order"; +import { withMedicationNote } from "@/lib/medications/medication-note"; /** * Export user data. @@ -126,10 +127,12 @@ export const GET = apiHandler(async (request: NextRequest) => { include: { schedules: { orderBy: MEDICATION_SCHEDULE_ORDER } }, orderBy: [{ createdAt: "desc" }, { id: "desc" }], }); + // The note travels decrypted (#1371); the ciphertext never does. + const readable = medications.map(withMedicationNote); data.medications = format === "csv" - ? toCSV(formatMedicationsForExport(medications)) - : formatMedicationsForExport(medications); + ? toCSV(formatMedicationsForExport(readable)) + : formatMedicationsForExport(readable); } if (type === "intake" || type === "all") { diff --git a/src/app/medications/page-client.tsx b/src/app/medications/page-client.tsx index f35ecf09b..2f4628b3c 100644 --- a/src/app/medications/page-client.tsx +++ b/src/app/medications/page-client.tsx @@ -85,6 +85,8 @@ interface Medication { id: string; name: string; dose: string; + /** #1371 — the person's own note; null = none. */ + note?: string | null; category: string; /** v1.40 — the label of a custom category; null for a built-in one. */ categoryLabel?: string | null; @@ -816,6 +818,7 @@ export default function MedicationsPageClient() { id: m.id, name: m.name, dose: m.dose, + note: m.note ?? null, active: m.active, intakeActionable: m.intakeActionable === true, lastTakenAt: m.lastTakenAt, diff --git a/src/components/medications/__tests__/log-intake-dialog.test.tsx b/src/components/medications/__tests__/log-intake-dialog.test.tsx index 538e47e25..1be40cc66 100644 --- a/src/components/medications/__tests__/log-intake-dialog.test.tsx +++ b/src/components/medications/__tests__/log-intake-dialog.test.tsx @@ -166,3 +166,36 @@ describe(" medication default", () => { expectSelected(html, "r"); }); }); + +describe(" the medication's own note (#1371)", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-12T08:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("shows the picked medication's note under the picker", () => { + const html = renderDialog([ + medication("a", { note: "30 to 60 minutes before food" }), + ]); + expect(html).toContain('data-slot="log-intake-note"'); + expect(html).toContain("30 to 60 minutes before food"); + }); + + it("shows no note line for a medication without one", () => { + const html = renderDialog([medication("a", { note: null })]); + expect(html).not.toContain("log-intake-note"); + }); + + it("shows the note of the medication that is picked, not another's", () => { + const html = renderDialog([ + medication("a", { name: "Alpha", note: "Alpha note" }), + medication("b", { name: "Beta", note: "Beta note" }), + ]); + expect(html).toContain("Alpha note"); + expect(html).not.toContain("Beta note"); + }); +}); diff --git a/src/components/medications/__tests__/medication-card-symmetry.test.tsx b/src/components/medications/__tests__/medication-card-symmetry.test.tsx index 1343f7103..42cfe1fa2 100644 --- a/src/components/medications/__tests__/medication-card-symmetry.test.tsx +++ b/src/components/medications/__tests__/medication-card-symmetry.test.tsx @@ -555,4 +555,59 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => { expect(htmls[1]).toContain("aria-hidden"); // prn skeleton expect(htmls[2]).toContain("aria-hidden"); // one-shot skeleton }); + + it("both cards show the medication's own note inside the bottom-pinned action block (#1371)", () => { + const note = "30 to 60 minutes before food"; + const client = makeClient(); + seedCompliance(client, ramipril.id); + seedCompliance(client, mounjaro.id); + seedGlp1Details(client, mounjaro.id); + const htmls = [ + render( + {}} + onOpenHistory={() => {}} + />, + client, + ), + render( + {}} + onOpenHistory={() => {}} + />, + client, + ), + ]; + for (const html of htmls) { + // In the bottom-pinned block, before the take button: the rows above + // keep their baseline, and the note is in view when a dose is logged. + const block = html.indexOf("mt-auto space-y-2"); + const noteAt = html.indexOf('data-slot="medication-card-note"'); + const takeAt = html.indexOf('data-slot="medication-intake-take"'); + expect(block).toBeGreaterThan(-1); + expect(noteAt).toBeGreaterThan(block); + expect(takeAt).toBeGreaterThan(noteAt); + expect(html).toContain(note); + // The person's words are content, never muted meta. + expect(html).toMatch( + //, + ); + } + }); + + it("renders no note slot for a medication without one", () => { + const client = makeClient(); + seedCompliance(client, ramipril.id); + const html = render( + {}} + onOpenHistory={() => {}} + />, + client, + ); + expect(html).not.toContain("medication-card-note"); + }); }); diff --git a/src/components/medications/__tests__/medication-detail-summary-course.test.tsx b/src/components/medications/__tests__/medication-detail-summary-course.test.tsx index 18bbbdc0d..a75099602 100644 --- a/src/components/medications/__tests__/medication-detail-summary-course.test.tsx +++ b/src/components/medications/__tests__/medication-detail-summary-course.test.tsx @@ -29,7 +29,10 @@ const payload = { ], }; -function render(courseStatus: "UPCOMING" | "CURRENT" | "ENDED") { +function render( + courseStatus: "UPCOMING" | "CURRENT" | "ENDED", + note: string | null = null, +) { return renderToStaticMarkup( @@ -41,6 +44,7 @@ function render(courseStatus: "UPCOMING" | "CURRENT" | "ENDED") { payload={payload} oneShot={false} startsOn="2026-09-20T00:00:00.000Z" + note={note} /> , @@ -69,3 +73,22 @@ describe(" — course status (#1040)", () => { expect(html).not.toContain("bg-success"); }); }); + +describe(" — the medication's own note (#1371)", () => { + it("shows the note in foreground text, keeping its line breaks", () => { + const html = render("CURRENT", "With food\nNot with milk"); + expect(html).toContain('data-slot="medication-detail-note"'); + expect(html).toContain("With food\nNot with milk"); + expect(html).toContain("whitespace-pre-line"); + // The note text itself is never muted; only the icon is. + expect(html).toMatch( + /data-slot="medication-detail-note" class="[^"]*\bwhitespace-pre-line\b[^"]*"/, + ); + expect(html).toContain('With food'); + expect(html).not.toMatch(/medication-detail-note" class="[^"]*muted/); + }); + + it("renders nothing for a medication without a note", () => { + expect(render("CURRENT")).not.toContain("medication-detail-note"); + }); +}); diff --git a/src/components/medications/__tests__/take-all-due.test.tsx b/src/components/medications/__tests__/take-all-due.test.tsx index 9426e49b7..653e5a8b3 100644 --- a/src/components/medications/__tests__/take-all-due.test.tsx +++ b/src/components/medications/__tests__/take-all-due.test.tsx @@ -123,6 +123,16 @@ describe("deriveDueMedications — only currently-due, active, unpaused", () => const now = todayAtUtc(12, 0); const opts = { now, tz: "UTC" }; + it("carries the medication's own note into the confirm list (#1371)", () => { + const [withNote] = deriveDueMedications( + [makeMed({ id: "a", note: "With food" })], + opts, + ); + expect(withNote?.note).toBe("With food"); + const [without] = deriveDueMedications([makeMed({ id: "b" })], opts); + expect(without?.note).toBeNull(); + }); + it("includes an in-window medication with its matched band and slot instant", () => { const due = deriveDueMedications([makeMed({ id: "a" })], opts); expect(due).toHaveLength(1); @@ -622,6 +632,24 @@ describe(" — confirm dialog lists the due medications", () = expect(html).toContain("Abbrechen"); }); + it("lists each medication's own note under its name (#1371)", () => { + const html = render( + , + ); + expect(html.match(/data-slot="take-all-due-note"/g)).toHaveLength(1); + expect(html).toContain("Vor dem Frühstück"); + expect(html.indexOf("Metoprolol")).toBeLessThan( + html.indexOf("Vor dem Frühstück"), + ); + }); + it("does not render when closed", () => { const html = render( +
+ ({ id: s.id, windowStart: s.windowStart, @@ -502,6 +505,7 @@ export function MedicationDetailTabs({ asNeeded={asNeeded} startsOn={medication.startsOn} recordOnly={recordOnly} + note={medication.note ?? null} /> ))} +
{slotTimes.length > 0 && ( diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 2a790b525..352bbbfa8 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -127,6 +127,8 @@ interface Medication { trackIntake?: boolean; /** v1.42.2 — doses recorded automatically. */ autoMarkTaken?: boolean; + /** #1371 — the person's own note, shown above the dose actions. */ + note?: string | null; /** * v1.39.4 (#1040) — server-resolved: taken / skip are offered today only * when true (active, tracked, and the course includes today). @@ -510,6 +512,7 @@ export function MedicationCard({ highlighted={highlighted} name={medication.name} dose={formatDose(medication.dose, t)} + note={medication.note ?? null} categoryLabel={categoryLabel} active={medication.active} href={`/medications/${medication.id}`} diff --git a/src/components/medications/medication-detail-summary.tsx b/src/components/medications/medication-detail-summary.tsx index 54e4bf7ee..ec4014e1e 100644 --- a/src/components/medications/medication-detail-summary.tsx +++ b/src/components/medications/medication-detail-summary.tsx @@ -16,6 +16,7 @@ */ import { Badge } from "@/components/ui/badge"; +import { MedicationNote } from "@/components/medications/medication-note"; import { hydrateWizardPayload, summariseCadence, @@ -47,6 +48,11 @@ export interface MedicationDetailSummaryProps { startsOn?: string | null; /** v1.39.1 (#1033) — intake tracking off: a "Record only" badge. */ recordOnly?: boolean; + /** + * #1371 — the person's own note ("30 to 60 minutes before food"), shown + * under the cadence line. Null or empty renders nothing. + */ + note?: string | null; } type Status = "active" | "paused" | "ended"; @@ -69,6 +75,7 @@ export function MedicationDetailSummary({ asNeeded = false, startsOn, recordOnly = false, + note = null, }: MedicationDetailSummaryProps) { const { t, locale } = useTranslations(); const formatters = useFormatters(); @@ -137,6 +144,12 @@ export function MedicationDetailSummary({ > {cadenceLine}

+ ); } diff --git a/src/components/medications/medication-note.tsx b/src/components/medications/medication-note.tsx new file mode 100644 index 000000000..bb5a5ae47 --- /dev/null +++ b/src/components/medications/medication-note.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { StickyNote } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +export interface MedicationNoteProps { + /** The person's own note; null or empty renders nothing. */ + note: string | null | undefined; + /** `data-slot` of the rendered note, for tests and e2e. */ + slot: string; + /** Cut to two lines, the full text on hover (cards). */ + clamp?: boolean; + /** Lead with a muted note glyph (the detail header). */ + icon?: boolean; + className?: string; +} + +/** + * #1371 — the medication's own note ("30 to 60 minutes before food"), in the + * one presentation every surface shares: the person's words in foreground + * text (user data is never muted), the line breaks they typed kept, and a + * screen-reader label in front. A `span` so it can sit inside a list row as + * well as a block. + */ +export function MedicationNote({ + note, + slot, + clamp = false, + icon = false, + className, +}: MedicationNoteProps) { + const { t } = useTranslations(); + if (!note) return null; + return ( + + {icon && ( + + ); +} diff --git a/src/components/medications/take-all-due-dialog.tsx b/src/components/medications/take-all-due-dialog.tsx index d055fe071..1755ffa63 100644 --- a/src/components/medications/take-all-due-dialog.tsx +++ b/src/components/medications/take-all-due-dialog.tsx @@ -22,6 +22,7 @@ import { Button } from "@/components/ui/button"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { useTranslations } from "@/lib/i18n/context"; import { useActiveRecordName } from "@/hooks/use-record-capabilities"; +import { MedicationNote } from "@/components/medications/medication-note"; import { formatDose } from "@/lib/medications/format-dose"; import { formatTimeWindowRange } from "@/lib/time-window-format"; import { @@ -113,14 +114,21 @@ export function TakeAllDueDialog({ key={med.id} className="flex items-baseline justify-between gap-3 text-sm" > - - {med.name} - {med.dose ? ( - - {" "} - — {formatDose(med.dose, t)} - - ) : null} + + + {med.name} + {med.dose ? ( + + {" "} + — {formatDose(med.dose, t)} + + ) : null} + + {med.window ? ( diff --git a/src/components/medications/take-all-due.ts b/src/components/medications/take-all-due.ts index 04bea56f4..fb898034b 100644 --- a/src/components/medications/take-all-due.ts +++ b/src/components/medications/take-all-due.ts @@ -75,6 +75,8 @@ export interface DueDerivationMedication { id: string; name: string; dose: string; + /** #1371 — the person's own note; null or absent = none. */ + note?: string | null; active: boolean; pausedAt: string | null; lastTakenAt: string | null; @@ -97,6 +99,8 @@ export interface DueMedication { name: string; /** Matched schedule dose, falling back to the medication-level dose. */ dose: string; + /** #1371 — the person's own note, listed under the name; null = none. */ + note?: string | null; /** HH:mm bounds of the matched dose band, for the dialog's window line. */ window: { start: string; end: string } | null; /** @@ -206,6 +210,7 @@ export function deriveDueMedications( id: m.id, name: m.name, dose: status.schedule?.dose ?? m.dose, + note: m.note ?? null, window: status.window ? { start: status.window.start, end: status.window.end } : null, diff --git a/src/components/medications/wizard/__tests__/step8-note.test.tsx b/src/components/medications/wizard/__tests__/step8-note.test.tsx new file mode 100644 index 000000000..3fc655b48 --- /dev/null +++ b/src/components/medications/wizard/__tests__/step8-note.test.tsx @@ -0,0 +1,50 @@ +/** + * #1371 — the wizard's last step edits the medication's own note. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { MEDICATION_NOTE_MAX_CHARS } from "@/lib/validations/medication"; + +import { Step8Summary } from "../steps/step8-summary"; +import { emptyWizardPayload } from "../wizard-payload"; + +function render(note: string) { + const payload = { ...emptyWizardPayload(), mode: "recurring" as const, note }; + return renderToStaticMarkup( + + + {}} + mode="edit" + submitError={null} + onEditSchedule={() => {}} + onRemoveSchedule={() => {}} + onAddSchedule={() => {}} + /> + + , + ); +} + +describe(" note field (#1371)", () => { + it("renders a labelled, length-capped textarea holding the note", () => { + const html = render("30 to 60 minutes before food"); + expect(html).toMatch(/]*for="wizard-note"[^>]*>Note<\/label>/); + expect(html).toMatch( + new RegExp( + `]*id="wizard-note"[^>]*maxLength="${MEDICATION_NOTE_MAX_CHARS}"`, + ), + ); + expect(html).toContain(">30 to 60 minutes before food"); + expect(html).toContain("Shown on the medication and when you log a dose."); + }); + + it("keeps the iOS zoom floor: the textarea renders at 16 px on touch", () => { + // The primitive's `text-base` (16 px) holds the viewport on focus. + expect(render("")).toMatch(/]*class="[^"]*\btext-base\b/); + }); +}); diff --git a/src/components/medications/wizard/__tests__/wizard-payload.test.ts b/src/components/medications/wizard/__tests__/wizard-payload.test.ts index c6c15e826..afdea4367 100644 --- a/src/components/medications/wizard/__tests__/wizard-payload.test.ts +++ b/src/components/medications/wizard/__tests__/wizard-payload.test.ts @@ -1412,3 +1412,74 @@ describe("custom categories (v1.40, #1041)", () => { expect(payload.customCategoryKey).toBeNull(); }); }); + +describe("the medication's own note (#1371)", () => { + function hydrate(note?: string | null) { + return hydrateWizardPayload({ + id: "m1", + name: "Levothyroxine", + dose: "50 µg", + category: "THYROID", + treatmentClass: "GENERIC", + notificationsEnabled: true, + startsOn: null, + endsOn: null, + oneShot: false, + ...(note !== undefined && { note }), + schedules: [ + { + windowStart: "07:00", + windowEnd: "08:00", + timesOfDay: ["07:00"], + rrule: "FREQ=DAILY", + }, + ], + }); + } + + it("starts empty on a fresh wizard and sends nothing for a blank note", () => { + const payload = emptyWizardPayload(); + expect(payload.note).toBe(""); + expect("note" in buildCreateBody({ ...payload, note: " " })).toBe(false); + }); + + it("sends a typed note on create, trimmed", () => { + const body = buildCreateBody( + { ...hydrate(), note: " 30 to 60 minutes before food \n" }, + "create", + ); + expect(body.note).toBe("30 to 60 minutes before food"); + }); + + it("hydrates the stored note into the editor", () => { + expect(hydrate("Before breakfast").note).toBe("Before breakfast"); + expect(hydrate(null).note).toBe(""); + }); + + it("leaves the stored note alone on an edit that did not touch it", () => { + const body = buildCreateBody( + { ...hydrate("Before breakfast"), name: "Renamed" }, + "edit", + ); + expect("note" in body).toBe(false); + }); + + it("never clears a note on an edit from a snapshot without the field", () => { + // A snapshot that predates the field hydrates an empty editor; saving + // must not send the empty value over the stored note. + expect("note" in buildCreateBody(hydrate(), "edit")).toBe(false); + }); + + it("sends a changed note on edit, and a blank one to clear it", () => { + const changed = buildCreateBody( + { ...hydrate("Before breakfast"), note: "With food" }, + "edit", + ); + expect(changed.note).toBe("With food"); + const cleared = buildCreateBody( + { ...hydrate("Before breakfast"), note: " " }, + "edit", + ); + expect(cleared.note).toBe(""); + }); +}); diff --git a/src/components/medications/wizard/steps/step8-note-field.tsx b/src/components/medications/wizard/steps/step8-note-field.tsx new file mode 100644 index 000000000..4f80a0f23 --- /dev/null +++ b/src/components/medications/wizard/steps/step8-note-field.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { useTranslations } from "@/lib/i18n/context"; +import { MEDICATION_NOTE_MAX_CHARS } from "@/lib/validations/medication/base"; + +import type { StepProps } from "./step1-name"; + +/** + * #1371 — the medication's own note, for directions such as "30 to 60 + * minutes before food". Shown on the medication and when a dose is logged. + * Optional; clearing it on an edit removes the stored note. + */ +export function Step8NoteField({ payload, applyPartial }: StepProps) { + const { t } = useTranslations(); + return ( +
+ +