From 4537ff0d8ebf03bd269e52f5a9d24add4a809224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 14 Nov 2026 09:30:40 +0100 Subject: [PATCH] feat(medications): add a note to the medication (#1371) Directions such as "30 to 60 minutes before food" had nowhere to go: a medication carried no free text of its own, only the notes on a dose change or a course. The medication now has one note, edited on the last step of the wizard and shown under the name on the detail page, above the dose actions on both card variants, under the picked medication in the log-dose sheet, and per row in the take-all-due list. One MedicationNote component renders it everywhere, in foreground text. The note is sealed at rest in Medication.noteEncrypted (migration 0498_medication_note, lock-bounded), with no plaintext column. Create and update take an optional `note`: trimmed, at most 500 characters, an empty string or null clears it, absent keeps it. The wizard sends it on an edit only when it changed, so an unrelated save never clears it. The list and detail reads strip the ciphertext and answer `note`. Both ends of the data path carry it: the backup (decrypted in a portable file, ciphertext in a disaster-recovery file), the restore, the data export, the key-rotation registry and script, and the MCP medication schedule, resources and fetch, fenced as user text. The doctor report and the FHIR export leave it out: it is the person's own reminder, and the report has no selection for personal prose. The size baselines rise by the one-line wiring of the field in the medication page, detail tabs, cards, log-dose sheet, wizard step and list read; the wizard payload grows by the field's own encode and hydrate. --- CHANGELOG.md | 16 ++ docs/api/openapi.yaml | 55 ++++++ e2e/medications-wizard-daily.spec.ts | 9 + e2e/medications-wizard-helpers.ts | 3 + messages/de.json | 5 + messages/en.json | 5 + messages/es.json | 5 + messages/fr.json | 5 + messages/it.json | 5 + messages/ko.json | 5 + messages/nl.json | 5 + messages/pl.json | 5 + messages/pt-BR.json | 5 + messages/ru.json | 5 + .../0498_medication_note/migration.sql | 19 +++ prisma/schema.prisma | 6 + scripts/rotate-encryption-key.ts | 4 + .../baselines/source-file-lines.json | 6 +- .../baselines/source-function-lines.json | 14 +- src/app/api/export/medications/route.ts | 5 +- src/app/api/export/route.ts | 7 +- src/app/medications/page-client.tsx | 3 + .../__tests__/log-intake-dialog.test.tsx | 33 ++++ .../medication-card-symmetry.test.tsx | 55 ++++++ .../medication-detail-summary-course.test.tsx | 25 ++- .../__tests__/take-all-due.test.tsx | 28 +++ .../card-parts/medication-card-body.tsx | 13 +- .../detail/medication-detail-tabs.tsx | 4 + .../medications/glp1-medication-card.tsx | 3 + .../medications/log-intake-dialog.tsx | 4 + .../medications/medication-card.tsx | 3 + .../medications/medication-detail-summary.tsx | 13 ++ .../medications/medication-note.tsx | 57 +++++++ .../medications/take-all-due-dialog.tsx | 24 ++- src/components/medications/take-all-due.ts | 5 + .../wizard/__tests__/step8-note.test.tsx | 50 ++++++ .../wizard/__tests__/wizard-payload.test.ts | 71 ++++++++ .../wizard/steps/step8-note-field.tsx | 37 ++++ .../wizard/steps/step8-summary.tsx | 2 + .../medications/wizard/wizard-payload.ts | 17 ++ src/lib/crypto/encrypted-columns.ts | 3 + src/lib/export.ts | 3 + src/lib/export/medications-backup-schema.ts | 6 + src/lib/export/medications-backup.ts | 11 ++ src/lib/export/medications-restore.ts | 8 + .../tools-list.write-session.snap | 9 +- src/lib/mcp/__tests__/tools.test.ts | 7 + src/lib/mcp/resources.ts | 9 +- src/lib/mcp/tools/fetch.ts | 7 +- src/lib/mcp/tools/get-medication-schedule.ts | 11 +- .../__tests__/medication-note.test.ts | 126 ++++++++++++++ src/lib/medications/create-medication.ts | 5 + src/lib/medications/list-read.ts | 5 +- src/lib/medications/medication-note.ts | 42 +++++ src/lib/medications/read-medication.ts | 5 +- .../medications/update-medication/write.ts | 6 + src/lib/openapi/routes/medications/schemas.ts | 6 + src/lib/validations/medication/base.ts | 23 +++ .../validations/medication/create-update.ts | 5 + .../export-disaster-recovery.json | 10 +- .../backup-parity/export-portable.json | 6 +- .../restore-disaster-recovery.json | 10 +- .../backup-parity/restore-portable.json | 6 +- .../backup-parity/restore-skips.json | 1 + .../backup-parity/restore-version-1.json | 4 + .../backup-parity/seeded-account.json | 5 +- tests/integration/backup-fixture.ts | 2 + tests/integration/backup-round-trip.test.ts | 73 ++++++++ tests/integration/medication-note.test.ts | 161 ++++++++++++++++++ .../medication-detail.01-rolling.json.snap | 8 + .../medication-detail.02-daily.json.snap | 8 + ...medication-detail.03-record-only.json.snap | 8 + .../medication-detail.04-not-found.json.snap | 7 + .../medication-detail.05a-reader.json.snap | 8 + .../medication-detail.05b-anonymous.json.snap | 7 + ...edication-detail.11-plain-fields.json.snap | 8 + ...ation-detail.12-schedule-replace.json.snap | 8 + ...dication-detail.13-schedule-echo.json.snap | 8 + ...edication-detail.14-grace-bridge.json.snap | 8 + .../medication-detail.15a-pause.json.snap | 8 + .../medication-detail.15b-resume.json.snap | 8 + ...cation-detail.16-auto-mark-taken.json.snap | 8 + ...ation-detail.17a-custom-category.json.snap | 8 + ...ion-detail.17b-native-other-kept.json.snap | 8 + .../medication-detail.17c-web-other.json.snap | 8 + ...tion-detail.17d-unknown-category.json.snap | 7 + ...-detail.18a-record-schedule-kept.json.snap | 8 + ...edication-detail.18b-tracking-on.json.snap | 8 + ...dication-detail.18c-tracking-off.json.snap | 8 + ....19a-scheduled-without-schedules.json.snap | 7 + .../medication-detail.19b-scheduled.json.snap | 8 + ....19c-as-needed-keeping-schedules.json.snap | 7 + .../medication-detail.19d-as-needed.json.snap | 8 + ...etail.20a-one-shot-two-schedules.json.snap | 7 + ...n-detail.20b-one-shot-recurrence.json.snap | 7 + .../medication-detail.20c-one-shot.json.snap | 8 + ...cation-detail.21a-course-overlap.json.snap | 7 + ...etail.21b-course-window-required.json.snap | 7 + ...medication-detail.21c-course-end.json.snap | 8 + ...tion-detail.22-composition-units.json.snap | 8 + .../medication-detail.23a-invalid.json.snap | 7 + .../medication-detail.23b-not-found.json.snap | 7 + .../medication-detail.24a-writer.json.snap | 7 + .../medication-detail.24b-manager.json.snap | 8 + .../medication-detail.24c-anonymous.json.snap | 7 + .../medication-detail.31-delete.json.snap | 6 + .../medication-detail.32-not-found.json.snap | 6 + .../medication-detail.33a-manager.json.snap | 6 + .../medication-detail.33b-anonymous.json.snap | 6 + .../medication-intake-bulk.01-mixed.json.snap | 4 + ...ation-intake-bulk.02-mixed-again.json.snap | 4 + ...ation-intake-bulk.03a-background.json.snap | 4 + ...ication-intake-bulk.03b-backfill.json.snap | 4 + ...cation-intake-bulk.04a-too-large.json.snap | 4 + ...dication-intake-bulk.04b-invalid.json.snap | 4 + ...take-bulk.04c-apple-not-mirrored.json.snap | 4 + ...edication-intake-bulk.05a-native.json.snap | 4 + ...ion-intake-bulk.05b-narrow-token.json.snap | 4 + ...ication-intake-bulk.05c-delegate.json.snap | 4 + ...cation-intake-bulk.05d-anonymous.json.snap | 4 + ...on-intake-event.01-take-in-place.json.snap | 2 + ...cation-intake-event.02-skip-flip.json.snap | 2 + ...ion-intake-event.03-move-by-band.json.snap | 2 + ...on-intake-event.04-pin-free-slot.json.snap | 2 + ...n-intake-event.05-pin-not-a-slot.json.snap | 2 + ...ion-intake-event.06-pin-occupied.json.snap | 2 + ...medication-intake-event.07-unpin.json.snap | 2 + ...on-intake-event.08-explicit-slot.json.snap | 2 + ...ion-intake-event.09-before-start.json.snap | 2 + ...dication-intake-event.10-invalid.json.snap | 2 + ...tion-intake-event.11a-tombstoned.json.snap | 2 + ...ntake-event.11b-other-medication.json.snap | 2 + ...dication-intake-event.12a-writer.json.snap | 2 + ...ication-intake-event.12b-manager.json.snap | 2 + ...cation-intake-event.13-anonymous.json.snap | 2 + ...edication-intake-event.21-delete.json.snap | 2 + ...-intake-event.22-delete-one-shot.json.snap | 2 + ...intake-event.23-other-medication.json.snap | 2 + ...dication-intake-event.24a-writer.json.snap | 2 + ...ication-intake-event.24b-manager.json.snap | 2 + ...cation-intake-event.25-anonymous.json.snap | 2 + ...edication-intake-status.01-today.json.snap | 4 + ...ntake-status.02a-compliance-cold.json.snap | 4 + ...ntake-status.02b-compliance-warm.json.snap | 4 + ...ication-intake-status.03-invalid.json.snap | 4 + ...ication-intake-status.04a-reader.json.snap | 4 + ...tion-intake-status.04b-anonymous.json.snap | 4 + ...n-intake-status.11-take-in-place.json.snap | 4 + ...-intake-status.12-take-slot-move.json.snap | 4 + ...medication-intake-status.13a-pin.json.snap | 4 + ...intake-status.13b-pin-not-a-slot.json.snap | 4 + ...medication-intake-status.14-skip.json.snap | 4 + ...n-intake-status.15a-snooze-until.json.snap | 4 + ...intake-status.15b-snooze-default.json.snap | 4 + ...cation-intake-status.16a-unknown.json.snap | 4 + ...cation-intake-status.16b-foreign.json.snap | 4 + ...ion-intake-status.16c-tombstoned.json.snap | 4 + ...take-status.17a-record-only-open.json.snap | 4 + ...tatus.17b-record-only-correction.json.snap | 4 + ...take-status.18a-site-not-allowed.json.snap | 4 + ...edication-intake-status.18b-site.json.snap | 4 + ...ication-intake-status.19-invalid.json.snap | 4 + ...on-intake-status.20a-writer-take.json.snap | 4 + ...status.20b-writer-snooze-refused.json.snap | 4 + ...us.20c-writer-correction-refused.json.snap | 4 + ...ication-intake-status.20d-reader.json.snap | 4 + ...tion-intake-status.20e-anonymous.json.snap | 4 + ...edication-intake.01-take-in-band.json.snap | 5 + ...ation-intake.02a-take-standalone.json.snap | 5 + ...ntake.02b-take-standalone-repeat.json.snap | 5 + ...ation-intake.03a-skip-named-slot.json.snap | 5 + ...ication-intake.03b-skip-slotless.json.snap | 5 + ...delegate-skip-standalone-refused.json.snap | 5 + ...intake.03d-owner-skip-standalone.json.snap | 5 + .../medication-intake.04a-pin.json.snap | 5 + ...cation-intake.04b-pin-not-a-slot.json.snap | 5 + ...dication-intake.04c-pin-occupied.json.snap | 5 + ...ication-intake.05-named-backfill.json.snap | 5 + .../medication-intake.06a-body-key.json.snap | 5 + ...ation-intake.06b-body-key-replay.json.snap | 5 + ...medication-intake.07a-header-key.json.snap | 5 + ...ion-intake.07b-header-key-replay.json.snap | 5 + .../medication-intake.08-native.json.snap | 5 + ...intake.09a-delegate-skip-refused.json.snap | 5 + ...ation-intake.09b-owner-skip-flip.json.snap | 5 + ...edication-intake.10a-writer-take.json.snap | 5 + .../medication-intake.10b-reader.json.snap | 5 + .../medication-intake.10c-anonymous.json.snap | 5 + ...tion-intake.11a-site-not-allowed.json.snap | 5 + ...ication-intake.11b-site-and-dose.json.snap | 5 + .../medication-intake.12-one-shot.json.snap | 5 + .../medication-intake.13-invalid.json.snap | 5 + .../medication-intake.14a-not-found.json.snap | 5 + ...edication-intake.14b-record-only.json.snap | 5 + .../medication-intake.21-default.json.snap | 5 + ...ication-intake.22-completed-page.json.snap | 5 + .../medication-intake.23a-skipped.json.snap | 5 + .../medication-intake.23b-taken.json.snap | 5 + .../medication-intake.24-invalid.json.snap | 5 + .../medication-intake.25a-not-found.json.snap | 5 + .../medication-intake.25b-reader.json.snap | 5 + .../medication-intake.25c-anonymous.json.snap | 5 + .../medications.01-list.json.snap | 2 + .../medications.02a-list-reader.json.snap | 2 + .../medications.02b-list-anonymous.json.snap | 1 + .../medications.11-plain.json.snap | 3 + .../medications.12-full.json.snap | 4 + .../medications.13a-as-needed.json.snap | 5 + .../medications.13b-one-shot.json.snap | 6 + ...ications.13c-prn-and-legacy-days.json.snap | 7 + .../medications.13d-ended-window.json.snap | 8 + ...medications.14a-unknown-category.json.snap | 7 + ...ons.14b-course-ends-before-start.json.snap | 7 + ...tions.14c-one-shot-two-schedules.json.snap | 7 + ...ications.14d-one-shot-recurrence.json.snap | 7 + ...ications.14e-unstable-concept-id.json.snap | 7 + .../medications.14f-invalid.json.snap | 7 + .../medications.15a-mirror.json.snap | 9 + .../medications.15b-mirror-replay.json.snap | 9 + .../medications.16-mirror-cap.json.snap | 30 ++++ ...medications.17a-idempotent-first.json.snap | 10 ++ ...edications.17b-idempotent-replay.json.snap | 10 ++ .../medications.18a-writer.json.snap | 11 ++ .../medications.18b-reader.json.snap | 10 ++ .../medications.19-anonymous.json.snap | 10 ++ 225 files changed, 2015 insertions(+), 44 deletions(-) create mode 100644 prisma/migrations/0498_medication_note/migration.sql create mode 100644 src/components/medications/medication-note.tsx create mode 100644 src/components/medications/wizard/__tests__/step8-note.test.tsx create mode 100644 src/components/medications/wizard/steps/step8-note-field.tsx create mode 100644 src/lib/medications/__tests__/medication-note.test.ts create mode 100644 src/lib/medications/medication-note.ts create mode 100644 tests/integration/medication-note.test.ts 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 ( +
+ +