diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6307da425..cd3a063a4 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts index 444627038..69f89ca7d 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -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 diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index 92ca8f95a..96ceaeaf8 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -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. * diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index f6e44b31a..328e2630c 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -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",