diff --git a/messages/de.json b/messages/de.json index 8ab6d3dbb..430d15ebc 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1323,8 +1323,9 @@ "edit": { "dialogTitle": "Einnahme bearbeiten", "takenAtLabel": "Eingenommen um", + "scheduledForHint": "Geplant: {dateTime}", + "farFromScheduledWarning": "Liegt weit vom geplanten Zeitpunkt entfernt – Datum prüfen.", "skippedLabel": "Übersprungen", - "noteLabel": "Notiz", "save": "Speichern", "cancel": "Abbrechen", "savedToast": "Einnahme aktualisiert", diff --git a/messages/en.json b/messages/en.json index 58d054157..aac9080a9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1323,8 +1323,9 @@ "edit": { "dialogTitle": "Edit intake", "takenAtLabel": "Taken at", + "scheduledForHint": "Scheduled: {dateTime}", + "farFromScheduledWarning": "This is far from the scheduled time — check the date.", "skippedLabel": "Skipped", - "noteLabel": "Note", "save": "Save", "cancel": "Cancel", "savedToast": "Intake updated", diff --git a/messages/es.json b/messages/es.json index a07cce72b..b37cd76c0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1323,8 +1323,9 @@ "edit": { "dialogTitle": "Editar toma", "takenAtLabel": "Tomada a las", + "scheduledForHint": "Programada: {dateTime}", + "farFromScheduledWarning": "Está muy lejos de la hora programada; comprueba la fecha.", "skippedLabel": "Omitida", - "noteLabel": "Nota", "save": "Guardar", "cancel": "Cancelar", "savedToast": "Toma actualizada", diff --git a/messages/fr.json b/messages/fr.json index 75f0bb202..3399e0725 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1323,8 +1323,9 @@ "edit": { "dialogTitle": "Modifier la prise", "takenAtLabel": "Prise à", + "scheduledForHint": "Prévue : {dateTime}", + "farFromScheduledWarning": "Très éloignée de l’heure prévue — vérifiez la date.", "skippedLabel": "Sautée", - "noteLabel": "Note", "save": "Enregistrer", "cancel": "Annuler", "savedToast": "Prise mise à jour", diff --git a/messages/it.json b/messages/it.json index 8d6623f95..b56733170 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1323,8 +1323,9 @@ "edit": { "dialogTitle": "Modifica assunzione", "takenAtLabel": "Assunta alle", + "scheduledForHint": "Programmata: {dateTime}", + "farFromScheduledWarning": "Molto distante dall’orario programmato — controlla la data.", "skippedLabel": "Saltata", - "noteLabel": "Nota", "save": "Salva", "cancel": "Annulla", "savedToast": "Assunzione aggiornata", diff --git a/messages/pl.json b/messages/pl.json index c3266cdd8..71c5b8d23 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1323,8 +1323,9 @@ "edit": { "dialogTitle": "Edytuj przyjęcie", "takenAtLabel": "Przyjęto o", + "scheduledForHint": "Zaplanowano: {dateTime}", + "farFromScheduledWarning": "Znacznie odbiega od zaplanowanego terminu — sprawdź datę.", "skippedLabel": "Pominięto", - "noteLabel": "Notatka", "save": "Zapisz", "cancel": "Anuluj", "savedToast": "Przyjęcie zaktualizowane", diff --git a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts index 8337a3b27..cde47a0cd 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts @@ -358,6 +358,106 @@ describe("PUT — v1.15.18 band re-attribution", () => { expect((created?.scheduledFor as Date).getTime()).toBe(at(19, 0).getTime()); }); + it("422s an edited takenAt before the medication's start date (P0-4)", async () => { + // Use fixed wall-clock so the schema's "not in the future / within 5 + // years" bounds hold regardless of when the suite runs. + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-10T12:00:00Z")); + try { + vi.mocked(prisma.medication.findFirst).mockResolvedValue({ + id: "m1", + startsOn: new Date("2026-06-01T00:00:00Z"), + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + schedules: [], + } as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ + id: "e1", + userId: "user-1", + medicationId: "m1", + scheduledFor: at(7, 0), + takenAt: at(7, 5), + skipped: false, + } as never); + + const res = await PUT( + putReq({ takenAt: "2026-05-15T10:00:00+02:00" }), + ROUTE_CTX, + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("start date"); + // The guard fires before any write. + expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("does not false-reject a takenAt in the early hours of the start day (tz day-key)", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-10T12:00:00Z")); + try { + // startsOn 2026-06-01; the edit is 00:30 local Berlin on the start day, + // which is 2026-05-31T22:30Z — a naive UTC compare would reject it. + vi.mocked(prisma.medication.findFirst).mockResolvedValue({ + id: "m1", + startsOn: new Date("2026-06-01T00:00:00Z"), + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + schedules: [ + { + id: "s1", + windowStart: "07:00", + windowEnd: "07:00", + daysOfWeek: null, + timesOfDay: ["07:00", "19:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }, + ], + } as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst) + .mockResolvedValueOnce({ + id: "e1", + userId: "user-1", + medicationId: "m1", + scheduledFor: at(7, 0), + takenAt: at(7, 5), + skipped: false, + } as never) + .mockResolvedValue(null as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue( + {} as never, + ); + vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({ + id: "e2", + userId: "user-1", + medicationId: "m1", + scheduledFor: new Date("2026-05-31T22:30:00Z"), + takenAt: new Date("2026-05-31T22:30:00Z"), + skipped: false, + } as never); + + const res = await PUT( + putReq({ takenAt: "2026-06-01T00:30:00+02:00" }), + ROUTE_CTX, + ); + expect(res.status).toBe(200); + } finally { + vi.useRealTimers(); + } + }); + it("422s a forceSlotInstant that is not a real slot", async () => { vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ id: "e1", diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index 5a8a76f19..0e1916ada 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -14,6 +14,7 @@ import { updateIntakeEventSchema } from "@/lib/validations/medication"; import { reconcileOneShotState } from "@/lib/medications/lifecycle"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; import { applyCanonicalSlotWrite, resolveForcedSlotForWrite, @@ -76,6 +77,35 @@ export const PUT = apiHandler( const data = parsed.data; + // v1.15.19 — start-date guard on an edited `takenAt` (audit P0-4). The + // schema bounds the instant to a plausible absolute range; this check + // adds the per-medication floor: a take cannot predate the day the + // medication starts (calendar-day comparison in the user's timezone, so + // a take in the early hours of the start day never false-rejects). + if (data.takenAt instanceof Date) { + const medication = await prisma.medication.findFirst({ + where: { id, userId: user.id }, + select: { startsOn: true }, + }); + if (medication?.startsOn) { + const takenDay = dayKeyForUserTz(data.takenAt, user.timezone); + const startsOnDay = medication.startsOn.toISOString().slice(0, 10); + if (takenDay < startsOnDay) { + annotate({ + action: { + name: "medications.intake.event.update.before-start-date", + }, + meta: { medication_id: id, event_id: eventId }, + }); + return apiError( + "takenAt is before the medication's start date", + 422, + { errorCode: "medications.intake.taken_at.before_start" }, + ); + } + } + } + // The post-edit state of the row (current values where the body omits a // field) — drives the re-attribution decision. const nextTakenAt = diff --git a/src/components/medications/__tests__/intake-edit-dialog-seed.test.tsx b/src/components/medications/__tests__/intake-edit-dialog-seed.test.tsx index 3111fb0ed..51cebe654 100644 --- a/src/components/medications/__tests__/intake-edit-dialog-seed.test.tsx +++ b/src/components/medications/__tests__/intake-edit-dialog-seed.test.tsx @@ -17,6 +17,9 @@ import { renderToStaticMarkup } from "react-dom/server"; vi.mock("@/lib/i18n/context", () => ({ useTranslations: () => ({ t: (key: string) => key }), + useFormatters: () => ({ + dateTime: (value: string | Date) => new Date(value).toISOString(), + }), })); vi.mock("@tanstack/react-query", () => ({ @@ -77,3 +80,64 @@ describe("IntakeEditDialog seed (G-1 §9 Q1)", () => { expect(html).toBe(""); }); }); + +describe("IntakeEditDialog guardrails (P0-4)", () => { + it("caps the datetime-local picker at now via the max attribute", () => { + const taken = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const html = renderToStaticMarkup( + {}} + />, + ); + // `YYYY-MM-DDTHH:mm` — exact value depends on render time, so pin the + // attribute presence + shape rather than the instant. + expect(html).toMatch(/max="\d{4}-\d{2}-\d{2}T\d{2}:\d{2}"/); + }); + + it("renders the scheduled-slot reference when the event carries one", () => { + const slot = new Date(2026, 2, 4, 8, 0).toISOString(); + const taken = new Date(2026, 2, 4, 8, 10).toISOString(); + const html = renderToStaticMarkup( + {}} + />, + ); + expect(html).toContain("medications.detail.intake.edit.scheduledForHint"); + // An on-time take never triggers the far-from-slot warning. + expect(html).not.toContain( + "medications.detail.intake.edit.farFromScheduledWarning", + ); + }); + + it("shows the non-blocking far-from-slot hint when takenAt sits >48h from the slot", () => { + const slot = new Date(2026, 2, 4, 8, 0).toISOString(); + // A month-off typo — exactly the P0-4 production case. + const taken = new Date(2026, 1, 4, 8, 0).toISOString(); + const html = renderToStaticMarkup( + {}} + />, + ); + expect(html).toContain( + "medications.detail.intake.edit.farFromScheduledWarning", + ); + }); + + it("no longer renders the note field the API never persisted (LOW-10)", () => { + const html = renderToStaticMarkup( + {}} + />, + ); + expect(html).not.toContain("intake-edit-note"); + expect(html).not.toContain("medications.detail.intake.edit.noteLabel"); + }); +}); diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index d13ddd53e..138ebe900 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -136,6 +136,7 @@ export function DoseHistoryLedger({ id: string; takenAt: string | null; skipped: boolean; + scheduledFor: string | null; } | null>(null); const [pendingDeleteId, setPendingDeleteId] = useState(null); const [marking, setMarking] = useState(null); @@ -384,6 +385,7 @@ function LedgerRowItem({ id: string; takenAt: string | null; skipped: boolean; + scheduledFor: string | null; }) => void; onDelete: (id: string) => void; }) { @@ -499,6 +501,7 @@ function LedgerRowItem({ id: intake.id as string, takenAt: intake.takenAt, skipped: intake.skipped, + scheduledFor: intake.scheduledFor, }) } className="min-h-9" diff --git a/src/components/medications/intake-edit-dialog.tsx b/src/components/medications/intake-edit-dialog.tsx index 45e513221..ee0e389c9 100644 --- a/src/components/medications/intake-edit-dialog.tsx +++ b/src/components/medications/intake-edit-dialog.tsx @@ -6,11 +6,19 @@ * * Restores the row-level edit affordance the v1.5.4 flat-form * retirement displaced (I-1 §15). The dialog opens from the - * intake-history kebab; fields are `takenAt` (datetime-local), - * `skipped` (boolean), `note` (string?). On save it PATCHes + * intake-history kebab; fields are `takenAt` (datetime-local) and + * `skipped` (boolean). On save it PATCHes * `/api/medications/{id}/intake/{eventId}` and fires the * medication cache bundle so the inline compliance tile + dashboard * chart converge in the same tick. + * + * v1.15.19 (audit P0-4) — date-typo guardrails on the edit path: + * the picker is capped at "now" (mirrors the add dialog), the row's + * scheduled slot renders under the input so the user sees what they + * are editing against, and a non-blocking hint appears when the + * picked time sits more than 48h from that slot. The former free-text + * note field is gone — the API schema stripped it and nothing ever + * persisted it (audit LOW-10), so the dialog no longer pretends. */ import { useState } from "react"; @@ -29,8 +37,7 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; -import { Textarea } from "@/components/ui/textarea"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys"; export interface IntakeEditDialogProps { @@ -40,20 +47,28 @@ export interface IntakeEditDialogProps { id: string; takenAt: string | null; skipped: boolean; - note?: string | null; + /** The row's slot anchor, ISO — renders as the "Geplant" reference + * and drives the far-from-slot hint. */ + scheduledFor?: string | null; } | null; onClose: () => void; } +/** Past this gap between the picked time and the scheduled slot the + * dialog surfaces the "check the date" hint — generous enough that a + * genuinely late dose never nags, tight enough that a month-off typo + * cannot pass silently. */ +const FAR_FROM_SLOT_MS = 48 * 60 * 60 * 1000; + /** - * Convert an ISO timestamp into the `` + * Convert an ISO timestamp (or Date) into the `` * shape `YYYY-MM-DDTHH:mm`. Returns an empty string for null inputs. * The string is rendered in the user's local time so the picker * matches the dose timestamp the user remembers. */ -function toDateTimeLocal(iso: string | null): string { - if (!iso) return ""; - const d = new Date(iso); +function toDateTimeLocal(value: string | Date | null): string { + if (!value) return ""; + const d = value instanceof Date ? value : new Date(value); if (Number.isNaN(d.getTime())) return ""; const pad = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad( @@ -61,6 +76,20 @@ function toDateTimeLocal(iso: string | null): string { )}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } +/** True when the picked local datetime sits more than 48h from the + * row's scheduled slot. Unparseable or absent values never warn. */ +export function isFarFromSlot( + takenAtLocal: string, + scheduledFor: string | null | undefined, +): boolean { + if (!takenAtLocal || !scheduledFor) return false; + const taken = new Date(takenAtLocal); + const slot = new Date(scheduledFor); + if (Number.isNaN(taken.getTime()) || Number.isNaN(slot.getTime())) + return false; + return Math.abs(taken.getTime() - slot.getTime()) > FAR_FROM_SLOT_MS; +} + export function IntakeEditDialog(props: IntakeEditDialogProps) { // Re-mount the body whenever the event id flips so the form state // initialises from the row the user clicked. The wrapper is also @@ -75,16 +104,19 @@ function IntakeEditDialogBody({ onClose, }: IntakeEditDialogProps) { const { t } = useTranslations(); + const fmt = useFormatters(); const queryClient = useQueryClient(); const [takenAt, setTakenAt] = useState(() => toDateTimeLocal(event?.takenAt ?? null), ); const [skipped, setSkipped] = useState(() => event?.skipped ?? false); - const [note, setNote] = useState(() => event?.note ?? ""); const [busy, setBusy] = useState(false); if (!event) return null; + const scheduledFor = event.scheduledFor ?? null; + const showFarHint = !skipped && isFarFromSlot(takenAt, scheduledFor); + async function save() { if (!event || busy) return; setBusy(true); @@ -100,9 +132,6 @@ function IntakeEditDialogBody({ } else { body.takenAt = null; } - if (note.trim().length > 0) { - body.note = note; - } const res = await fetch( `/api/medications/${medicationId}/intake/${event.id}`, { @@ -142,9 +171,30 @@ function IntakeEditDialogBody({ id="intake-edit-taken-at" type="datetime-local" value={takenAt} + max={toDateTimeLocal(new Date())} onChange={(e) => setTakenAt(e.target.value)} disabled={skipped} /> + {scheduledFor && ( +

+ {t("medications.detail.intake.edit.scheduledForHint", { + dateTime: fmt.dateTime(scheduledFor), + })} +

+ )} + {/* Non-blocking: a 48h+ gap to the slot is almost always a + date typo, but a deliberate far edit stays saveable. */} + {showFarHint && ( +

+ {t("medications.detail.intake.edit.farFromScheduledWarning")} +

+ )} -
- -