mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
a87a48a7c3
commit
bb1a587f8b
4 files changed
+962
-340
No files matched your search
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user