expose the open dose's per-dose status on the compliance read

Derive a single DoseStatus for the open cycle in buildComplianceDisplay
and surface it as complianceDisplay.currentDose, so the medication card
renders its take-window / overdue presentation from one server authority
instead of re-spelling the window math client-side. Add the field to the
OpenAPI ComplianceDisplay schema and regenerate the spec.
This commit is contained in:
Marc-André Bombeck committed 2026-06-07 13:38:24 +02:00
1 parent 7bbb473070
commit 939a732aa6
4 files changed
+161

No files matched your search

+30
View File
@@ -8550,6 +8550,35 @@ components:
description: v1.13.x — the current (open) dose cycle, surfaced so a between-doses sparse med renders a neutral 'next
dose in N days' line instead of a scary red 0%. The percentage rows above already exclude the open forward
cycle from their denominator.
currentDose:
type: object
properties:
status:
type: string
enum:
- upcoming
- on_time_window
- overdue
- missed
- taken_on_time
- taken_late
- skipped
description: Per-dose state of the open cycle, server-derived from the window model so the card renders the take-window
(green) / overdue / heavily-overdue escalation from one authority. `upcoming` when no dose is open (PRN
/ paused / ended).
targetAt:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
description: Target instant of the open dose. Null when no dose is open.
required:
- status
- targetAt
additionalProperties: false
description: v1.15.9 — the open dose's per-dose status + target, so the card highlights the actionable take-window green
and escalates an overdue dose without re-deriving the window math client-side.
required:
- shortDays
- longDays
@@ -8559,6 +8588,7 @@ components:
- short
- long
- currentCycle
- currentDose
additionalProperties: false
description: The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds
step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that.
@@ -1604,6 +1604,69 @@ describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => {
);
});
it("currentDose — rolling weekly, last shot 3 days ago → upcoming (card stays calm)", () => {
// v1.15.9 — the open dose's per-dose status drives the card's green /
// overdue presentation. A weekly shot due in 4 days is well before the
// ±1-day on-time window opens → `upcoming`, with the target echoed.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
const lastShot = new Date(NOW.getTime() - 3 * DAY_MS);
const events = [
{ scheduledFor: lastShot, takenAt: lastShot, skipped: false },
];
const display = buildComplianceDisplay(
events,
schedules,
ctx({ startsOn: lastShot, lastIntakeAt: lastShot }),
{ now: NOW },
);
expect(display.currentDose.status).toBe("upcoming");
expect(display.currentDose.targetAt).not.toBeNull();
expect(display.currentDose.targetAt!.getTime()).toBe(
display.currentCycle.nextDueAt!.getTime(),
);
});
it("currentDose — weekly shot due now is on_time_window (green); long overdue is missed", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
// Last shot exactly 7 days ago → next due ~now → inside the ±1-day
// on-time window → green.
const dueNow = new Date(NOW.getTime() - 7 * DAY_MS);
const dueNowDisplay = buildComplianceDisplay(
[{ scheduledFor: dueNow, takenAt: dueNow, skipped: false }],
schedules,
ctx({ startsOn: dueNow, lastIntakeAt: dueNow }),
{ now: NOW },
);
expect(dueNowDisplay.currentDose.status).toBe("on_time_window");
// Last shot 14 days ago → due ~7 days ago → past the 4-day overdue tail
// → missed (the card escalates to "Stark überfällig").
const longAgo = new Date(NOW.getTime() - 14 * DAY_MS);
const missedDisplay = buildComplianceDisplay(
[{ scheduledFor: longAgo, takenAt: longAgo, skipped: false }],
schedules,
ctx({ startsOn: longAgo, lastIntakeAt: longAgo }),
{ now: NOW },
);
expect(missedDisplay.currentDose.status).toBe("missed");
});
it("currentCycle — brand-new sparse med with zero closed cycles → hasClosedCycles false", () => {
// v1.13.x Fix 4 — a med created today with no logged intake has no
// closed dose cycle; the percentage rows are vacuous and the card should
+45
View File
@@ -820,6 +820,15 @@ export interface ComplianceDisplay {
* rows so a between-doses sparse med never renders a scary red number.
*/
currentCycle: CurrentCycle;
/**
* v1.15.9 — the open cycle's per-dose {@link DoseStatus}, server-derived
* so the card renders its state (green take-window, overdue / heavily-
* overdue escalation) from one authority instead of re-deriving the window
* math client-side. `status` is `upcoming` when no dose is open yet and
* there is no projected next dose (PRN / paused / ended). `targetAt` is the
* open dose's target instant (echo of `currentCycle.nextDueAt`).
*/
currentDose: { status: DoseStatus; targetAt: Date | null };
}
/**
@@ -954,6 +963,24 @@ export function buildComplianceDisplay(
events,
);
// v1.15.9 — derive the open dose's per-dose status from one server
// authority so the card renders the take-window (green) / overdue /
// heavily-overdue escalation without re-spelling the window math. The
// cadence family comes from the SOONEST non-PRN schedule (the one the
// open cycle anchors on). `none` cycles (PRN / paused / ended) carry an
// `upcoming` status with a null target so the card stays calm.
const currentDose: { status: DoseStatus; targetAt: Date | null } =
currentCycle.nextDueAt
? {
status: deriveDoseStatus(
currentCycle.nextDueAt,
soonestCadenceFamily(schedules),
now,
),
targetAt: currentCycle.nextDueAt,
}
: { status: "upcoming", targetAt: null };
return {
shortDays,
longDays,
@@ -977,9 +1004,27 @@ export function buildComplianceDisplay(
missed: long.missed,
},
currentCycle,
currentDose,
};
}
/**
* v1.15.9 — the {@link DoseCadenceFamily} of the soonest non-PRN schedule,
* used to pick the window model for the open dose's status. A multi-schedule
* med whose cycle anchors on its earliest window keeps that window's cadence;
* when every schedule is PRN (no projected dose) the family is irrelevant
* because the caller only reads `currentDose` when a cycle is open.
*/
function soonestCadenceFamily(
schedules: ComplianceSchedule[],
): DoseCadenceFamily {
for (const s of schedules) {
if (s.scheduleType === "PRN") continue;
return doseCadenceFamily(s);
}
return "daily";
}
/**
* Calculate compliance for a medication over a given period.
*
+23
View File
@@ -1332,6 +1332,29 @@ const complianceDisplay = z
.describe(
"v1.13.x — the current (open) dose cycle, surfaced so a between-doses sparse med renders a neutral 'next dose in N days' line instead of a scary red 0%. The percentage rows above already exclude the open forward cycle from their denominator.",
),
currentDose: z
.object({
status: z
.enum([
"upcoming",
"on_time_window",
"overdue",
"missed",
"taken_on_time",
"taken_late",
"skipped",
])
.describe(
"Per-dose state of the open cycle, server-derived from the window model so the card renders the take-window (green) / overdue / heavily-overdue escalation from one authority. `upcoming` when no dose is open (PRN / paused / ended).",
),
targetAt: z.iso
.datetime({ offset: true })
.nullable()
.describe("Target instant of the open dose. Null when no dose is open."),
})
.describe(
"v1.15.9 — the open dose's per-dose status + target, so the card highlights the actionable take-window green and escalates an overdue dose without re-deriving the window math client-side.",
),
})
.meta({
id: "ComplianceDisplay",