refactor(medications): share the wizard cadence-line builder

`summariseCadence` and `summariseScheduleDraft` each re-spelled the
cadence phrase, its detail clause, and the interpolation count. Extract
a single `{mode, cadence, subControls}`-shaped helper both call so the
medication-wide and per-schedule summaries can't drift apart, and drop
the now-redundant `summaryKeyForCadence` wrapper. Behaviour is
identical; add coverage pinning the per-schedule summary to the
medication-wide summary's leading cadence segment.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 23:43:39 +02:00
1 parent 4038cfcffe
commit 2c91dc7b75
2 files changed
+98 -54

No files matched your search

@@ -14,6 +14,7 @@ import {
rowFromTreatment,
setActiveSchedule,
summariseCadence,
summariseScheduleDraft,
validateStep,
type WizardPayload,
WIZARD_TREATMENT_MAPPING,
@@ -559,6 +560,49 @@ describe("summariseCadence", () => {
});
});
describe("summariseScheduleDraft — shared cadence-line helper", () => {
const t = makeStubT();
// The per-schedule summary and the medication-wide summary build their
// cadence segment from the same extracted helper, so for an equivalent
// cadence shape the draft summary equals the leading segment of the
// full summary (which trails the times / course-window phrases).
it("matches summariseCadence's leading cadence segment", () => {
const cases: WizardPayload[] = [
withCadence("daily"),
withCadence("everyNWeeks", { intervalWeeks: 2, weekdays: ["MO"] }),
withCadence("rolling", { rollingDays: 14 }),
withCadence("weekdays", { weekdays: ["MO", "WE", "FR"] }),
withCadence("monthly", { dayOfMonth: 15 }),
];
for (const base of cases) {
// `withCadence` only sets the flat mirror fields; commit them onto
// the active schedule draft so the draft carries the same cadence.
const p = commitActiveDraft(base);
const draft = p.schedules[p.activeScheduleIndex];
const draftLine = summariseScheduleDraft(draft, t);
const fullSummary = summariseCadence(p, t);
// The draft line's cadence segment is the leading " · " segment of
// the full summary (which trails times / course-window phrases).
const firstSegment = fullSummary.split(" · ")[0];
expect(draftLine.split(" · ")[0]).toBe(firstSegment);
}
});
it("threads the everyNWeeks interval into the cadence key", () => {
const p = commitActiveDraft(withCadence("everyNWeeks", { intervalWeeks: 3 }));
const out = summariseScheduleDraft(p.schedules[p.activeScheduleIndex], t);
expect(out).toMatch(/cadence\.everyNWeeks[^|]*"n":3/);
});
it("omits the cadence detail for a one-shot draft", () => {
const p = commitActiveDraft(withCadence("oneShot"));
const out = summariseScheduleDraft(p.schedules[p.activeScheduleIndex], t);
expect(out).not.toContain("weekdaysDetail");
expect(out).not.toContain("dayOfMonthDetail");
});
});
describe("hydrateWizardPayload", () => {
function makeInitial(
overrides: Partial<MedicationPayload> = {},
@@ -712,10 +712,6 @@ function summaryKeyForDraft(
}
}
function summaryKeyForCadence(payload: WizardPayload): string {
return summaryKeyForDraft(payload.mode, payload.cadence, payload.subControls);
}
function weekdaysDetail(
tokens: readonly string[],
t: (key: string, params?: Record<string, string | number>) => string,
@@ -732,25 +728,71 @@ function weekdaysDetail(
});
}
/**
* The cadence-bearing slice both {@link WizardPayload} and
* {@link ScheduleDraft} share. Summary helpers only ever read these three
* fields, so the cadence-line builder below stays draft/payload-agnostic.
*/
type CadenceShape = Pick<ScheduleDraft, "mode" | "cadence" | "subControls">;
/**
* The interpolation count the cadence summary keys read. `everyNWeeks`
* surfaces the week interval; `rolling` the day interval; everything else
* leaves it at zero. Extracted so the medication-wide and per-schedule
* summaries can't drift apart.
*/
function cadenceInterpolationN(shape: CadenceShape): number {
if (shape.cadence.kind === "everyNWeeks") return shape.subControls.intervalWeeks;
if (shape.cadence.kind === "rolling")
return shape.cadence.rollingIntervalDays ?? 0;
return 0;
}
/**
* The trailing detail clause appended to a cadence phrase (the weekday
* list or the day-of-month). Empty for one-shot doses and for cadences
* that carry no extra detail.
*/
function cadenceDetailPhrase(
payload: WizardPayload,
shape: CadenceShape,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
if (payload.mode === "oneShot") return "";
switch (payload.cadence.kind) {
if (shape.mode === "oneShot") return "";
switch (shape.cadence.kind) {
case "weekdays":
return weekdaysDetail(payload.subControls.weekdays, t);
return weekdaysDetail(shape.subControls.weekdays, t);
case "everyNWeeks":
return weekdaysDetail(payload.subControls.weekdays, t);
return weekdaysDetail(shape.subControls.weekdays, t);
case "monthly":
return t("medications.wizard.summary.dayOfMonthDetail", {
day: payload.subControls.dayOfMonth,
day: shape.subControls.dayOfMonth,
});
default:
return "";
}
}
/**
* The full cadence line — phrase (`every N weeks`, `daily`, …) plus its
* detail clause. Both {@link summariseCadence} and
* {@link summariseScheduleDraft} build their first segment from this so
* the two summaries render byte-identical cadence text.
*/
function cadenceLineFor(
shape: CadenceShape,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
const cadenceKey = summaryKeyForDraft(
shape.mode,
shape.cadence,
shape.subControls,
);
const cadencePhrase = t(`medications.wizard.summary.cadence.${cadenceKey}`, {
n: cadenceInterpolationN(shape),
});
return cadencePhrase + cadenceDetailPhrase(shape, t);
}
/**
* Plain-language summary for Step 8. Mirrors v1.5.3's helper shape so
* the test surface stays familiar. The `t` callback is passed in so
@@ -760,20 +802,7 @@ export function summariseCadence(
payload: WizardPayload,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
const cadenceKey = summaryKeyForCadence(payload);
const cadencePhrase = t(
`medications.wizard.summary.cadence.${cadenceKey}`,
{
n:
payload.cadence.kind === "everyNWeeks"
? payload.subControls.intervalWeeks
: payload.cadence.kind === "rolling"
? (payload.cadence.rollingIntervalDays ?? 0)
: 0,
},
);
const cadenceDetail = cadenceDetailPhrase(payload, t);
const cadenceLine = cadencePhrase + cadenceDetail;
const cadenceLine = cadenceLineFor(payload, t);
const timesPhrase =
payload.mode !== "oneShot" && payload.timesOfDay.length > 0
? t("medications.wizard.summary.times", {
@@ -807,36 +836,7 @@ export function summariseScheduleDraft(
draft: ScheduleDraft,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
const cadenceKey = summaryKeyForDraft(
draft.mode,
draft.cadence,
draft.subControls,
);
const cadencePhrase = t(
`medications.wizard.summary.cadence.${cadenceKey}`,
{
n:
draft.cadence.kind === "everyNWeeks"
? draft.subControls.intervalWeeks
: draft.cadence.kind === "rolling"
? (draft.cadence.rollingIntervalDays ?? 0)
: 0,
},
);
let cadenceDetail = "";
if (draft.mode !== "oneShot") {
if (
draft.cadence.kind === "weekdays" ||
draft.cadence.kind === "everyNWeeks"
) {
cadenceDetail = weekdaysDetail(draft.subControls.weekdays, t);
} else if (draft.cadence.kind === "monthly") {
cadenceDetail = t("medications.wizard.summary.dayOfMonthDetail", {
day: draft.subControls.dayOfMonth,
});
}
}
const cadenceLine = cadencePhrase + cadenceDetail;
const cadenceLine = cadenceLineFor(draft, t);
const timesPhrase =
draft.mode !== "oneShot" && draft.timesOfDay.length > 0
? t("medications.wizard.summary.times", {