From b3f61d0c75dcd560c8feb7d903f141af096d613d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 1 Jun 2026 19:35:11 +0200 Subject: [PATCH 01/30] fix(medications): surface the first rolling dose at the start date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rolling schedules anchored next-due on start + N days when no intake had been logged yet, which skipped the dose due AT the start date and suppressed its reminder (the worker shares this engine). The +N cadence now begins only after the first intake is logged: with no intake the first dose is due at startsOn (or createdAt). A past start date with no intake surfaces the dose as due/overdue rather than rolling it forward — nextOccurrenceAfter floors the rolling search to the start of the user's day so an overdue or due-earlier-today dose stays visible. The post-intake re-anchor (lastIntakeAt + N) and the endsOn cap are unchanged; PRN, one-shot, RRULE and legacy cadences are unaffected. --- .../scheduling/__tests__/next-due.test.ts | 120 ++++++++++++++++++ .../scheduling/__tests__/recurrence.test.ts | 115 ++++++++++++++++- src/lib/medications/scheduling/recurrence.ts | 64 +++++++--- 3 files changed, 278 insertions(+), 21 deletions(-) create mode 100644 src/lib/medications/scheduling/__tests__/next-due.test.ts diff --git a/src/lib/medications/scheduling/__tests__/next-due.test.ts b/src/lib/medications/scheduling/__tests__/next-due.test.ts new file mode 100644 index 000000000..c80bcadd2 --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/next-due.test.ts @@ -0,0 +1,120 @@ +/** + * v1.8.5 — `computeNextDueAt` tests, focused on the rolling-cadence + * first-dose semantics. + * + * The server computes `nextDueAt` by asking the canonical engine for the + * earliest next slot across a medication's schedules. The medically + * important case: a rolling course whose `startsOn` is earlier today with + * no intake logged must surface its start dose as DUE/OVERDUE ("take + * now"), not roll it forward by N days (which also suppressed its + * reminder, since the worker shares the engine). + */ +import { describe, expect, it } from "vitest"; + +import { computeNextDueAt } from "../next-due"; +import type { + WorkerMedicationRow, + WorkerScheduleRow, +} from "../worker-helpers"; + +function d(iso: string): Date { + return new Date(iso); +} + +function makeMedication( + overrides: Partial = {}, +): WorkerMedicationRow { + return { + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: d("2026-01-01T00:00:00Z"), + ...overrides, + }; +} + +function makeRollingSchedule( + overrides: Partial = {}, +): WorkerScheduleRow { + return { + id: "sched-1", + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["08:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: 7, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + ...overrides, + }; +} + +const BERLIN = "Europe/Berlin"; + +function berlinDay(date: Date): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone: BERLIN, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(date); +} + +describe("computeNextDueAt — rolling first dose", () => { + it("surfaces an overdue first dose (past startsOn, no intake) as DUE today", () => { + const startsOn = d("2026-06-10T06:00:00Z"); // 08:00 Berlin slot is past + const now = d("2026-06-10T12:00:00Z"); // noon, after the slot + const next = computeNextDueAt({ + medication: makeMedication({ startsOn }), + schedules: [makeRollingSchedule()], + now, + userTz: BERLIN, + lastIntakeAt: null, + }); + expect(next).not.toBeNull(); + expect(next!.getTime()).toBeLessThan(now.getTime()); // overdue + expect(berlinDay(next!)).toBe("2026-06-10"); + }); + + it("returns the future first dose AT startsOn (no intake) without adding N", () => { + const startsOn = d("2026-06-12T00:00:00Z"); + const now = d("2026-06-10T12:00:00Z"); + const next = computeNextDueAt({ + medication: makeMedication({ startsOn }), + schedules: [makeRollingSchedule()], + now, + userTz: BERLIN, + lastIntakeAt: null, + }); + expect(next).not.toBeNull(); + expect(berlinDay(next!)).toBe("2026-06-12"); + }); + + it("re-anchors on lastIntakeAt + N once an intake exists", () => { + const now = d("2026-06-10T12:00:00Z"); + const next = computeNextDueAt({ + medication: makeMedication({ startsOn: d("2026-06-01T00:00:00Z") }), + schedules: [makeRollingSchedule()], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-06-09T08:00:00Z"), + }); + expect(next).not.toBeNull(); + expect(berlinDay(next!)).toBe("2026-06-16"); // 2026-06-09 + 7d + }); + + it("returns null when no schedules", () => { + const next = computeNextDueAt({ + medication: makeMedication(), + schedules: [], + now: d("2026-06-10T12:00:00Z"), + userTz: BERLIN, + lastIntakeAt: null, + }); + expect(next).toBeNull(); + }); +}); diff --git a/src/lib/medications/scheduling/__tests__/recurrence.test.ts b/src/lib/medications/scheduling/__tests__/recurrence.test.ts index 9dbfcb921..daaa9fa2b 100644 --- a/src/lib/medications/scheduling/__tests__/recurrence.test.ts +++ b/src/lib/medications/scheduling/__tests__/recurrence.test.ts @@ -279,9 +279,12 @@ describe("occurrencesBetween — rolling", () => { ); }); - it("rollingIntervalDays=7 + lastIntakeAt=null + startsOn=T-2d emits one at T+5d", () => { + it("rollingIntervalDays=7 + lastIntakeAt=null + future startsOn → first dose due AT startsOn (not startsOn+N)", () => { + // v1.8.5 — with no intake yet the FIRST rolling dose lands ON the + // start date, not start + N. A future startsOn therefore emits its + // slot at startsOn itself. const NOW = d("2026-06-10T12:00:00Z"); - const startsOn = new Date(NOW.getTime() - 2 * 24 * 60 * 60 * 1000); + const startsOn = new Date(NOW.getTime() + 2 * 24 * 60 * 60 * 1000); const schedule = makeSchedule({ rollingIntervalDays: 7, timesOfDay: ["08:00"], @@ -294,7 +297,7 @@ describe("occurrencesBetween — rolling", () => { const to = new Date(NOW.getTime() + 14 * 24 * 60 * 60 * 1000); const slots = occurrencesBetween(schedule, from, to, ctx); expect(slots).toHaveLength(1); - const expectedDay = new Date(startsOn.getTime() + 7 * 24 * 60 * 60 * 1000); + // The slot is the start dose itself (startsOn), NOT startsOn + 7d. expect( new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", @@ -308,10 +311,36 @@ describe("occurrencesBetween — rolling", () => { year: "numeric", month: "2-digit", day: "2-digit", - }).format(expectedDay), + }).format(startsOn), ); }); + it("rollingIntervalDays=7 + lastIntakeAt=null + startsOn=today → first dose due today", () => { + // Fresh rolling course starting today: the start dose is due today, + // not seven days out. + const startsOn = d("2026-06-10T00:00:00Z"); + const schedule = makeSchedule({ + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + }); + const ctx = makeCtx({ + lastIntakeAt: null, + medication: { startsOn }, + }); + const from = d("2026-06-10T00:00:00Z"); + const to = d("2026-06-24T23:59:59Z"); + const slots = occurrencesBetween(schedule, from, to, ctx); + expect(slots).toHaveLength(1); + expect( + new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(slots[0].at), + ).toBe("2026-06-10"); + }); + it("rolling terminates when endsOn falls before next-due", () => { const NOW = d("2026-06-10T12:00:00Z"); const lastIntake = new Date(NOW.getTime() - 3 * 24 * 60 * 60 * 1000); @@ -332,6 +361,84 @@ describe("occurrencesBetween — rolling", () => { ); expect(slots).toHaveLength(0); }); + + it("nextOccurrenceAfter surfaces an overdue first dose (past startsOn, no intake) as DUE today", () => { + // v1.8.5 — a rolling course whose startsOn is earlier today with no + // intake logged yet must surface the start dose as DUE/OVERDUE, not + // roll it forward by N. The strict `> now` filter previously dropped + // it; the rolling branch now floors to the start of the user's day. + const startsOn = d("2026-06-10T06:00:00Z"); // 08:00 Berlin slot is past + const NOW = d("2026-06-10T12:00:00Z"); // noon — after the 08:00 slot + const schedule = makeSchedule({ + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + }); + const ctx = makeCtx({ + lastIntakeAt: null, + medication: { startsOn }, + }); + const next = nextOccurrenceAfter(schedule, NOW, ctx); + expect(next).not.toBeNull(); + // The surfaced slot is today's start dose (08:00 Berlin), already in + // the past relative to NOW — i.e. overdue, "take now". + expect(next!.at.getTime()).toBeLessThan(NOW.getTime()); + expect( + new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(next!.at), + ).toBe("2026-06-10"); + }); + + it("nextOccurrenceAfter returns the future first dose AT startsOn (no intake) without adding N", () => { + const NOW = d("2026-06-10T12:00:00Z"); + const startsOn = d("2026-06-12T00:00:00Z"); // 2 days out + const schedule = makeSchedule({ + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + }); + const ctx = makeCtx({ + lastIntakeAt: null, + medication: { startsOn }, + }); + const next = nextOccurrenceAfter(schedule, NOW, ctx); + expect(next).not.toBeNull(); + expect( + new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(next!.at), + ).toBe("2026-06-12"); + }); + + it("nextOccurrenceAfter re-anchors on lastIntakeAt + N after an intake", () => { + // The v1.8.4 re-anchor must stay: once an intake exists, the next + // slot is lastIntakeAt + N, NOT the start date. + const NOW = d("2026-06-10T12:00:00Z"); + const lastIntake = d("2026-06-09T08:00:00Z"); + const schedule = makeSchedule({ + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + }); + const ctx = makeCtx({ + lastIntakeAt: lastIntake, + medication: { startsOn: d("2026-06-01T00:00:00Z") }, + }); + const next = nextOccurrenceAfter(schedule, NOW, ctx); + expect(next).not.toBeNull(); + expect( + new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(next!.at), + ).toBe("2026-06-16"); // 2026-06-09 + 7d + }); }); // ──────────────────────────────────────────────────────────────────── diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index 6496ad39c..8b59e1bd5 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -16,10 +16,11 @@ * 1. **One-shot** (`medication.oneShot === true`) — single slot at * the anchor date, one per `timesOfDay` entry. * 2. **Rolling** (`schedule.rollingIntervalDays !== null`) — next - * slot is `lastIntakeAt + N days` (or `startsOn + N days` if - * the user has never logged an intake). Only the immediately - * next slot is emitted; further slots depend on the user - * actually logging the intake. + * slot is `lastIntakeAt + N days`. With no intake yet the FIRST + * dose is due AT `startsOn ?? createdAt` (not `+ N`); the `+ N` + * cadence only begins once the first intake is logged. Only the + * immediately next slot is emitted; further slots depend on the + * user actually logging the intake. * 3. **RRULE** (`schedule.rrule !== null`) — RFC 5545 expansion * via the `rrule` npm lib. Day-anchored dates are returned in * UTC by the library; each `timesOfDay` entry is then applied @@ -248,15 +249,19 @@ export function nextOccurrenceAfter( } // Rolling is also single-slot (only emits the immediately next). + // + // v1.8.5 — floor the search window to the start of the user's current + // day rather than the strict `after` instant. A rolling dose whose slot + // lands earlier today (a past `startsOn` with no intake, or an overdue + // `lastIntakeAt + N`) is DUE/OVERDUE and must surface as "take now", + // not be skipped by a strict `> now` filter. Flooring to start-of-day + // keeps a slot the user has not yet acted on visible through the rest + // of its due day instead of silently rolling it forward by N. if (schedule.rollingIntervalDays !== null) { - const slots = expandRolling( - schedule, - ctx, - new Date(after.getTime() + 1), - limit, - ); + const dayFloor = startOfDayInTz(after, ctx.timeZone); + const slots = expandRolling(schedule, ctx, dayFloor, limit); for (const s of slots) { - if (s.at.getTime() <= after.getTime()) continue; + if (s.at.getTime() < dayFloor.getTime()) continue; if (cyclic && !isInCyclicOnWeek(s.at, schedule, ctx)) continue; return s; } @@ -334,9 +339,21 @@ function expandOneShot( // ──────────────────────────────────────────────────────────────────── /** - * Rolling cadence — "every N days from last intake". Emits one - * occurrence at `(lastIntakeAt ?? startsOn ?? createdAt) + N days`, - * applied at the first `timesOfDay` entry (or `windowStart`). + * Rolling cadence — "every N days from last intake". + * + * Anchoring (v1.8.5 — medically-correct first dose): + * - After an intake → next slot is `lastIntakeAt + N days` (the + * v1.8.4 re-anchor). Logging a dose pushes the next one out by N. + * - No intake yet → the FIRST dose is due AT `startsOn ?? createdAt`, + * NOT `start + N`. A rolling course's first dose lands on the start + * date; the `+ N` cadence only kicks in once the first intake is + * logged. The pre-v1.8.5 engine emitted `start + N` here, which + * silently skipped the start-date dose (and suppressed its reminder, + * since the worker shares this engine). + * + * A past `startsOn` with no intake therefore surfaces the start dose as + * DUE/OVERDUE ("take now") via `nextOccurrenceAfter`'s rolling branch, + * rather than rolling it forward. * * Design choice: only the FIRST time-of-day entry is emitted even * when multiple are configured. The rolling semantic — "every N days @@ -358,10 +375,13 @@ function expandRolling( ): Occurrence[] { const n = schedule.rollingIntervalDays; if (n === null || n <= 0) return []; - const anchor = - ctx.lastIntakeAt ?? ctx.medication.startsOn ?? ctx.medication.createdAt; - const nextDue = new Date(anchor.getTime() + n * DAY_MS); + // After an intake the next dose is N days out. With no intake yet the + // FIRST dose is the start date itself (no `+ N`) — see the doc comment. + const nextDue = + ctx.lastIntakeAt !== null + ? new Date(ctx.lastIntakeAt.getTime() + n * DAY_MS) + : ctx.medication.startsOn ?? ctx.medication.createdAt; // endsOn cap. if ( @@ -560,6 +580,16 @@ function applyTimeOfDayToDate(day: Date, hhmm: string, tz: string): Date { return guess; } +/** + * The UTC instant of midnight (00:00 wall-clock) on `instant`'s day in + * the user's IANA timezone. Used by the rolling-cadence next-due search + * to floor the window to the start of the user's current day so an + * overdue / due-earlier-today rolling dose still surfaces. + */ +function startOfDayInTz(instant: Date, tz: string): Date { + return applyTimeOfDayToDate(instant, "00:00", tz); +} + function tzOffsetMinutes(date: Date, tz: string): number { const parts = wallClockInTz(date, tz); const asIfUtc = Date.UTC( From ddb08b048ac84af4a19d84157a2a39043a87ba78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 1 Jun 2026 19:36:33 +0200 Subject: [PATCH 02/30] fix(medications): stop the delete confirm hanging in a spinner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a medication from the danger zone awaited a prefix-invalidate of the "medications" key bundle before navigating. That bundle covers the detail page's own active query, so the invalidation refetched the just- deleted resource, hit a 404, retried with backoff, and never resolved — the confirm button spun forever even though the delete had persisted. Evict the detail query before invalidating, navigate to the list first, then fire-and-forget the dependent-bundle invalidation. The busy state is also cleared on the failed-response path. The detail query opts out of retries so a deleted-resource refetch never burns a backoff cycle. --- src/app/medications/[id]/page.tsx | 5 ++++ .../sections/destructive-zone-section.tsx | 30 +++++++++++++++---- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/app/medications/[id]/page.tsx b/src/app/medications/[id]/page.tsx index 4ce75cc2a..4f5a5aa93 100644 --- a/src/app/medications/[id]/page.tsx +++ b/src/app/medications/[id]/page.tsx @@ -132,6 +132,11 @@ export default function MedicationDetailPage({ return (await res.json()).data as MedicationDetailSnapshot; }, enabled: isAuthenticated, + // A deleted medication 404s here. Don't burn a retry/backoff cycle on + // a resource that no longer exists — the delete handler evicts this + // key on success, but `retry: false` hardens any caller that + // prefix-invalidates `["medications"]` while this page is mounted. + retry: false, }); // v1.7.2 W3 — derive the wizard payload purely to feed the read-only diff --git a/src/components/medications/sections/destructive-zone-section.tsx b/src/components/medications/sections/destructive-zone-section.tsx index 16888ed9a..29d617ed0 100644 --- a/src/components/medications/sections/destructive-zone-section.tsx +++ b/src/components/medications/sections/destructive-zone-section.tsx @@ -47,7 +47,11 @@ import { Card } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; import { useTranslations } from "@/lib/i18n/context"; -import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys"; +import { + invalidateKeys, + medicationDependentKeys, + queryKeys, +} from "@/lib/query-keys"; const PAUSE_SWITCH_ID = "medication-detail-pause-switch"; const PAUSE_TITLE_ID = "medication-detail-pause-title"; @@ -283,15 +287,29 @@ export function DangerZoneBody({ }); if (!res.ok) { toast.error(t("medications.detail.zone.delete.failed")); + setTier3bBusy(false); return; } - // Cache cascade first so the list page paints without the - // medication, then route. The bundle includes - // `compliance-chart-inline` so the inline chart's stale cache - // evicts before the user lands on the list. - await invalidateKeys(queryClient, medicationDependentKeys); + // Drop the deleted medication's own detail query before the + // prefix-invalidate below. `medicationDependentKeys` carries + // `["medications"]`, which covers the active `["medications", id]` + // detail query on this page; awaiting that invalidation refetched + // a now-404 resource, retried, and hung the spinner forever (the + // delete had already persisted server-side). `removeQueries` + // evicts the dead key so the prefix-invalidate can't refetch it. + queryClient.removeQueries({ + queryKey: queryKeys.medicationDetail(medicationId), + }); + // Navigate away first — we're leaving the detail page, so the + // dependent-bundle invalidation can settle in the background while + // the list page mounts and re-reads `["medications"]` fresh. toast.success(t("medications.detail.zone.delete.toast")); + setDeleteDialogOpen(false); router.push("/medications"); + // Fire-and-forget the dependent-bundle invalidation (list / + // analytics / compliance / inline chart). Not awaited: navigation + // already happened and a transient refetch must not block it. + void invalidateKeys(queryClient, medicationDependentKeys); } catch { toast.error(t("medications.detail.zone.delete.failed")); setTier3bBusy(false); From d660279c97f687bb6c7468e844672d04c609b1cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 1 Jun 2026 19:37:58 +0200 Subject: [PATCH 03/30] fix(medications): stop the wizard showing each time-of-day suggestion twice The schedule wizard's time-of-day step rendered the four suggested times twice: once as the icon chips from the step itself, and again as the labelled preset row built into the chip component below. Add an opt-out on the chip component and pass it from the step so only the icon-based suggestions remain. The period is still conveyed by the icon plus an aria-label; the offered times and selection logic are unchanged. The preset row stays on by default for every other caller. --- .../scheduling/TimesOfDayChips.tsx | 76 +++++++++++-------- .../__tests__/TimesOfDayChips.test.tsx | 18 +++++ .../medications/wizard/steps/Step7Times.tsx | 4 + 3 files changed, 65 insertions(+), 33 deletions(-) diff --git a/src/components/medications/scheduling/TimesOfDayChips.tsx b/src/components/medications/scheduling/TimesOfDayChips.tsx index 3b01e87b3..b166b6293 100644 --- a/src/components/medications/scheduling/TimesOfDayChips.tsx +++ b/src/components/medications/scheduling/TimesOfDayChips.tsx @@ -46,6 +46,13 @@ export interface TimesOfDayChipsProps { disabled?: boolean; /** Translation namespace prefix. */ i18nPrefix?: string; + /** + * Render the built-in Morning / Noon / Evening / Night preset row. + * Default `true`. The wizard's Step 7 supplies its own icon-based + * preset row, so it passes `false` to avoid showing each suggested + * time twice (the icon chip from Step 7 and the labelled chip here). + */ + showPresets?: boolean; } /** Preset table — wall-clock HH:mm and i18n suffix key. */ @@ -106,6 +113,7 @@ export function TimesOfDayChips({ maxChips = 8, disabled = false, i18nPrefix = "medications.scheduling.timesOfDay", + showPresets = true, }: TimesOfDayChipsProps) { const { t } = useTranslations(); const inputId = useId(); @@ -165,38 +173,40 @@ export function TimesOfDayChips({ aria-label={t(`${i18nPrefix}.label`)} > {/* Preset row */} -
- {PRESETS.map(({ key, value: preset }) => { - const isOn = sortedValue.includes(preset); - const label = `${t(`${i18nPrefix}.presets.${key}`)} ${preset}`; - return ( - - ); - })} -
+ {showPresets && ( +
+ {PRESETS.map(({ key, value: preset }) => { + const isOn = sortedValue.includes(preset); + const label = `${t(`${i18nPrefix}.presets.${key}`)} ${preset}`; + return ( + + ); + })} +
+ )} {/* Chip list */} {sortedValue.length === 0 ? ( @@ -225,7 +235,7 @@ export function TimesOfDayChips({ disabled={disabled} aria-label={`${t(`${i18nPrefix}.remove`)} ${time}`} onClick={() => onRemove(time)} - className="focus-visible:ring-ring inline-flex h-7 w-7 items-center justify-center rounded-sm hover:bg-background focus-visible:outline-none focus-visible:ring-2 disabled:opacity-50" + className="focus-visible:ring-ring hover:bg-background inline-flex h-7 w-7 items-center justify-center rounded-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50" data-slot="times-of-day-chip-remove" >