From bb1a587f8bff4dfa4b76d8ff20065f94e82784cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 28 May 2026 09:09:36 +0200 Subject: [PATCH] refactor(meds): wire the edit form through the v1.5 picker primitives Drops the legacy weekday grid in favour of the cadence picker, the times-of-day chips, and the course-window row. Pre-v1.5 medications read-fall-back through inferCadenceFromLegacy; writes go to both shapes so the v1.5.x dual-write window stays intact. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/medications/page.tsx | 33 + .../__tests__/medication-form-v15.test.tsx | 246 ++++++ .../medications/medication-form.tsx | 820 ++++++++++-------- .../medications/scheduling/legacy-bridge.ts | 203 +++++ 4 files changed, 962 insertions(+), 340 deletions(-) create mode 100644 src/components/medications/__tests__/medication-form-v15.test.tsx create mode 100644 src/components/medications/scheduling/legacy-bridge.ts diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index ec391afa6..e15481ffe 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -56,6 +56,14 @@ interface Schedule { label: string | null; dose: string | null; daysOfWeek: string | null; + /** v1.5 — first-class times-of-day. */ + timesOfDay?: string[]; + /** v1.5 — RFC 5545 RRULE string for calendar-anchored cadences. */ + rrule?: string | null; + /** v1.5 — flexible-rolling interval in days. */ + rollingIntervalDays?: number | null; + /** v1.5 — reminder grace window in minutes. */ + reminderGraceMinutes?: number | null; } interface Medication { @@ -71,6 +79,12 @@ interface Medication { notificationsEnabled: boolean; pausedAt: string | null; lastTakenAt: string | null; + /** v1.5 — medication-level course start date (ISO string). */ + startsOn?: string | null; + /** v1.5 — medication-level course end date (ISO string). */ + endsOn?: string | null; + /** v1.5 — single-administration medication. */ + oneShot?: boolean; schedules: Schedule[]; } @@ -297,12 +311,31 @@ export default function MedicationsPage() { dosesPerUnit: editingMed.dosesPerUnit ?? null, active: editingMed.active, notificationsEnabled: editingMed.notificationsEnabled, + // v1.5 — medication-level course window + one-shot + // flag. Pass-through so the form can hydrate the + // CourseWindowRow + oneShot Switch with the row's + // current values. + startsOn: editingMed.startsOn + ? new Date(editingMed.startsOn) + : null, + endsOn: editingMed.endsOn + ? new Date(editingMed.endsOn) + : null, + oneShot: editingMed.oneShot ?? false, schedules: editingMed.schedules.map((s) => ({ windowStart: s.windowStart, windowEnd: s.windowEnd, label: s.label ?? "", dose: s.dose ?? "", ...parseScheduleRecurrence(s.daysOfWeek), + // v1.5 — pass-through the new scheduling fields so + // the form can render the CadencePicker + chips in + // the row's actual cadence rather than the + // legacy-inferred fallback. + timesOfDay: s.timesOfDay, + rrule: s.rrule, + rollingIntervalDays: s.rollingIntervalDays, + reminderGraceMinutes: s.reminderGraceMinutes, })), } : undefined diff --git a/src/components/medications/__tests__/medication-form-v15.test.tsx b/src/components/medications/__tests__/medication-form-v15.test.tsx new file mode 100644 index 000000000..c598921b7 --- /dev/null +++ b/src/components/medications/__tests__/medication-form-v15.test.tsx @@ -0,0 +1,246 @@ +/** + * v1.5.0 — pure-helper tests for the medication-form refactor. + * + * The edit-only path of `medication-form.tsx` now composes the + * v1.5 picker primitives. Two helpers carry the logic that survives + * the legacy → v1.5 dual-write window: + * + * inferCadenceFromLegacy — choose the picker kind + sub-controls + * that match a pre-v1.5 schedule + * legacyPairFromCadence — derive the legacy (daysOfWeek, + * intervalWeeks) pair the route still + * persists alongside the v1.5 fields + * + * Both are pure functions; the tests pin the mapping table from the + * design synthesis so a future picker tweak that drops one branch + * surfaces here, not in a snapshot from production. + */ +import { describe, expect, it } from "vitest"; + +import { + inferCadenceFromLegacy, + legacyPairFromCadence, + tokensToWeekdayIndexes, + weekdayIndexesToTokens, + weekdayIndexToToken, +} from "@/components/medications/scheduling/legacy-bridge"; +import { + DEFAULT_SUB_CONTROLS, + type WeekdayToken, +} from "@/components/medications/scheduling/types"; + +describe("weekdayIndexToToken", () => { + it("maps Sunday-anchored 0..6 to SU/MO/TU/WE/TH/FR/SA", () => { + expect(weekdayIndexToToken(0)).toBe("SU"); + expect(weekdayIndexToToken(1)).toBe("MO"); + expect(weekdayIndexToToken(2)).toBe("TU"); + expect(weekdayIndexToToken(3)).toBe("WE"); + expect(weekdayIndexToToken(4)).toBe("TH"); + expect(weekdayIndexToToken(5)).toBe("FR"); + expect(weekdayIndexToToken(6)).toBe("SA"); + }); + + it("returns null for out-of-range indexes", () => { + expect(weekdayIndexToToken(-1)).toBeNull(); + expect(weekdayIndexToToken(7)).toBeNull(); + expect(weekdayIndexToToken(NaN)).toBeNull(); + }); +}); + +describe("weekdayIndexesToTokens", () => { + it("preserves the canonical Mo-Su order independent of input order", () => { + expect(weekdayIndexesToTokens([5, 1, 3])).toEqual(["MO", "WE", "FR"]); + expect(weekdayIndexesToTokens([0, 6])).toEqual(["SA", "SU"]); + }); + + it("dedupes duplicate indexes", () => { + expect(weekdayIndexesToTokens([1, 1, 1])).toEqual(["MO"]); + }); + + it("filters out-of-range values silently", () => { + expect(weekdayIndexesToTokens([1, 99, 3, -1])).toEqual(["MO", "WE"]); + }); + + it("returns [] for an empty input", () => { + expect(weekdayIndexesToTokens([])).toEqual([]); + }); +}); + +describe("tokensToWeekdayIndexes", () => { + it("inverts weekdayIndexesToTokens", () => { + const tokens: WeekdayToken[] = ["MO", "WE", "FR"]; + expect(tokensToWeekdayIndexes(tokens)).toEqual([1, 3, 5]); + }); + + it("returns Sunday-anchored sorted indexes", () => { + expect(tokensToWeekdayIndexes(["SU", "SA", "MO"])).toEqual([0, 1, 6]); + }); + + it("returns [] for an empty input", () => { + expect(tokensToWeekdayIndexes([])).toEqual([]); + }); +}); + +describe("inferCadenceFromLegacy — mapping table", () => { + it("empty daysOfWeek + intervalWeeks=1 → daily", () => { + const result = inferCadenceFromLegacy({ + daysOfWeek: [], + intervalWeeks: 1, + }); + expect(result.value.kind).toBe("daily"); + expect(result.value.rrule).toBe("FREQ=DAILY"); + expect(result.value.rollingIntervalDays).toBeNull(); + expect(result.value.oneShot).toBe(false); + expect(result.subControls).toEqual(DEFAULT_SUB_CONTROLS); + }); + + it("non-empty daysOfWeek + intervalWeeks=1 → weekdays", () => { + const result = inferCadenceFromLegacy({ + daysOfWeek: [1, 3, 5], + intervalWeeks: 1, + }); + expect(result.value.kind).toBe("weekdays"); + expect(result.value.rrule).toBe("FREQ=WEEKLY;BYDAY=MO,WE,FR"); + expect(result.subControls.weekdays).toEqual(["MO", "WE", "FR"]); + }); + + it("non-empty daysOfWeek + intervalWeeks>1 → everyNWeeks", () => { + const result = inferCadenceFromLegacy({ + daysOfWeek: [3], + intervalWeeks: 2, + }); + expect(result.value.kind).toBe("everyNWeeks"); + expect(result.value.rrule).toBe("FREQ=WEEKLY;INTERVAL=2;BYDAY=WE"); + expect(result.subControls.weekdays).toEqual(["WE"]); + expect(result.subControls.intervalWeeks).toBe(2); + }); + + it("preserves the multi-week interval verbatim up to the picker cap (52)", () => { + const result = inferCadenceFromLegacy({ + daysOfWeek: [1], + intervalWeeks: 4, + }); + expect(result.value.rrule).toBe("FREQ=WEEKLY;INTERVAL=4;BYDAY=MO"); + expect(result.subControls.intervalWeeks).toBe(4); + }); + + it("empty daysOfWeek + intervalWeeks>1 → safe daily fallback", () => { + // Pathological legacy shape — daysOfWeek empty AND intervalWeeks + // bumped past 1. The legacy reader treats this as "every day every + // N weeks" but the picker has no clean home for it; surface daily + // so the user can pick a real cadence before saving. + const result = inferCadenceFromLegacy({ + daysOfWeek: [], + intervalWeeks: 3, + }); + expect(result.value.kind).toBe("daily"); + }); + + it("NaN intervalWeeks → daily fallback (no throw)", () => { + const result = inferCadenceFromLegacy({ + daysOfWeek: [], + intervalWeeks: NaN, + }); + expect(result.value.kind).toBe("daily"); + }); + + it("intervalWeeks=0 + non-empty daysOfWeek → safe daily fallback", () => { + const result = inferCadenceFromLegacy({ + daysOfWeek: [1, 3], + intervalWeeks: 0, + }); + expect(result.value.kind).toBe("daily"); + }); +}); + +describe("legacyPairFromCadence — dual-write derivation", () => { + it("weekdays cadence → daysOfWeek + intervalWeeks=1", () => { + const { value, subControls } = inferCadenceFromLegacy({ + daysOfWeek: [1, 3, 5], + intervalWeeks: 1, + }); + expect(legacyPairFromCadence(value, subControls)).toEqual({ + daysOfWeek: [1, 3, 5], + intervalWeeks: 1, + }); + }); + + it("everyNWeeks cadence → daysOfWeek + intervalWeeks (clamped to 4)", () => { + const { value, subControls } = inferCadenceFromLegacy({ + daysOfWeek: [3], + intervalWeeks: 2, + }); + expect(legacyPairFromCadence(value, subControls)).toEqual({ + daysOfWeek: [3], + intervalWeeks: 2, + }); + }); + + it("everyNWeeks beyond the legacy cap clamps to 4", () => { + // Picker accepts intervalWeeks up to 52; the legacy column tops + // out at 4. The dual-write helper clamps so the row still + // persists, while the canonical engine reads `rrule` for the + // full interval. + const value = { + kind: "everyNWeeks" as const, + rrule: "FREQ=WEEKLY;INTERVAL=12;BYDAY=MO", + rollingIntervalDays: null, + oneShot: false, + }; + const subControls = { ...DEFAULT_SUB_CONTROLS, intervalWeeks: 12 }; + expect(legacyPairFromCadence(value, subControls)).toEqual({ + daysOfWeek: [1], + intervalWeeks: 4, + }); + }); + + it("daily cadence → empty daysOfWeek, intervalWeeks=1", () => { + const { value, subControls } = inferCadenceFromLegacy({ + daysOfWeek: [], + intervalWeeks: 1, + }); + expect(legacyPairFromCadence(value, subControls)).toEqual({ + daysOfWeek: [], + intervalWeeks: 1, + }); + }); + + it("rolling cadence → empty legacy pair (most permissive fallback)", () => { + const value = { + kind: "rolling" as const, + rrule: null, + rollingIntervalDays: 7, + oneShot: false, + }; + expect(legacyPairFromCadence(value, DEFAULT_SUB_CONTROLS)).toEqual({ + daysOfWeek: [], + intervalWeeks: 1, + }); + }); + + it("oneShot cadence → empty legacy pair", () => { + const value = { + kind: "oneShot" as const, + rrule: null, + rollingIntervalDays: null, + oneShot: true, + }; + expect(legacyPairFromCadence(value, DEFAULT_SUB_CONTROLS)).toEqual({ + daysOfWeek: [], + intervalWeeks: 1, + }); + }); + + it("monthly cadence → empty legacy pair (no clean legacy mirror)", () => { + const value = { + kind: "monthly" as const, + rrule: "FREQ=MONTHLY;BYMONTHDAY=1", + rollingIntervalDays: null, + oneShot: false, + }; + expect(legacyPairFromCadence(value, DEFAULT_SUB_CONTROLS)).toEqual({ + daysOfWeek: [], + intervalWeeks: 1, + }); + }); +}); diff --git a/src/components/medications/medication-form.tsx b/src/components/medications/medication-form.tsx index fdf8efe95..ce5f81350 100644 --- a/src/components/medications/medication-form.tsx +++ b/src/components/medications/medication-form.tsx @@ -1,11 +1,24 @@ "use client"; +// i18n keys added: +// medications.scheduling.cadence.section — "Cadence" section header +// medications.scheduling.timesOfDay.section — "Times of day" section header +// medications.scheduling.courseWindow.section — "Course window" section header +// medications.scheduling.oneShot.section — "Single dose" toggle label +// medications.scheduling.legacyWindow.section — "Reminder window (legacy)" header +// medications.scheduling.legacyWindow.help — explainer for the legacy fallback +// The picker primitives consume `medications.scheduling.cadence.*`, +// `medications.scheduling.timesOfDay.*`, and +// `medications.scheduling.courseWindow.*` via template-string keys +// (see CadencePicker.tsx, TimesOfDayChips.tsx, CourseWindowRow.tsx). + import { useId, useState } from "react"; import { createPortal } from "react-dom"; import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, @@ -42,17 +55,29 @@ import { Play, Plus, RotateCcw, - Settings2, Terminal, Trash2, Upload, } from "lucide-react"; -import { formatTimeWindowRange } from "@/lib/time-window-format"; import { toast } from "sonner"; -import { useTranslations, useFormatters } from "@/lib/i18n/context"; -import type { Locale } from "@/lib/i18n/config"; +import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys"; import { PhaseConfigDialog } from "@/components/medications/phase-config-dialog"; +import { CadencePicker } from "@/components/medications/scheduling/CadencePicker"; +import { + TimesOfDayChips, + addTime, +} from "@/components/medications/scheduling/TimesOfDayChips"; +import { CourseWindowRow } from "@/components/medications/scheduling/CourseWindowRow"; +import { + type CadenceSubControls, + type CadenceValue, + DEFAULT_SUB_CONTROLS, +} from "@/components/medications/scheduling/types"; +import { + inferCadenceFromLegacy, + legacyPairFromCadence, +} from "@/components/medications/scheduling/legacy-bridge"; // DOSE_UNITS built dynamically via t() in the component @@ -73,89 +98,51 @@ function getDayLabelsShort(t: TranslateFn): string[] { ]; } -function getDayLabelsLong(t: TranslateFn): string[] { - return [ - t("medications.weekdaySundayPlural"), - t("medications.weekdayMondayPlural"), - t("medications.weekdayTuesdayPlural"), - t("medications.weekdayWednesdayPlural"), - t("medications.weekdayThursdayPlural"), - t("medications.weekdayFridayPlural"), - t("medications.weekdaySaturdayPlural"), - ]; -} - -function getNextRecurrenceDate(schedule: Schedule): Date | null { - const now = new Date(); - const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const weekMs = 7 * 24 * 60 * 60 * 1000; - const startWeekIndex = Math.floor(start.getTime() / weekMs); - - for (let offset = 0; offset <= 400; offset++) { - const candidate = new Date(start); - candidate.setDate(start.getDate() + offset); - - const candidateWeekIndex = Math.floor(candidate.getTime() / weekMs); - const isIntervalWeek = - schedule.intervalWeeks <= 1 || - candidateWeekIndex % schedule.intervalWeeks === - startWeekIndex % schedule.intervalWeeks; - if (!isIntervalWeek) continue; - - if ( - schedule.daysOfWeek.length === 0 || - schedule.daysOfWeek.includes(candidate.getDay()) - ) { - return candidate; - } - } - - return null; -} - -function formatRecurrenceSummary(schedule: Schedule, t: TranslateFn): string { - const dayLabelsShort = getDayLabelsShort(t); - const dayLabelsLong = getDayLabelsLong(t); - const dayText = - schedule.daysOfWeek.length === 0 - ? t("medications.scheduleDaily") - : schedule.daysOfWeek.length === 1 - ? dayLabelsLong[schedule.daysOfWeek[0]] - : schedule.daysOfWeek.map((day) => dayLabelsShort[day]).join(", "); - const intervalText = - schedule.intervalWeeks === 1 - ? t("medications.scheduleEveryWeek") - : `${t("medications.scheduleEveryNWeeks").replace("{n}", String(schedule.intervalWeeks))}`; - return `${t("medications.scheduleIntervalPrefix")} ${dayText} - ${intervalText}`; -} - -function formatNextWindowSummary( - schedule: Schedule, - t: TranslateFn, - formatShortDate: (date: Date) => string, - locale: Locale, -): string { - const isSimpleDaily = - schedule.daysOfWeek.length === 0 && schedule.intervalWeeks === 1; - if (isSimpleDaily) { - return `${t("medications.nextSchedulePrefix")} ${t("medications.nextScheduleDaily")}, ${formatTimeWindowRange(schedule.windowStart, schedule.windowEnd, locale)}`; - } - - const nextDate = getNextRecurrenceDate(schedule); - const nextText = nextDate - ? `${formatShortDate(nextDate)}, ${formatTimeWindowRange(schedule.windowStart, schedule.windowEnd, locale)}` - : "—"; - return `${t("medications.nextSchedulePrefix")} ${nextText}`; -} - +/** + * Internal form-level schedule shape. Carries the v1.5 picker state + * (cadence + sub-controls + timesOfDay) alongside the legacy mirror + * fields the route still persists during the v1.5.x dual-write + * window. `legacyOnLoad` flips true when the snapshot loaded from + * the API had no v1.5 fields populated — that's the cue to surface + * the legacy windowStart/windowEnd inputs as a defensive fallback. + */ interface Schedule { + /** Picker output — the schedule's canonical cadence value. */ + cadence: CadenceValue; + /** Sub-controls remembered for the picker so radio toggles don't + * drop the user's previous selection. */ + cadenceSub: CadenceSubControls; + /** Times of day the dose is taken (HH:mm, sorted, capped at 8). */ + timesOfDay: string[]; + /** Per-schedule label override (e.g. "Morning"). */ + label: string; + /** Per-schedule dose override (e.g. "5 mg"). */ + dose: string; + /** Legacy reminder window start (HH:mm). Dual-written for v1.5.x. */ + windowStart: string; + /** Legacy reminder window end (HH:mm). Dual-written for v1.5.x. */ + windowEnd: string; + /** True when the schedule loaded with no v1.5 fields → surface the + * legacy windowStart/windowEnd inputs as a fallback during the + * migration window. */ + legacyOnLoad: boolean; +} + +interface InitialSchedule { windowStart: string; windowEnd: string; label: string; dose: string; - daysOfWeek: number[]; - intervalWeeks: number; - showAdvanced: boolean; + daysOfWeek?: number[]; + intervalWeeks?: number; + /** v1.5 — first-class times-of-day. */ + timesOfDay?: string[]; + /** v1.5 — RFC 5545 RRULE string for calendar-anchored cadences. */ + rrule?: string | null; + /** v1.5 — flexible-rolling interval in days. */ + rollingIntervalDays?: number | null; + /** v1.5 — reminder grace window in minutes. */ + reminderGraceMinutes?: number | null; } interface MedicationFormProps { @@ -184,25 +171,32 @@ interface MedicationFormProps { dosesPerUnit?: number | null; active: boolean; notificationsEnabled?: boolean; - schedules: Array<{ - windowStart: string; - windowEnd: string; - label: string; - dose: string; - daysOfWeek?: number[]; - intervalWeeks?: number; - }>; + /** v1.5 — medication-level course start date. */ + startsOn?: Date | null; + /** v1.5 — medication-level course end date. */ + endsOn?: Date | null; + /** v1.5 — single-administration medication. */ + oneShot?: boolean; + schedules: Array; }; } +const DEFAULT_CADENCE: CadenceValue = { + kind: "daily", + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + oneShot: false, +}; + const DEFAULT_SCHEDULE: Schedule = { - windowStart: "08:00", - windowEnd: "09:00", + cadence: { ...DEFAULT_CADENCE }, + cadenceSub: { ...DEFAULT_SUB_CONTROLS }, + timesOfDay: ["08:00"], label: "", dose: "", - daysOfWeek: [], - intervalWeeks: 1, - showAdvanced: false, + windowStart: "08:00", + windowEnd: "09:00", + legacyOnLoad: false, }; /** Try to split "400mg" into ["400", "mg"] */ @@ -218,30 +212,88 @@ function parseTimeToMinutes(value: string): number | null { return Number(m[1]) * 60 + Number(m[2]); } -function sortSchedules(list: Schedule[]): Schedule[] { +function minutesToHHmm(total: number): string { + const normalised = ((total % (24 * 60)) + 24 * 60) % (24 * 60); + const h = String(Math.floor(normalised / 60)).padStart(2, "0"); + const m = String(normalised % 60).padStart(2, "0"); + return `${h}:${m}`; +} + +/** + * Derive a `(windowStart, windowEnd)` mirror from the picker chips so + * the route's legacy plumb still has values to persist during the + * v1.5.x dual-write window. Default 60-minute grace window matches + * the legacy default the historical form started new schedules with. + */ +function deriveLegacyWindow( + timesOfDay: string[], + fallbackStart: string, + fallbackEnd: string, +): { windowStart: string; windowEnd: string } { + if (timesOfDay.length === 0) { + return { windowStart: fallbackStart, windowEnd: fallbackEnd }; + } + const first = timesOfDay[0]; + const startMin = parseTimeToMinutes(first); + if (startMin === null) { + return { windowStart: fallbackStart, windowEnd: fallbackEnd }; + } + return { + windowStart: first, + windowEnd: minutesToHHmm(startMin + 60), + }; +} + +function sortSchedulesByFirstTime(list: Schedule[]): Schedule[] { return [...list] .map((schedule, index) => ({ schedule, index })) .sort((a, b) => { - const aStart = parseTimeToMinutes(a.schedule.windowStart); - const bStart = parseTimeToMinutes(b.schedule.windowStart); - - if (aStart === null && bStart === null) return a.index - b.index; - if (aStart === null) return 1; - if (bStart === null) return -1; - if (aStart !== bStart) return aStart - bStart; - - const aEnd = parseTimeToMinutes(a.schedule.windowEnd); - const bEnd = parseTimeToMinutes(b.schedule.windowEnd); - if (aEnd === null && bEnd === null) return a.index - b.index; - if (aEnd === null) return 1; - if (bEnd === null) return -1; - if (aEnd !== bEnd) return aEnd - bEnd; - + const aFirst = a.schedule.timesOfDay[0] ?? a.schedule.windowStart; + const bFirst = b.schedule.timesOfDay[0] ?? b.schedule.windowStart; + const aMin = parseTimeToMinutes(aFirst); + const bMin = parseTimeToMinutes(bFirst); + if (aMin === null && bMin === null) return a.index - b.index; + if (aMin === null) return 1; + if (bMin === null) return -1; + if (aMin !== bMin) return aMin - bMin; return a.index - b.index; }) .map((item) => item.schedule); } +function hydrateScheduleFromInitial(s: InitialSchedule): Schedule { + const legacy = { + daysOfWeek: s.daysOfWeek ?? [], + intervalWeeks: s.intervalWeeks ?? 1, + }; + const inferred = inferCadenceFromLegacy(legacy); + // The schedule is "legacy on load" when it has no v1.5 fields + // populated — timesOfDay empty AND no rrule AND no rollingIntervalDays. + // The migration backfills `timesOfDay = [windowStart]` for every + // pre-v1.5 row, so an explicit empty array means a row that hasn't + // been touched by the migration yet (defensive fallback). + const hasV15Fields = + (s.timesOfDay && s.timesOfDay.length > 0) || + !!s.rrule || + typeof s.rollingIntervalDays === "number"; + + const timesOfDay = + s.timesOfDay && s.timesOfDay.length > 0 + ? [...s.timesOfDay] + : [s.windowStart]; + + return { + cadence: inferred.value, + cadenceSub: inferred.subControls, + timesOfDay, + label: s.label ?? "", + dose: s.dose ?? "", + windowStart: s.windowStart, + windowEnd: s.windowEnd, + legacyOnLoad: !hasV15Fields, + }; +} + export function MedicationForm({ onSuccess, onCancel, @@ -250,8 +302,7 @@ export function MedicationForm({ initial, }: MedicationFormProps) { const queryClient = useQueryClient(); - const { t, locale } = useTranslations(); - const fmt = useFormatters(); + const { t } = useTranslations(); // v1.4.27 R4 RC2 — stable form id so the portalled Save button keeps // its `
` association via the HTML `form` attribute even when @@ -291,23 +342,18 @@ export function MedicationForm({ const [notificationsEnabled, setNotificationsEnabled] = useState( initial?.notificationsEnabled ?? true, ); + + // v1.5 — medication-level course window + one-shot flag. + const [startsOn, setStartsOn] = useState( + initial?.startsOn ?? null, + ); + const [endsOn, setEndsOn] = useState(initial?.endsOn ?? null); + const [oneShot, setOneShot] = useState(initial?.oneShot ?? false); + const [schedules, setSchedules] = useState( - sortSchedules( + sortSchedulesByFirstTime( initial?.schedules?.length - ? initial.schedules.map((s) => { - const recurrence = { - daysOfWeek: s.daysOfWeek ?? [], - intervalWeeks: s.intervalWeeks ?? 1, - }; - const hasCustomRecurrence = - recurrence.daysOfWeek.length > 0 || recurrence.intervalWeeks > 1; - return { - ...s, - daysOfWeek: recurrence.daysOfWeek, - intervalWeeks: recurrence.intervalWeeks, - showAdvanced: hasCustomRecurrence, - }; - }) + ? initial.schedules.map(hydrateScheduleFromInitial) : [{ ...DEFAULT_SCHEDULE }], ), ); @@ -330,39 +376,24 @@ export function MedicationForm({ ? `${doseAmount}${doseUnit ? ` ${doseUnit}` : ""}` : doseUnit; - function updateSchedule( - index: number, - field: keyof Schedule, - value: string | number[] | number | boolean, - ) { + function patchSchedule(index: number, patch: Partial) { setSchedules((prev) => - sortSchedules( - prev.map((s, i) => (i === index ? { ...s, [field]: value } : s)), - ), + prev.map((s, i) => (i === index ? { ...s, ...patch } : s)), ); } - function toggleDay(scheduleIndex: number, day: number) { + function patchScheduleAndResort(index: number, patch: Partial) { setSchedules((prev) => - prev.map((s, i) => { - if (i !== scheduleIndex) return s; - const days = s.daysOfWeek.includes(day) - ? s.daysOfWeek.filter((d) => d !== day) - : [...s.daysOfWeek, day].sort(); - return { ...s, daysOfWeek: days }; - }), + sortSchedulesByFirstTime( + prev.map((s, i) => (i === index ? { ...s, ...patch } : s)), + ), ); } function addSchedule() { - setSchedules((prev) => sortSchedules([...prev, { ...DEFAULT_SCHEDULE }])); - } - - function toggleAdvanced(scheduleIndex: number) { + if (oneShot) return; setSchedules((prev) => - prev.map((s, i) => - i === scheduleIndex ? { ...s, showAdvanced: !s.showAdvanced } : s, - ), + sortSchedulesByFirstTime([...prev, { ...DEFAULT_SCHEDULE }]), ); } @@ -381,43 +412,61 @@ export function MedicationForm({ setDosesPerUnit(""); setActive(true); setNotificationsEnabled(true); + setStartsOn(null); + setEndsOn(null); + setOneShot(false); setSchedules([{ ...DEFAULT_SCHEDULE }]); setError(null); } /** - * v1.4.25 W4d — "Once weekly on …" preset. Marc directive - * 2026-05-14: GLP-1 receptor agonists are weekly meds, and the - * existing form buries that behind two advanced toggles. The preset - * collapses the picker to a single weekday button row that writes - * MedicationSchedule.daysOfWeek correctly and remembers the choice - * locally so the next weekly med starts on the same weekday. - * - * Persisted to localStorage as a one-row preference so the convenience - * survives across sessions — non-invasive (key is namespaced) and - * web-only (the iOS surface uses its own picker). + * v1.4.25 W4d — "Once weekly on …" preset, restated against the + * v1.5 picker. One tap on a weekday writes the picker into the + * `weekdays` kind with the chosen day and revealss the cadence row + * via the picker's own conditional sub-controls. The preference + * still persists across sessions so the next GLP-1 medication + * starts on the same weekday. */ - function applyWeeklyPreset(weekday: number) { + function applyWeeklyPreset(weekdayIndex0Sun: number) { try { localStorage.setItem( "medication-form:last-weekly-weekday", - String(weekday), + String(weekdayIndex0Sun), ); } catch { /* private mode — silent */ } + // Convert 0-6 (Sun-anchored) to the BYDAY token order the picker + // expects. Day 0 (Sun) maps to "SU"; day 1 (Mon) maps to "MO"; + // and so on through day 6 (Sat) → "SA". + const tokenMap: Record< + number, + (typeof DEFAULT_SUB_CONTROLS)["weekdays"][number] + > = { + 0: "SU", + 1: "MO", + 2: "TU", + 3: "WE", + 4: "TH", + 5: "FR", + 6: "SA", + }; + const token = tokenMap[weekdayIndex0Sun]; + if (!token) return; setSchedules((prev) => - sortSchedules( - prev.map((s, i) => - i === 0 - ? { - ...s, - daysOfWeek: [weekday], - intervalWeeks: 1, - showAdvanced: true, - } - : s, - ), + prev.map((s, i) => + i === 0 + ? { + ...s, + cadence: { + kind: "weekdays", + rrule: `FREQ=WEEKLY;BYDAY=${token}`, + rollingIntervalDays: null, + oneShot: false, + }, + cadenceSub: { ...s.cadenceSub, weekdays: [token] }, + } + : s, ), ); } @@ -441,6 +490,44 @@ export function MedicationForm({ const method = isEdit ? "PUT" : "POST"; try { + const courseStartsOnIso = + startsOn !== null ? toIsoDateOnly(startsOn) : null; + const courseEndsOnIso = endsOn !== null ? toIsoDateOnly(endsOn) : null; + + const serialisedSchedules = sortSchedulesByFirstTime(schedules).map( + (s) => { + // Dual-write — derive the legacy mirror from the picker so + // the route's `serializeScheduleRecurrence` and the v1.5.x + // reader-fallback still see a consistent pair. + const legacyPair = legacyPairFromCadence(s.cadence, s.cadenceSub); + const window = deriveLegacyWindow( + s.timesOfDay, + s.windowStart, + s.windowEnd, + ); + return { + windowStart: window.windowStart, + windowEnd: window.windowEnd, + label: s.label || undefined, + dose: s.dose || undefined, + daysOfWeek: + legacyPair.daysOfWeek.length > 0 + ? legacyPair.daysOfWeek + : undefined, + intervalWeeks: legacyPair.intervalWeeks, + timesOfDay: + s.timesOfDay.length > 0 ? s.timesOfDay : undefined, + // v1.5 — pass-through the picker output. Daily / rolling / + // oneShot all map to the right route branches below; the + // PUT layer also normalises endsOn / mints a default rrule. + ...(s.cadence.rrule !== null && { rrule: s.cadence.rrule }), + ...(s.cadence.rollingIntervalDays !== null && { + rollingIntervalDays: s.cadence.rollingIntervalDays, + }), + }; + }, + ); + const res = await fetch(url, { method, headers: { "Content-Type": "application/json" }, @@ -463,14 +550,15 @@ export function MedicationForm({ }), } : {}), - schedules: sortSchedules(schedules).map((s) => ({ - windowStart: s.windowStart, - windowEnd: s.windowEnd, - label: s.label || undefined, - dose: s.dose || undefined, - daysOfWeek: s.daysOfWeek.length > 0 ? s.daysOfWeek : undefined, - intervalWeeks: s.intervalWeeks, - })), + // v1.5 — medication-level course window + one-shot flag. + // Only emit fields that diverged from the defaults so a + // legacy medication that the user opened and saved without + // touching the course window doesn't get a NULL stamped on + // its existing startsOn. + ...(startsOn !== null && { startsOn: courseStartsOnIso }), + ...(endsOn !== null && { endsOn: courseEndsOnIso }), + ...(oneShot && { oneShot: true }), + schedules: serialisedSchedules, }), }); @@ -670,11 +758,10 @@ export function MedicationForm({ {treatmentClass === "GLP1" && ( <> - {/* v1.4.25 W4d — once-weekly preset. One tap on a weekday - writes daysOfWeek=[N] + intervalWeeks=1 into the first - schedule and reveals the advanced section so the user can - still tune the window. Persists the last pick across - sessions for convenience. */} + {/* v1.4.25 W4d — once-weekly preset. Now writes through the + v1.5 cadence picker (kind = "weekdays" + single BYDAY + token) so the row stays consistent with whatever the + user has picked in the dedicated picker below. */}
{getDayLabelsShort(t).map((label, dayIndex) => { + const firstSchedule = schedules[0]; + const tokenForDay = ( + ["SU", "MO", "TU", "WE", "TH", "FR", "SA"] as const + )[dayIndex]; const isSelected = - schedules[0]?.daysOfWeek.length === 1 && - schedules[0]?.daysOfWeek[0] === dayIndex; + firstSchedule?.cadence.kind === "weekdays" && + firstSchedule.cadenceSub.weekdays.length === 1 && + firstSchedule.cadenceSub.weekdays[0] === tokenForDay; return ( + {!oneShot && ( + + )}
{schedules.map((s, i) => (

{s.label.trim() || `${t("medications.formSchedule")} ${i + 1}`} - {" "} - - ({formatNextWindowSummary(s, t, fmt.dateShort, locale)})

@@ -785,12 +938,6 @@ export function MedicationForm({ - toggleAdvanced(i)}> - - {s.showAdvanced - ? t("medications.advancedEditingHide") - : t("medications.advancedEditing")} - {schedules.length > 1 && (
-
-
- - - updateSchedule(i, "windowStart", e.target.value) - } - required - aria-required="true" - aria-invalid={!!error || undefined} - aria-describedby={errorDescriptor} - maxLength={5} - /> -
-
- - - updateSchedule(i, "windowEnd", e.target.value) - } - required - aria-required="true" - aria-invalid={!!error || undefined} - aria-describedby={errorDescriptor} - maxLength={5} - /> -
-
+
+
updateSchedule(i, "label", e.target.value)} + onChange={(e) => + patchSchedule(i, { label: e.target.value }) + } placeholder={t("medications.labelPlaceholder")} maxLength={50} enterKeyHint="next" @@ -865,14 +969,16 @@ export function MedicationForm({ autoComplete="off" />
-
+
updateSchedule(i, "dose", e.target.value)} + onChange={(e) => + patchSchedule(i, { dose: e.target.value }) + } placeholder={dose || t("medications.defaultDose")} maxLength={50} enterKeyHint="done" @@ -880,89 +986,110 @@ export function MedicationForm({ />
- {s.showAdvanced && ( -

- {formatRecurrenceSummary(s, t)} -

+ + {/* v1.5 — Cadence picker. Hidden when oneShot is on; the + course-window row above already carries the single + dose date in that case. */} + {!oneShot && ( +
+ + + patchSchedule(i, { + cadence: nextValue, + cadenceSub: nextSub, + }) + } + /> +
)} - {/* Day-of-week selection */} - {s.showAdvanced && ( -
-
- -
- {[1, 2, 3, 4].map((weeks) => ( - - ))} -
-
+ {/* v1.5 — Times-of-day chips. Single time picker when + oneShot is on; multi-chip otherwise. */} +
+ + { + // Keep the legacy window mirror in sync so the + // route's dual-write still has values to persist. + const win = deriveLegacyWindow( + next, + s.windowStart, + s.windowEnd, + ); + patchScheduleAndResort(i, { + timesOfDay: next, + windowStart: win.windowStart, + windowEnd: win.windowEnd, + }); + }} + /> +
-
- - {/* v1.4.27 MB6 CF-4 — the previous single-row - `flex w-full gap-1` layout placed the wide - "Daily" pill (min-w-24 ≈ 96 px) next to seven - weekday pills inside the same row. At 320 px - viewport widths the eighth child pushed the row - past the form edge and the last weekday clipped - off-screen. The new layout stacks the Daily - pill above a seven-column grid so each weekday - keeps the 44 px tap-target floor without - relying on the available width. The grid wraps - naturally at any container width because every - cell is one explicit grid track. */} - -
- {getDayLabelsShort(t).map((label, dayIndex) => { - const isSelected = s.daysOfWeek.includes(dayIndex); - return ( - - ); - })} + {/* Legacy reminder-window fallback — surfaced only for + schedules loaded with no v1.5 fields populated. The + migration backfills `timesOfDay = [windowStart]` so + this section disappears for every row that has gone + through the v1.5 migration. */} + {s.legacyOnLoad && ( +
+ +

+ {t(`medications.scheduling.legacyWindow.help`)} +

+
+
+ + { + const nextStart = e.target.value; + // Keep timesOfDay in sync — the legacy field + // is the source-of-truth while the schedule + // is still in legacyOnLoad mode. + patchScheduleAndResort(i, { + windowStart: nextStart, + timesOfDay: addTime( + s.timesOfDay.length > 0 ? s.timesOfDay : [], + nextStart, + 8, + ), + }); + }} + maxLength={5} + /> +
+
+ + + patchSchedule(i, { windowEnd: e.target.value }) + } + maxLength={5} + />
@@ -1160,3 +1287,16 @@ export function MedicationForm({ ); } + +/** + * Format a Date as YYYY-MM-DD using its UTC components — matches the + * encoding the route layer parses via `z.iso.date()` and avoids the + * cross-timezone drift that `toISOString().slice(0, 10)` introduces + * for users east of UTC. + */ +function toIsoDateOnly(d: Date): string { + const y = d.getUTCFullYear(); + const m = String(d.getUTCMonth() + 1).padStart(2, "0"); + const day = String(d.getUTCDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} diff --git a/src/components/medications/scheduling/legacy-bridge.ts b/src/components/medications/scheduling/legacy-bridge.ts new file mode 100644 index 000000000..c6b2e8ba6 --- /dev/null +++ b/src/components/medications/scheduling/legacy-bridge.ts @@ -0,0 +1,203 @@ +/** + * v1.5.0 — legacy → v1.5 cadence bridge. + * + * Helpers that infer a `CadencePicker` initial value from the legacy + * `(daysOfWeek, intervalWeeks)` pair the pre-v1.5 form stored, and + * convert weekday indexes (0-6, Sunday-anchored) into the RFC 5545 + * BYDAY tokens the picker emits. Used by the edit-only path of + * `medication-form.tsx` so a pre-v1.5 medication opens with the + * correct picker selection and the form can dual-write both shapes + * during the v1.5.x migration window. + * + * Pure functions only — no React, no DOM — so the unit tests can pin + * the mapping table without spinning up a render tree. + */ +import { encodeCadence } from "./CadencePicker"; +import { + type CadenceSubControls, + type CadenceValue, + DEFAULT_SUB_CONTROLS, + type WeekdayToken, + WEEKDAY_TOKENS, +} from "./types"; + +/** + * Legacy schedule shape the page passes to the form. Sunday-anchored + * weekday indexes (0 = Sun, 1 = Mon, …, 6 = Sat) — that's the + * convention `parseScheduleRecurrence` produces and the form has + * historically stored. + */ +export interface LegacyScheduleSnapshot { + daysOfWeek: number[]; + intervalWeeks: number; +} + +/** + * Convert a 0-6 (Sunday-anchored) weekday index to the RFC 5545 + * BYDAY token. Returns null for out-of-range input so the caller can + * filter cleanly. + */ +export function weekdayIndexToToken(index: number): WeekdayToken | null { + // Sunday-anchored input → BYDAY uses Monday as the first day of the + // week (Mo Tu We Th Fr Sa Su). Map index 0 (Sun) to "SU" and 1..6 to + // MO..SA in order. + switch (index) { + case 0: + return "SU"; + case 1: + return "MO"; + case 2: + return "TU"; + case 3: + return "WE"; + case 4: + return "TH"; + case 5: + return "FR"; + case 6: + return "SA"; + default: + return null; + } +} + +/** + * Convert a list of legacy weekday indexes (0-6) to BYDAY tokens. + * Order follows the WEEKDAY_TOKENS canonical layout so the encoded + * RRULE matches the picker's own output for the same selection. + */ +export function weekdayIndexesToTokens(indexes: number[]): WeekdayToken[] { + const tokens = new Set(); + for (const idx of indexes) { + const tok = weekdayIndexToToken(idx); + if (tok) tokens.add(tok); + } + return WEEKDAY_TOKENS.filter((w) => tokens.has(w)); +} + +/** + * Infer a `CadenceValue` + matching sub-controls from a pre-v1.5 + * schedule. Mapping per design-synthesis section "Edit flow — flat + * form": + * + * daysOfWeek empty AND intervalWeeks === 1 → daily + * daysOfWeek non-empty AND intervalWeeks === 1 → weekdays + * daysOfWeek non-empty AND intervalWeeks > 1 → everyNWeeks + * anything else (defensive fallback) → daily + * + * `everyNWeeks` with an empty weekday list keeps the picker valid by + * defaulting to Monday (the same default the picker uses when its + * own sub-control is empty). The fallback branch covers pathological + * inputs (negative intervalWeeks, NaN, etc.) without throwing. + */ +export function inferCadenceFromLegacy( + schedule: LegacyScheduleSnapshot, +): { value: CadenceValue; subControls: CadenceSubControls } { + const tokens = weekdayIndexesToTokens(schedule.daysOfWeek ?? []); + const interval = Number.isFinite(schedule.intervalWeeks) + ? Math.trunc(schedule.intervalWeeks) + : 1; + + // Daily — no weekday restriction, interval == 1. + if (tokens.length === 0 && interval === 1) { + const sub: CadenceSubControls = { ...DEFAULT_SUB_CONTROLS }; + return { value: encodeCadence("daily", sub), subControls: sub }; + } + + // Weekdays — explicit weekday list, interval == 1. + if (tokens.length > 0 && interval === 1) { + const sub: CadenceSubControls = { + ...DEFAULT_SUB_CONTROLS, + weekdays: tokens, + }; + return { value: encodeCadence("weekdays", sub), subControls: sub }; + } + + // EveryNWeeks — explicit weekday list, interval > 1. + if (tokens.length > 0 && interval > 1) { + const sub: CadenceSubControls = { + ...DEFAULT_SUB_CONTROLS, + weekdays: tokens, + intervalWeeks: interval, + }; + return { value: encodeCadence("everyNWeeks", sub), subControls: sub }; + } + + // Safe fallback — surface the schedule as daily so the user can + // pick a real cadence before saving. + const sub: CadenceSubControls = { ...DEFAULT_SUB_CONTROLS }; + return { value: encodeCadence("daily", sub), subControls: sub }; +} + +/** + * Convert the picker's BYDAY tokens back to the legacy 0-6 weekday + * indexes (Sunday-anchored) the form sends as the `daysOfWeek` field + * on the schedule body. Used during the v1.5.x dual-write window so + * the route can still serialise the legacy `daysOfWeek` string column. + */ +export function tokensToWeekdayIndexes(tokens: WeekdayToken[]): number[] { + const out: number[] = []; + for (const tok of tokens) { + switch (tok) { + case "SU": + out.push(0); + break; + case "MO": + out.push(1); + break; + case "TU": + out.push(2); + break; + case "WE": + out.push(3); + break; + case "TH": + out.push(4); + break; + case "FR": + out.push(5); + break; + case "SA": + out.push(6); + break; + } + } + return out.sort((a, b) => a - b); +} + +/** + * v1.5.x dual-write helper — derives the legacy `(daysOfWeek, + * intervalWeeks)` pair the route still persists from a `CadenceValue`. + * Returns the safe defaults for any non-calendar cadence so the + * legacy column accepts the row even when the user picked a rolling + * or one-shot schedule. + */ +export function legacyPairFromCadence( + value: CadenceValue, + subControls: CadenceSubControls, +): { daysOfWeek: number[]; intervalWeeks: number } { + switch (value.kind) { + case "weekdays": + return { + daysOfWeek: tokensToWeekdayIndexes(subControls.weekdays), + intervalWeeks: 1, + }; + case "everyNWeeks": { + // The legacy `intervalWeeks` column caps at 4 — anything higher + // is preserved on the new `rrule` field; the legacy mirror is + // clamped so the route's `serializeScheduleRecurrence` accepts + // the value without dropping the row. + const clamped = Math.min(4, Math.max(1, subControls.intervalWeeks)); + return { + daysOfWeek: tokensToWeekdayIndexes(subControls.weekdays), + intervalWeeks: clamped, + }; + } + default: + // daily / monthly / everyNMonths / yearly / rolling / oneShot — + // none map cleanly to the legacy pair; emit "every day" as the + // most permissive fallback so the legacy reader never silently + // filters the row out. + return { daysOfWeek: [], intervalWeeks: 1 }; + } +}