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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 09:19:46 +02:00
1 parent a87a48a7c3
commit bb1a587f8b
4 files changed
+962 -340

No files matched your search

+33
View File
@@ -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
@@ -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,
});
});
});
File diff suppressed because it is too large. Load diff
@@ -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<WeekdayToken>();
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 };
}
}