Merge pull request #286 from MBombeck/release/v1.15.9

release/v1.15.9 — medication compliance and cards
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-07 13:50:40 +02:00
commit 67d0f8f238
20 files changed
+1535 -630

No files matched your search

+15
View File
@@ -2,6 +2,21 @@
## [Unreleased]
## [1.15.9] — 2026-06-07 — medication compliance and cards
### Fixed
- **Compliance now counts a forgotten dose as missed.** A scheduled dose that was never taken or skipped used to quietly drop out of the calculation after a day, which made adherence read higher than it was. It now counts as missed, so your 7 / 30 / 90 / 365-day figures reflect what actually happened — and genuinely differ from one another instead of all looking the same.
- **The dashboard medication chart is computed against your schedule.** It previously measured taken doses against the doses you had logged (not the doses you were due), which pushed every window toward 100%. It now measures against the schedule.
- **Consistent skip handling.** A dose you deliberately skip is treated the same everywhere (a deliberate pause, not a miss); a dose simply forgotten counts as missed.
### Changed
- **The two medication cards are now truly identical.** Both styles render from one shared card body, so structure, spacing, labels, and the streak row line up exactly — only the value content differs (a weekly injectable keeps its relative-day phrasing, a daily dose shows a time). The bottom-spacing mismatch between cards is gone.
- **Cards highlight the dose that's due.** When a dose is in its take-now window the card is highlighted; once it is past due the card shows an overdue note, escalating to "Stark überfällig" near the cutoff. After the cutoff the dose counts as missed and the next dose becomes the focus.
- **Dose windows are cadence-aware.** A daily dose has a short on-time window then an overdue grace before it counts as missed; a weekly injectable follows a wider, clinically-appropriate window.
- **Tidier card.** The compliance bars show the percentage on its own, and the injection-site line was removed from the card (site tracking is unchanged).
## [1.15.8.1] — 2026-06-07 — large Apple Health imports
### Fixed
+57 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.15.8.1
version: 1.15.9
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -8460,6 +8460,18 @@ components:
type: integer
minimum: 0
maximum: 9007199254740991
expected:
type: integer
minimum: 0
maximum: 9007199254740991
description: v1.15.9 rate denominator over the short window (`taken + missed`); user skips are excluded. Render `taken /
expected · rate%`.
missed:
type: integer
minimum: 0
maximum: 9007199254740991
description: v1.15.9 doses counted against the rate over the short window — includes forgotten doses the auto-miss cron
flipped.
streak:
type: integer
minimum: 0
@@ -8467,6 +8479,8 @@ components:
required:
- rate
- taken
- expected
- missed
- streak
additionalProperties: false
long:
@@ -8480,9 +8494,21 @@ components:
type: integer
minimum: 0
maximum: 9007199254740991
expected:
type: integer
minimum: 0
maximum: 9007199254740991
description: v1.15.9 rate denominator over the long window (`taken + missed`).
missed:
type: integer
minimum: 0
maximum: 9007199254740991
description: v1.15.9 doses counted against the rate over the long window.
required:
- rate
- taken
- expected
- missed
additionalProperties: false
currentCycle:
type: object
@@ -8524,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
@@ -8533,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.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.15.8.1",
"version": "1.15.9",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
@@ -0,0 +1,13 @@
-- v1.15.9 — terminal "auto-missed" state for forgotten medication doses.
--
-- The hourly auto-miss cron previously flipped a never-acted pending dose to
-- `skipped = true`. Because the compliance engine excludes skipped doses from
-- the denominator (a deliberate user pause), a forgotten dose then neither
-- counted as taken nor as missed — silently inflating adherence.
--
-- This column lets the engine tell the two apart: a user-initiated skip stays
-- `skipped = true` (excluded), while a forgotten dose past its miss cutoff is
-- marked `auto_missed = true` and counts as a MISS against the rate. Every
-- legacy row defaults to false.
ALTER TABLE "medication_intake_events"
ADD COLUMN "auto_missed" BOOLEAN NOT NULL DEFAULT false;
+8
View File
@@ -1443,6 +1443,14 @@ model MedicationIntakeEvent {
scheduledFor DateTime @map("scheduled_for")
takenAt DateTime? @map("taken_at")
skipped Boolean @default(false)
/// v1.15.9 — distinguishes a never-acted forgotten dose from a
/// deliberate user skip. The hourly auto-miss cron sets this (NOT
/// `skipped`) on a pending row past its miss cutoff so the compliance
/// engine counts it as a MISS in the denominator, while a user-initiated
/// `skipped = true` stays excluded (a deliberate drug holiday). Every
/// legacy row defaults to false (a user skip or a live pending row,
/// neither of which is an auto-miss).
autoMissed Boolean @default(false) @map("auto_missed")
source IntakeSource @default(WEB)
idempotencyKey String? @unique @map("idempotency_key")
createdAt DateTime @default(now()) @map("created_at")
@@ -113,6 +113,9 @@ export const GET = apiHandler(
takenAt: e.takenAt,
skipped: e.skipped,
scheduledFor: e.scheduledFor,
// v1.15.9 — a forgotten dose the auto-miss cron flipped counts as a
// miss, not a neutral skip.
autoMissed: e.autoMissed,
}));
const createdAt = medication.createdAt;
@@ -131,7 +134,14 @@ export const GET = apiHandler(
userTz,
);
// v1.15.9 — pin a single `now` and thread it into every cadence
// computation. The two `calculateCompliance` calls + the display windows
// can otherwise straddle a day boundary on a slow request, so a dose
// would count in one window and not the next within the same response.
const now = new Date();
const compliance7 = calculateCompliance(mapped, medication.schedules, 7, createdAt, {
now,
medicationContext,
});
const compliance30 = calculateCompliance(
@@ -139,7 +149,7 @@ export const GET = apiHandler(
medication.schedules,
30,
createdAt,
{ medicationContext },
{ now, medicationContext },
);
// v1.8.6 — the two-row compliance display. The card always shows two
@@ -152,10 +162,10 @@ export const GET = apiHandler(
mapped,
medication.schedules,
medicationContext,
{ now },
);
// Build daily compliance map for heatmap/line chart (90 days)
const now = new Date();
const dailyCompliance: Record<string, DailyComplianceEntry> = {};
for (let d = 0; d < 90; d++) {
@@ -369,43 +369,59 @@ describe("POST /api/medications/intake", () => {
});
});
describe("v1.4.39 W-MED — compliance rollup read swap", () => {
it("reads the rollup tier when coverage is present", async () => {
describe("v1.15.9 — schedule-anchored compliance buckets (BUG #1)", () => {
// The dashboard tile's `scheduled` is now the canonical recurrence
// engine's expected-dose count per day (not the count of logged intake
// rows). With partial adherence the per-day rate `taken / scheduled`
// genuinely reflects taken-of-expected — it is NOT pinned at ~100%.
function activeDailyMed(createdAt: Date) {
return {
id: "m1",
userId: "user-1",
active: true,
startsOn: null,
endsOn: null,
oneShot: false,
createdAt,
schedules: [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
timesOfDay: ["08:00"],
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
],
};
}
it("anchors `scheduled` to the schedule — a day with a dose due but none taken reads < 100%", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// QA F-H-01 (v1.4.39): coverage probe returns
// `{ rolled_days >= event_days }` so the route lands on the
// rollup tier. Match the trailing-7-day window.
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ rolled_days: BigInt(7), event_days: BigInt(7) },
const createdAt = new Date(Date.now() - 60 * 86_400_000);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
activeDailyMed(createdAt),
] as never);
// v1.4.39.1 — anchor the seed row on the runtime's "today" so the
// test stays green across the calendar regardless of when it
// runs. The pre-fix shape hard-coded `2026-05-18`, which fell out
// of the trailing-7-day window on every subsequent wall-clock
// day and silently shifted the body.data tail off the seed.
//
// v1.4.49.4 — compute todayKey in the SAME zone the route uses
// (`readMedicationCompliance` → `userDayKey(now, safeTz)`), not in
// UTC. SESSION_OK carries no `timezone`, so the route falls
// through to `DEFAULT_TIMEZONE = "Europe/Berlin"`. The pre-fix
// UTC computation diverged from the route's Berlin computation by
// one calendar day during the 22:00-24:00 UTC window every night,
// causing the test to fail deterministically once the clock
// crossed midnight Berlin while still on the previous UTC day.
const todayKey = (() => {
const now = new Date();
const fmt = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
// `en-CA` short-date format is `YYYY-MM-DD`, matching the
// route's `userDayKey` output (see `src/lib/tz/resolver.ts`).
return fmt.format(now);
})();
vi.mocked(prisma.medicationComplianceRollup.findMany).mockResolvedValue([
{ day: todayKey, scheduled: 3, taken: 2, skipped: 1 },
// Only ONE taken dose across the whole 7-day window. A logged-row
// denominator would read 1 scheduled / 1 taken = 100% on that one day
// and nothing elsewhere; the engine instead expects ~7 doses (one per
// day) and counts the six un-taken days as missed → an aggregate rate
// well below 100%.
const yesterday8 = new Date();
yesterday8.setUTCDate(yesterday8.getUTCDate() - 1);
yesterday8.setUTCHours(8, 30, 0, 0);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
medicationId: "m1",
scheduledFor: yesterday8,
takenAt: yesterday8,
skipped: false,
autoMissed: false,
},
] as never);
const res = await GET(
@@ -418,35 +434,53 @@ describe("v1.4.39 W-MED — compliance rollup read swap", () => {
data: Array<{ date: string; scheduled: number; taken: number }>;
};
expect(body.data).toHaveLength(7);
const today = body.data[body.data.length - 1];
expect(today.scheduled).toBe(3);
expect(today.taken).toBe(2);
// The legacy live aggregator must not have run when coverage is hot.
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
// Engine expects a dose on (nearly) every day → total scheduled ≫ the
// single logged row, and total taken is just the one.
const totalScheduled = body.data.reduce((s, d) => s + d.scheduled, 0);
const totalTaken = body.data.reduce((s, d) => s + d.taken, 0);
expect(totalScheduled).toBeGreaterThanOrEqual(5);
expect(totalTaken).toBe(1);
// Aggregate rate is far from the degenerate ~100% the logged-row
// denominator produced.
expect(totalTaken / totalScheduled).toBeLessThan(0.5);
});
it("falls back to the live aggregator on coverage miss", async () => {
it("a fully-adherent daily med reads ~100% on every dosed day", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// QA F-H-01 (v1.4.39): partial coverage — events present but
// rollups missing — forces fall-through to the live aggregator.
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ rolled_days: BigInt(0), event_days: BigInt(7) },
const createdAt = new Date(Date.now() - 60 * 86_400_000);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
activeDailyMed(createdAt),
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
scheduledFor: new Date(),
takenAt: new Date(),
// One taken dose per day across the window.
const events = [];
for (let d = 0; d < 8; d++) {
const at = new Date();
at.setUTCDate(at.getUTCDate() - d);
at.setUTCHours(8, 10, 0, 0);
events.push({
medicationId: "m1",
scheduledFor: at,
takenAt: at,
skipped: false,
},
] as never);
autoMissed: false,
});
}
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
events as never,
);
const res = await GET(
new NextRequest(
"http://localhost/api/medications/intake?scope=compliance&days=7",
),
);
expect(res.status).toBe(200);
expect(prisma.medicationIntakeEvent.findMany).toHaveBeenCalled();
const body = (await res.json()) as {
data: Array<{ date: string; scheduled: number; taken: number }>;
};
// Every dosed day has taken === scheduled → 100% throughout.
for (const day of body.data) {
if (day.scheduled > 0) expect(day.taken).toBe(day.scheduled);
}
});
});
+138 -81
View File
@@ -28,13 +28,13 @@ import { auditLog } from "@/lib/auth/audit";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import {
readMedicationCompliance,
hasMedicationComplianceCoverage,
recomputeMedicationComplianceForEvent,
enqueueUserMedicationComplianceBackfill,
type ComplianceBucket as RollupComplianceBucket,
} from "@/lib/rollups/medication-compliance-rollups";
buildComplianceMedicationContext,
expectedSlotCountForDay,
lastNonSkippedTakenAt,
} from "@/lib/analytics/compliance";
import { getUserTodayBounds } from "@/lib/timezone";
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
import {
@@ -167,10 +167,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
? "skipped"
: e.takenAt
? "taken"
: e.medication.snoozedUntil &&
e.medication.snoozedUntil > new Date()
? "snoozed"
: "pending",
: // v1.15.9 — a never-acted dose the auto-miss cron flipped is a
// terminal MISS, not a perpetual "pending". (Today's freshly
// projected rows are < 24 h old so this rarely fires here, but
// a stale row surfacing in the window reads honestly.)
e.autoMissed
? "missed"
: e.medication.snoozedUntil &&
e.medication.snoozedUntil > new Date()
? "snoozed"
: "pending",
snoozedUntil: e.medication.snoozedUntil?.toISOString() ?? null,
})),
);
@@ -182,15 +188,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
// move). Cache key carries the userTz so a user who changes timezone
// doesn't read another tz's bucketing.
//
// v1.4.39 W-MED — read path now consumes the persistent
// `medication_compliance_rollups` tier. A coverage probe falls back
// to the legacy live aggregator when the user has intake events but
// zero rollup rows; the fallback fires a boot-backfill enqueue in the
// background so the next request hits the rollup tier.
// v1.15.9 — `scheduled` is now the SCHEDULE-ANCHORED expected-dose count
// per day (the canonical recurrence engine), NOT the count of logged
// intake rows. The old rollup-backed path set `scheduled = COUNT(*)` of
// intake rows, so the dashboard tile's rate (`taken / scheduled`) was ~100%
// across every window regardless of real adherence — every logged row was
// both numerator and denominator. Anchoring `scheduled` to the schedule
// makes the rate genuinely reflect taken-of-expected and lets the 7/30/90
// windows diverge with partial adherence.
const result = await cached(
caches.medicationsIntake as ServerCache<ComplianceBucket[]>,
`${user.id}|compliance|${days}|${userTz}`,
() => readComplianceBucketsWithFallback(user.id, days, userTz),
() => buildScheduleAnchoredComplianceBuckets(user.id, days, userTz),
annotate,
);
@@ -202,50 +211,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
return apiSuccess(result);
});
/**
* v1.4.39 W-MED — read from the persistent rollup tier when covered,
* fall through to the legacy live aggregator on coverage miss. The
* fallback fires the boot backfill in the background so subsequent
* requests hit the rollup path; the current request still returns a
* correct response built from the raw events.
*/
async function readComplianceBucketsWithFallback(
userId: string,
days: number,
userTz: string,
): Promise<ComplianceBucket[]> {
const covered = await hasMedicationComplianceCoverage(userId, days, userTz);
if (covered) {
const buckets = await readMedicationCompliance(userId, days, userTz);
annotate({
meta: {
medication_compliance_path: "rollup",
medication_compliance_days: days,
},
});
return buckets satisfies RollupComplianceBucket[];
}
// Coverage miss — the legacy aggregator stays correct over an empty
// rollup window. Fire a user-scoped backfill in the background so
// the next request lands on the rollup tier; the current request
// still returns the live-derived buckets.
//
// QA F-SEC-M-01 (v1.4.39): pre-fix this fired the cluster-wide
// `enqueueBootTimeMedicationComplianceBackfill` on every coverage-
// miss request, opening a soft-DoS amplifier (every authenticated
// user could drive a multi-tenant `LEFT JOIN` scan on each hit).
// The user-scoped helper enqueues exactly the caller's account.
void enqueueUserMedicationComplianceBackfill(userId);
annotate({
meta: {
medication_compliance_path: "live-fallback",
medication_compliance_days: days,
},
});
return buildComplianceBuckets(userId, days, userTz);
}
interface ComplianceBucket {
date: string;
scheduled: number;
@@ -253,40 +218,132 @@ interface ComplianceBucket {
}
/**
* v1.4.34 IW-G — pulled out of the GET handler so `cached()` can wrap
* the per-user compliance aggregation. Pure function over the user's
* intake events + the requested window; deterministic given a fixed
* "now" wall-clock (we anchor on `Date.now()` once at call time so a
* 15-minute cached row stays internally consistent).
* v1.15.9 — schedule-anchored per-day compliance buckets for the dashboard
* tile. Replaces the rollup/legacy path whose `scheduled` was the count of
* logged intake rows (BUG #1: rate `taken / scheduled` ≈ 100% across every
* window because every logged row was both numerator and denominator).
*
* `scheduled` is now the canonical recurrence engine's expected-dose count
* for the day, summed across the user's active medications, so days the
* schedule expected a dose that the user missed pull the rate down — and the
* 7/30/90 windows genuinely diverge with partial adherence. `taken` stays the
* count of taken (non-skipped, non-auto-missed) doses that day, capped at the
* day's expected count so a duplicate log can't push a day above 100%.
*
* Pure-ish over a single pinned `now` so a 15-minute cached row is internally
* consistent. Bounded: one active-medications query + one window-events query,
* then per-medication-per-day engine expansion over the trailing window.
*/
async function buildComplianceBuckets(
async function buildScheduleAnchoredComplianceBuckets(
userId: string,
days: number,
userTz: string,
): Promise<ComplianceBucket[]> {
const nowMs = Date.now();
const now = new Date();
const nowMs = now.getTime();
const start = new Date(nowMs - days * 86_400_000);
const medications = await prisma.medication.findMany({
where: { userId, active: true },
include: { schedules: true },
});
const events = await prisma.medicationIntakeEvent.findMany({
// v1.7.0 sync — exclude tombstoned rows from the compliance buckets.
where: { userId, deletedAt: null, scheduledFor: { gte: start } },
select: { scheduledFor: true, takenAt: true, skipped: true },
select: {
medicationId: true,
scheduledFor: true,
takenAt: true,
skipped: true,
autoMissed: true,
},
});
const buckets = new Map<string, { scheduled: number; taken: number }>();
for (let i = 0; i < days; i++) {
const d = new Date(nowMs - i * 86_400_000);
buckets.set(userDayKey(d, userTz), { scheduled: 0, taken: 0 });
}
for (const e of events) {
const key = userDayKey(e.scheduledFor, userTz);
const bucket = buckets.get(key);
if (!bucket) continue;
bucket.scheduled += 1;
if (e.takenAt && !e.skipped) bucket.taken += 1;
// Pre-compute each local day's [start, end) bounds + key, oldest → newest.
const dayKeys: string[] = [];
const dayBounds = new Map<string, { start: Date; end: Date }>();
for (let i = days - 1; i >= 0; i--) {
const representative = new Date(nowMs - i * 86_400_000 - 12 * 60 * 60 * 1000);
const { start: dayStart, end: dayEndInclusive } = getUserTodayBounds(
representative,
userTz,
);
const key = userDayKey(dayStart, userTz);
if (dayBounds.has(key)) continue;
dayKeys.push(key);
dayBounds.set(key, {
start: dayStart,
end: new Date(dayEndInclusive.getTime() + 1), // half-open [start, end)
});
}
return [...buckets.entries()]
.map(([date, v]) => ({ date, scheduled: v.scheduled, taken: v.taken }))
const totals = new Map<string, { scheduled: number; taken: number }>();
for (const key of dayKeys) totals.set(key, { scheduled: 0, taken: 0 });
// Group events by medication so each med's engine context is built once.
const eventsByMed = new Map<
string,
{ scheduledFor: Date; takenAt: Date | null; skipped: boolean; autoMissed: boolean }[]
>();
for (const e of events) {
const list = eventsByMed.get(e.medicationId) ?? [];
list.push({
scheduledFor: e.scheduledFor,
takenAt: e.takenAt,
skipped: e.skipped,
autoMissed: e.autoMissed,
});
eventsByMed.set(e.medicationId, list);
}
for (const med of medications) {
if (med.schedules.length === 0) continue;
const medEvents = eventsByMed.get(med.id) ?? [];
const ctx = buildComplianceMedicationContext(
med,
lastNonSkippedTakenAt(medEvents),
userTz,
);
for (const key of dayKeys) {
const bounds = dayBounds.get(key)!;
// Skip days before the medication existed so a young med doesn't paint
// missed-denominator days it could not have been dosed on.
if (bounds.end <= med.createdAt) continue;
const scheduled = expectedSlotCountForDay(
med.schedules,
bounds.start,
bounds.end,
ctx,
medEvents,
);
if (scheduled === 0) continue;
// Taken doses that landed in this day's window (non-skipped, non-auto-
// missed), capped at the expected count so a duplicate log can't push a
// single day above 100%.
const takenThisDay = medEvents.filter(
(e) =>
e.takenAt !== null &&
!e.skipped &&
!e.autoMissed &&
e.scheduledFor >= bounds.start &&
e.scheduledFor < bounds.end,
).length;
const bucket = totals.get(key)!;
bucket.scheduled += scheduled;
bucket.taken += Math.min(takenThisDay, scheduled);
}
}
return dayKeys
.map((date) => {
const v = totals.get(date)!;
return { date, scheduled: v.scheduled, taken: v.taken };
})
.sort((a, b) => a.date.localeCompare(b.date));
}
@@ -13,7 +13,8 @@ import { MedicationStatusPill } from "@/components/medications/card-parts/medica
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
import { MedicationCycleStatus } from "@/components/medications/card-parts/medication-cycle-status";
import type { CurrentCycle } from "@/lib/analytics/compliance";
import { MedicationCardBody } from "@/components/medications/card-parts/medication-card-body";
import type { CurrentCycle, DoseStatus } from "@/lib/analytics/compliance";
/**
* v1.7.2 — the medication-card status pill, compliance bars, intake-action
@@ -84,49 +85,22 @@ describe("medication card-parts — shared presentational components", () => {
expect(html).not.toContain("lucide-flame");
});
it("compliance bars show taken / expected counts next to each rate", () => {
// v1.15.8 — two identical percentages with no context read as a stuck
// display; the count after each rate makes them distinguishable. A
// rolling weekly med reading 100% on both windows now shows
// `100% · 4 / 4` and `100% · 52 / 52`.
it("compliance bars render the percentage only — no dose count beside it", () => {
// v1.15.9 — the operator does not want a count next to the percentage.
// The auto-miss engine now makes the two windows' percentages genuinely
// diverge, so the per-row count (`· 12 / 12`) is gone: percentage + bar
// + window-days label only.
const html = render(
<MedicationComplianceBars
rate7={100}
rate30={100}
streak={3}
takenShort={4}
expectedShort={4}
takenLong={52}
expectedLong={52}
/>,
);
expect(html).toContain("4 / 4");
expect(html).toContain("52 / 52");
// The middle-dot separator joins the percentage and the count.
expect(html).toContain("·");
});
it("compliance bars fall back to the bare expected count when no taken count is on the wire", () => {
const html = render(
<MedicationComplianceBars
rate7={80}
rate30={75}
streak={0}
expectedShort={12}
expectedLong={52}
/>,
);
expect(html).toContain("12 doses");
expect(html).toContain("52 doses");
});
it("compliance bars render no count line when neither count is available", () => {
// Older mocks / the pre-display fallback path pass only the rates.
const html = render(
<MedicationComplianceBars rate7={90} rate30={88} streak={0} />,
<MedicationComplianceBars rate7={100} rate30={92} streak={3} />,
);
expect(html).toContain("100%");
expect(html).toContain("92%");
// No middle-dot separator and no "doses" count caption anywhere.
expect(html).not.toContain("·");
expect(html).not.toContain("doses");
// The bar + the window-days label survive.
expect(html).toContain("7-day compliance");
expect(html).toContain("30-day compliance");
});
it("status pill stamps the success token + take-now glyph in window", () => {
@@ -397,3 +371,89 @@ describe("streak-token parity — generic vs GLP-1 card", () => {
}
});
});
/**
* v1.15.9 — the ONE shared `<MedicationCardBody>` both cards render. These
* tests pin the state-driven presentation (green take-window highlight,
* overdue / heavily-overdue top line) and the structural identity that makes
* the two variants impossible to diverge — they pass content into THIS body.
*/
describe("medication card body — shared shell + dose-state presentation", () => {
function renderBody(doseStatus: DoseStatus, active = true) {
return render(
<MedicationCardBody
name="Ramipril"
dose="5 mg"
categoryLabel="Blood Pressure"
active={active}
href="/medications/m1"
linkLabel="Open"
stateBadges={null}
headerActions={null}
windowStatus={
doseStatus === "on_time_window"
? { status: "in_window", windowStart: "08:00", windowEnd: "20:00" }
: null
}
doseStatus={doseStatus}
nextLine="Tomorrow, 08:00"
lastLine="Today, 07:30"
compliance={{ rate7: 90, rate30: 88, streak: 0, shortDays: 7, longDays: 30 }}
currentCycle={null}
intakeLoading={null}
onRecordIntake={() => {}}
/>,
);
}
it("highlights the card green when the dose is in its take-window", () => {
const html = renderBody("on_time_window");
// Green ring + tinted background on the Card shell.
expect(html).toContain("ring-success/40");
expect(html).toContain("bg-success/5");
});
it("stays calm (no green highlight) for upcoming / taken states", () => {
for (const status of ["upcoming", "taken_on_time", "taken_late", "skipped"] as const) {
const html = renderBody(status);
expect(html).not.toContain("ring-success/40");
}
});
it("shows a calm 'Overdue' top line for an overdue dose", () => {
const html = renderBody("overdue");
expect(html).toContain("Overdue");
expect(html).toContain("text-destructive");
expect(html).not.toContain("Very overdue");
});
it("escalates to 'Very overdue' at / past the miss cutoff", () => {
const html = renderBody("missed");
expect(html).toContain("Very overdue");
expect(html).toContain("text-destructive");
});
it("never highlights green on an inactive medication", () => {
const html = renderBody("on_time_window", false);
expect(html).not.toContain("ring-success/40");
expect(html).toContain("opacity-60");
});
it("renders the decisive next + last lines exactly once each", () => {
const html = renderBody("upcoming");
expect((html.match(/Next intake:/g) ?? []).length).toBe(1);
expect((html.match(/Last intake:/g) ?? []).length).toBe(1);
expect(html).toContain("Tomorrow, 08:00");
expect(html).toContain("Today, 07:30");
});
it("keeps the body shell + spacing tokens that guarantee cross-variant identity", () => {
const html = renderBody("upcoming");
// Single shared CardContent body with the canonical spacing rhythm.
expect(html).toContain("flex h-full flex-col space-y-3.5");
// Bottom-pinned action wrapper.
expect(html).toContain("mt-auto");
// The shared reserved-height next/last slot.
expect(html).toContain("min-h-[2.75rem] space-y-3.5 text-sm");
});
});
@@ -321,11 +321,12 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
);
// v1.15.8 — the GLP-1 card unified onto the generic "Next intake" /
// "Last intake" labels (the appointment-phrased override is gone). The
// last-line renders the "Last intake:" label in its own left column and
// the value (label · site) flush right; both halves show in the SSR.
// "Last intake" labels (the appointment-phrased override is gone).
// v1.15.9 — the injection SITE is no longer shown on the card line; the
// last-line reads the relative-day label only, exactly like the generic
// card. (Tracking / logging is unchanged everywhere else.)
expect(html).toContain("Last intake:");
expect(html).toContain("Abdomen, lower left");
expect(html).not.toContain("Abdomen, lower left");
// The next-line carries the "Next intake:" label in its left column,
// independent of which of the three value variants (today / tomorrow /
// "in N days") the helper produced.
@@ -372,11 +373,13 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
expect(html).not.toContain("Dosis-Historie");
});
it("does not render an injection-site recommendation on the card", () => {
// The card no longer carries a "recommended next site" nudge — the
// recommendation is noise on the at-a-glance surface. Site TRACKING stays
// (the last site reads on the last-injection line; the post-dose picker
// still captures the next site), only the recommendation copy is gone.
it("does not render any injection-site display on the card", () => {
// v1.15.9 — the card surface drops the injection SITE entirely: neither
// the old "recommended next site" nudge NOR the last-site label on the
// last-injection line. The operator: "where I injected doesn't interest
// me." Site TRACKING is unchanged everywhere else (the post-dose picker
// captures the next site; the detail history shows it) — only the card
// surface is clean.
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {
@@ -399,10 +402,9 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
client,
);
// No recommendation copy anywhere on the card.
// No recommendation copy and no last-site label anywhere on the card.
expect(html).not.toContain("Recommended next:");
// Last site stays visible on the last-injection line (tracking preserved).
expect(html).toContain("Abdomen, lower left");
expect(html).not.toContain("Abdomen, lower left");
});
it("no longer renders the pen-inventory line (retired v1.4.28)", () => {
@@ -609,9 +611,10 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
expect(html).toContain("Sonstiges");
expect(html).not.toContain("GLP-1-Injektion");
// v1.15.8 — unified onto the generic "Letzte Einnahme:" label (the
// appointment-phrased "Letzter Termin:" override is gone).
// appointment-phrased "Letzter Termin:" override is gone). v1.15.9 — the
// injection site no longer renders on the card.
expect(html).toContain("Letzte Einnahme:");
expect(html).toContain("Bauch, unten links");
expect(html).not.toContain("Bauch, unten links");
// v1.4.28 retired the "Dosis-Historie" disclosure on the GLP-1 card.
expect(html).not.toContain("Dosis-Historie");
});
@@ -0,0 +1,215 @@
"use client";
import { type ReactNode } from "react";
import { AlertTriangle } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
import {
MedicationComplianceBars,
MedicationComplianceSkeleton,
} from "@/components/medications/card-parts/medication-compliance-bars";
import { MedicationCycleStatus } from "@/components/medications/card-parts/medication-cycle-status";
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
import { MedicationNextLastSlot } from "@/components/medications/card-parts/medication-next-last-slot";
import { useTranslations } from "@/lib/i18n/context";
import type { CurrentCycle, DoseStatus } from "@/lib/analytics/compliance";
import type { MedicationWindowStatus } from "@/lib/medications/window-status";
/**
* v1.15.9 — the ONE shared medication-card body.
*
* Both the generic `<MedicationCard>` and the `<Glp1MedicationCard>` render
* THIS component for their entire card shell + body, so structure, vertical
* rhythm, spacing tokens, section order and labels are byte-identical by
* construction — they cannot drift. The variants own only the data and the
* VALUE content of the next/last lines (a daily med shows a clock time, a
* weekly GLP-1 shows the liked "Samstag 13.7. (in 7 Tagen)" relative-day
* phrasing); everything structural lives here.
*
* This closes the recurring "the two cards look subtly different" failure.
* The Ramipril-vs-Mounjaro bottom-spacing mismatch in particular is fixed by
* sharing this single `CardContent` (`flex h-full flex-col space-y-3.5`) and
* the single `mt-auto pt-0` action wrapper — there is no per-variant spacing
* left to diverge.
*
* State-driven presentation (like the iOS app):
* - `on_time_window` → the card highlights green (`ring` + tinted bg): the
* actionable "take it now" state stands out at a glance.
* - `overdue` → a calm top status line; `missed` (at / past the miss
* cutoff) escalates to "Stark überfällig" in the destructive tone.
* - `upcoming` / `taken_*` / `skipped` → no escalation; the card stays calm.
*/
export interface MedicationCardBodyProps {
/** Medication name shown on the header's line 1. */
name: string;
/** Dose shown beside the name on line 1. */
dose: string;
/** Localised category label for the header badge. */
categoryLabel: string;
/** Whether the medication is active (greys out + hides the action row). */
active: boolean;
/** Detail-page link target for the header region. */
href: string;
/** Accessible label for the navigating header link. */
linkLabel: string;
/** State badges (without-notification / paused) for the header. */
stateBadges: ReactNode;
/** Overflow kebab for the header. */
headerActions: ReactNode;
/** Current take-window status pill props, or null when no window is open. */
windowStatus: {
status: Exclude<MedicationWindowStatus, null>;
windowStart: string;
windowEnd: string;
} | null;
/**
* The open dose's server-derived {@link DoseStatus}. Drives the green
* take-window highlight and the overdue / heavily-overdue top line.
*/
doseStatus: DoseStatus;
/** Upcoming-intake line value, or null when there is nothing to show. */
nextLine: ReactNode | null;
/** Last-intake line value, or null when the med has never been taken. */
lastLine: ReactNode | null;
/**
* The resolved compliance block, or null while the per-card query is in
* flight (the body then renders the constant-height skeleton).
*/
compliance: {
rate7: number;
rate30: number;
streak: number;
shortDays: number;
longDays: number;
} | null;
/** The open-cycle descriptor, or null when the display block is absent. */
currentCycle: CurrentCycle | null;
/** "take" | "skip" while the matching request is in flight, else null. */
intakeLoading: string | null;
/** Record the displayed dose (the card binds the slot instant). */
onRecordIntake: (skipped: boolean) => void;
/** GLP-1 variant mounts its post-dose injection-site dialog here. */
children?: ReactNode;
}
export function MedicationCardBody({
name,
dose,
categoryLabel,
active,
href,
linkLabel,
stateBadges,
headerActions,
windowStatus,
doseStatus,
nextLine,
lastLine,
compliance,
currentCycle,
intakeLoading,
onRecordIntake,
children,
}: MedicationCardBodyProps) {
const { t } = useTranslations();
// Green take-window highlight — the actionable "take it now" state, mirrored
// from the iOS card. Suppressed on an inactive med (it carries no actions).
const inTakeWindow = active && doseStatus === "on_time_window";
// Overdue escalation line: a dose past its on-time window. `missed` (at /
// past the clinical miss cutoff) reads "Stark überfällig"; the still-takeable
// `overdue` tail reads the calmer "Überfällig". Both use the destructive
// tone so the urgency is unmistakable; suppressed on an inactive med.
const overdueLabel =
active && doseStatus === "missed"
? t("medications.veryOverdue")
: active && doseStatus === "overdue"
? t("medications.overdue")
: null;
return (
<Card
className={cn(
"h-full",
active ? "" : "opacity-60",
inTakeWindow && "ring-success/40 bg-success/5 ring-1",
)}
>
<MedicationCardHeader
name={name}
dose={dose}
categoryLabel={categoryLabel}
stateBadges={stateBadges}
actions={headerActions}
href={href}
linkLabel={linkLabel}
/>
<CardContent className="flex h-full flex-col space-y-3.5">
{/* Top status line. The in-window take-now pill (success) and the
overdue escalation are mutually exclusive — a dose is either still
in its take-window or past it. The overdue line wins when present
so a heavily-overdue dose reads loud at the top of the card. */}
{overdueLabel ? (
<p className="text-destructive flex items-center gap-1 text-sm font-medium">
<AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />
{overdueLabel}
</p>
) : windowStatus ? (
<MedicationStatusPill
status={windowStatus.status}
windowStart={windowStatus.windowStart}
windowEnd={windowStatus.windowEnd}
/>
) : null}
{/* Next / last intake — the two decisive lines, each shown once. */}
<MedicationNextLastSlot next={nextLine} last={lastLine} />
{/* Compliance bars — always two rows; constant-height skeleton holds
the slot while the query is in flight so the grid row stays even. */}
{active &&
(compliance ? (
<MedicationComplianceBars
rate7={compliance.rate7}
rate30={compliance.rate30}
streak={compliance.streak}
shortDays={compliance.shortDays}
longDays={compliance.longDays}
/>
) : (
<MedicationComplianceSkeleton />
))}
{/* Open-cycle status line — calm, rate-decoupled. */}
{active && currentCycle && (
<MedicationCycleStatus cycle={currentCycle} />
)}
{/* Quick actions — bottom-pinned so the action rows align across a
grid row regardless of how much content sits above. */}
{active && (
<div className="mt-auto pt-0">
<MedicationIntakeActions
intakeLoading={intakeLoading}
onRecordIntake={onRecordIntake}
/>
</div>
)}
</CardContent>
{children}
</Card>
);
}
@@ -3,30 +3,6 @@ import { Flame } from "lucide-react";
import { Progress } from "@/components/ui/progress";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
type Translate = ReturnType<typeof useTranslations>["t"];
type Formatters = ReturnType<typeof useFormatters>;
/**
* v1.15.8 — build the dose-count caption shown after the percentage.
*
* Returns `taken / expected` (`4 / 12`) when both counts are known, the
* pluralised `<expected> doses` string when only the denominator is on the
* wire, and `null` when neither is available (older mocks / pre-display
* fallback) so the caller renders nothing rather than an empty separator.
*/
function doseCount(
t: Translate,
fmt: Formatters,
taken: number | undefined,
expected: number | undefined,
): string | null {
if (typeof expected !== "number") return null;
if (typeof taken === "number") {
return `${fmt.number(taken)} / ${fmt.number(expected)}`;
}
return t("medications.complianceDoses", { count: expected });
}
interface MedicationComplianceBarsProps {
rate7: number;
rate30: number;
@@ -41,20 +17,6 @@ interface MedicationComplianceBarsProps {
shortDays?: number;
/** v1.8.6 — the span of the long row in days. */
longDays?: number;
/**
* v1.15.8 — taken-dose count over the short window (numerator). Rendered
* after the percentage as a `taken / expected` count so two identical
* percentages stay distinguishable: a rolling weekly med reading 100% on
* every window now shows `100% · 4 / 4` vs `100% · 52 / 52` instead of two
* bare identical numbers that read as a stuck display.
*/
takenShort?: number;
/** v1.15.8 — expected-dose count over the short window (denominator). */
expectedShort?: number;
/** v1.15.8 — taken-dose count over the long window (numerator). */
takenLong?: number;
/** v1.15.8 — expected-dose count over the long window (denominator). */
expectedLong?: number;
}
/**
@@ -67,6 +29,12 @@ interface MedicationComplianceBarsProps {
* 365-day long window. The labels are parametrised on the chosen day-counts
* so each row names the window it actually covers.
*
* v1.15.9 — the per-row dose count (`· 12 / 12`) is gone: the operator wants
* the percentage only. The auto-miss engine now makes the two windows'
* percentages genuinely diverge, so the counts no longer earn the row noise.
* The engine still carries `expected` / `taken` / `missed` on the wire for
* iOS + the Health Score; the card simply does not render them.
*
* The streak flame uses the semantic `text-warning` token (an alias over
* Dracula orange in dark mode, AA-safe on the light card). The generic card
* historically drifted onto Tailwind stock `text-orange-400`, and the flame
@@ -79,10 +47,6 @@ export function MedicationComplianceBars({
streak,
shortDays = 7,
longDays = 30,
takenShort,
expectedShort,
takenLong,
expectedLong,
}: MedicationComplianceBarsProps) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -96,27 +60,12 @@ export function MedicationComplianceBars({
const shortPct = fmt.number(Math.round(rate7));
const longPct = fmt.number(Math.round(rate30));
// v1.15.8 — the dose-count line rendered after each percentage. Shows the
// taken-of-expected count when both are known (`4 / 12`), else the bare
// expected count when only the denominator is on the wire (`12 Dosen`).
// Two windows reading the same percentage stay distinguishable by their
// counts, which is what an operator needs to trust the number.
const shortCount = doseCount(t, fmt, takenShort, expectedShort);
const longCount = doseCount(t, fmt, takenLong, expectedLong);
return (
<div className="space-y-2.5">
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{shortLabel}</span>
<span className="font-medium">
{shortPct}%
{shortCount && (
<span className="text-muted-foreground ml-1.5 font-normal">
· {shortCount}
</span>
)}
</span>
<span className="font-medium">{shortPct}%</span>
</div>
{/* aria-label so the bar has an accessible name. */}
<Progress value={rate7} className="h-2" aria-label={shortLabel} />
@@ -125,14 +74,7 @@ export function MedicationComplianceBars({
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{longLabel}</span>
<span className="font-medium">
{longPct}%
{longCount && (
<span className="text-muted-foreground ml-1.5 font-normal">
· {longCount}
</span>
)}
</span>
<span className="font-medium">{longPct}%</span>
</div>
<Progress value={rate30} className="h-2" aria-label={longLabel} />
</div>
@@ -4,23 +4,11 @@ import { useEffect, useReducer, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
import { MedicationCardMenu } from "@/components/medications/medication-card-menu";
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
import {
MedicationComplianceBars,
MedicationComplianceSkeleton,
} from "@/components/medications/card-parts/medication-compliance-bars";
import { MedicationCycleStatus } from "@/components/medications/card-parts/medication-cycle-status";
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
import { MedicationCardBody } from "@/components/medications/card-parts/medication-card-body";
import { useMedicationIntake } from "@/components/medications/use-medication-intake";
import {
MedicationNextLastSlot,
useWeekdayLabel,
} from "@/components/medications/card-parts/medication-next-last-slot";
import { useWeekdayLabel } from "@/components/medications/card-parts/medication-next-last-slot";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
invalidateKeys,
@@ -285,13 +273,10 @@ export function Glp1MedicationCard({
const rate7 = display?.short.rate ?? compliance?.compliance7?.rate ?? 0;
const rate30 = display?.long.rate ?? compliance?.compliance30?.rate ?? 0;
const streak = display?.short.streak ?? compliance?.compliance7?.streak ?? 0;
// v1.15.8 — taken-of-expected counts, shown after each percentage so two
// identical rates stay distinguishable (a weekly GLP-1 reading 100% on
// both windows shows e.g. `100% · 4 / 4` vs `100% · 13 / 13`).
const takenShort = display?.short.taken;
const expectedShort = display?.expectedShort;
const takenLong = display?.long.taken;
const expectedLong = display?.expectedLong;
// v1.15.9 — the open dose's server-derived status drives the green
// take-window highlight + the overdue / heavily-overdue top line, shared
// verbatim with the generic card. Defaults to "upcoming" (calm).
const doseStatus = display?.currentDose.status ?? "upcoming";
// v1.4.37 W4b — symmetric take-now / overdue pill with the generic
// card. Sort schedules by `windowStart` so the most-actionable
@@ -325,9 +310,12 @@ export function Glp1MedicationCard({
now,
});
// v1.15.9 — the recent-injection list now feeds ONLY the post-dose
// injection-site dialog's rotation history; the card surface no longer
// shows where the last shot landed. The operator: "where I injected
// doesn't interest me." Site TRACKING / logging is unchanged everywhere
// else (the picker, the detail history) — only the card line drops it.
const recentInjections = details?.recentIntakes ?? [];
const lastSite =
recentInjections.find((i) => i.injectionSite)?.injectionSite ?? null;
function lastInjectionLabel(): string | null {
if (!medication.lastTakenAt) return null;
@@ -382,129 +370,58 @@ export function Glp1MedicationCard({
const categoryLabel = getMedicationCategoryLabel(medication.category, t);
// The upcoming-injection line value. The card owns this VALUE content —
// the liked relative-day phrasing ("Samstag 13.7. (in 7 Tagen)") — while
// the structure / labels live in the shared body. The purple dose accent
// is byte-equivalent with the generic card's.
const nextLine =
next && currentWindowStatus.status !== "in_window" ? (
<>
{nextInjectionLabel()}
{schedule?.dose && (
<span className="text-dose-accent hidden font-medium sm:inline">
{" "}
— {schedule.dose}
</span>
)}
</>
) : null;
// v1.15.9 — the last-injection line drops the injection-site display; it
// now reads exactly like the generic card's last line (relative-day only).
const lastLine = medication.lastTakenAt
? t("medications.glp1LastInjection", { label: lastInjectionLabel() ?? "—" })
: null;
return (
<Card className={cn("h-full", medication.active ? "" : "opacity-60")}>
<MedicationCardHeader
name={medication.name}
dose={medication.dose}
categoryLabel={categoryLabel}
stateBadges={stateBadges}
actions={headerActions}
href={`/medications/${medication.id}`}
linkLabel={t("medications.openDetailPage")}
/>
<CardContent className="flex h-full flex-col space-y-3.5">
{/* Take-now / overdue / very-overdue pill, shared with the
generic medication card. The GLP-1 card historically
omitted this row, which made Mounjaro feel different from
Ramipril on the medications grid even though the underlying
schedule contract is the same shape. */}
{currentWindowStatus.status && (
<MedicationStatusPill
status={currentWindowStatus.status}
windowStart={currentWindowStatus.schedule!.windowStart}
windowEnd={currentWindowStatus.schedule!.windowEnd}
/>
)}
{/* Injection state — next + last, rendered through the SAME shared
<MedicationNextLastSlot> the generic card uses, so the order
(next-then-last), labels ("Next intake" / "Last intake"), colour,
spacing, gating, reserved min-height and the label-left /
value-right two-column layout are identical across the two card
types. v1.15.8 dropped the GLP-1-specific appointment-phrased
label override so both cards name the concept identically; the
relative-day value phrasing ("in N days" / "today") stays the
GLP-1 card's own and is preserved. */}
<MedicationNextLastSlot
next={
next && currentWindowStatus.status !== "in_window" ? (
<>
{nextInjectionLabel()}
{/* Purple dose accent on the upcoming schedule dose,
byte-equivalent with the generic card. Schedule.dose can
override the medication-level dose during titration.
Hidden below sm: to keep the narrow viewport row tight. */}
{schedule?.dose && (
<span className="text-dose-accent hidden font-medium sm:inline">
{" "}
— {schedule.dose}
</span>
)}
</>
) : null
}
last={
medication.lastTakenAt
? lastSite
? t("medications.glp1LastInjectionWithSite", {
label: lastInjectionLabel() ?? "—",
site: t(`medications.site${siteSuffix(lastSite)}`),
})
: t("medications.glp1LastInjection", {
label: lastInjectionLabel() ?? "—",
})
: null
}
/>
{/* v1.4.28 — the "Bestand" (inventory) surface retired from
the GLP-1 card: both the inline pens-remaining summary line
and the per-pen disclosure are gone. The iOS-consumed
Glp1InventoryDTO slot on /api/medications/[id]/glp1 stays
in the response shape; only the web mounts are gone. */}
{/* Compliance bars — always two rows, shared with the generic
card so the page grid stays harmonious. A constant-height
skeleton holds the slot while the per-card compliance query is
in flight (or returns null) so the card body keeps a fixed
footprint and the action row pins to the same baseline as its
grid-row sibling. */}
{medication.active &&
(compliance ? (
<MedicationComplianceBars
rate7={rate7}
rate30={rate30}
streak={streak}
shortDays={shortDays}
longDays={longDays}
takenShort={takenShort}
expectedShort={expectedShort}
takenLong={takenLong}
expectedLong={expectedLong}
/>
) : (
<MedicationComplianceSkeleton />
))}
{/* Open-cycle status line — the real win for a weekly GLP-1: between
injections the percentage rows are vacuous, so this calm,
rate-decoupled line reports "next dose in N days" / "due today" /
"overdue" (or a neutral "no closed cycles yet") instead of a 0%
that misreads a perfectly on-schedule med. `state: "none"`
collapses to nothing inside the part. */}
{medication.active && display?.currentCycle && (
<MedicationCycleStatus cycle={display.currentCycle} />
)}
{/* Primary actions row — shared with the generic medication card.
The GLP-1-specific side-effect quick-log lives in the
header-actions overflow (kebab), not this row, so Mounjaro and
Ramipril share the canonical two-button primary row. The content
above reserves constant-height slots, so the card bodies in a
grid row are equal height and `mt-auto` pins the action row to
the same baseline without opening a void. */}
{medication.active && (
<div className="mt-auto pt-0">
<MedicationIntakeActions
intakeLoading={intakeLoading}
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
/>
</div>
)}
</CardContent>
<MedicationCardBody
name={medication.name}
dose={medication.dose}
categoryLabel={categoryLabel}
active={medication.active}
href={`/medications/${medication.id}`}
linkLabel={t("medications.openDetailPage")}
stateBadges={stateBadges}
headerActions={headerActions}
windowStatus={
currentWindowStatus.status
? {
status: currentWindowStatus.status,
windowStart: currentWindowStatus.schedule!.windowStart,
windowEnd: currentWindowStatus.schedule!.windowEnd,
}
: null
}
doseStatus={doseStatus}
nextLine={nextLine}
lastLine={lastLine}
compliance={
compliance ? { rate7, rate30, streak, shortDays, longDays } : null
}
currentCycle={display?.currentCycle ?? null}
intakeLoading={intakeLoading}
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
>
{/* v1.8.5 — post-dose injection-site capture (optional, skippable). */}
{tracksInjection && (
<LogInjectionSiteDialog
@@ -523,18 +440,6 @@ export function Glp1MedicationCard({
onSkip={() => setSiteIntakeId(null)}
/>
)}
</Card>
</MedicationCardBody>
);
}
/**
* Map enum value → i18n suffix. ABDOMEN_LEFT → "AbdomenLeft" so we can
* lookup `medications.siteAbdomenLeft` etc. without a switch table.
*/
function siteSuffix(site: InjectionSiteKey): string {
return site
.toLowerCase()
.split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("");
}
+93 -162
View File
@@ -3,23 +3,11 @@
import { useState, useEffect, useReducer } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Card, CardContent } from "@/components/ui/card";
import { useMedicationIntake } from "@/components/medications/use-medication-intake";
import { cn } from "@/lib/utils";
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
import { MedicationCardMenu } from "@/components/medications/medication-card-menu";
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
import {
MedicationComplianceBars,
MedicationComplianceSkeleton,
} from "@/components/medications/card-parts/medication-compliance-bars";
import { MedicationCycleStatus } from "@/components/medications/card-parts/medication-cycle-status";
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
import {
MedicationNextLastSlot,
useWeekdayLabel,
} from "@/components/medications/card-parts/medication-next-last-slot";
import { MedicationCardBody } from "@/components/medications/card-parts/medication-card-body";
import { useWeekdayLabel } from "@/components/medications/card-parts/medication-next-last-slot";
import { formatTimeWindowRange } from "@/lib/time-window-format";
import { formatDateTime, formatTime } from "@/lib/format";
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
@@ -85,10 +73,6 @@ interface Medication {
interface ComplianceData {
compliance7: {
totalExpected: number;
taken: number;
skipped: number;
missed: number;
rate: number;
streak: number;
};
@@ -98,9 +82,10 @@ interface ComplianceData {
/**
* v1.8.6 — the two compliance windows scaled to the dosing cadence. The
* card always shows two percentage rows; `shortDays` / `longDays` name the
* windows and `short` / `long` carry their rates. Additive — older mocks
* omit it, in which case the card falls back to the static 7-/30-day
* `compliance7` / `compliance30` fields.
* windows and `short` / `long` carry their rates. v1.15.9 also carries the
* open dose's `currentDose.status` for the green / overdue presentation.
* Additive — older mocks omit it, in which case the card falls back to the
* static 7-/30-day `compliance7` / `compliance30` fields and a calm state.
*/
complianceDisplay?: ComplianceDisplay;
}
@@ -211,14 +196,10 @@ export function MedicationCard({
const rate7 = display?.short.rate ?? compliance?.compliance7?.rate ?? 0;
const rate30 = display?.long.rate ?? compliance?.compliance30?.rate ?? 0;
const streak = display?.short.streak ?? compliance?.compliance7?.streak ?? 0;
// v1.15.8 — taken-of-expected counts, shown after each percentage so two
// identical rates stay distinguishable. Falls back to the static 7-day
// `taken` when the cadence-scaled display block is absent.
const takenShort = display?.short.taken ?? compliance?.compliance7?.taken;
const expectedShort =
display?.expectedShort ?? compliance?.compliance7?.totalExpected;
const takenLong = display?.long.taken;
const expectedLong = display?.expectedLong;
// v1.15.9 — the open dose's server-derived status drives the card's green
// take-window highlight + the overdue / heavily-overdue top line. Defaults
// to "upcoming" (calm) when the display block is absent (older mocks).
const doseStatus = display?.currentDose.status ?? "upcoming";
const categoryLabel = getMedicationCategoryLabel(medication.category, t);
const sortedSchedules = [...medication.schedules].sort(
(a, b) =>
@@ -307,139 +288,89 @@ export function MedicationCard({
/>
);
// The upcoming-intake line value — a day label + window range + optional
// dose accent. The card owns this VALUE content (a daily med reads as a
// clock-time window); the structure / labels live in the shared body.
const nextLine =
nextSchedule && currentWindowStatus.status !== "in_window"
? (() => {
const s = nextSchedule;
// Format day label relative to today
let dayLabel = "";
if (nextAt) {
const nextDate = toBerlinDate(new Date(nextAt));
const todayStr = `${nowBerlin.getFullYear()}-${nowBerlin.getMonth()}-${nowBerlin.getDate()}`;
const nextStr = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`;
const tomorrow = new Date(nowBerlin);
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = `${tomorrow.getFullYear()}-${tomorrow.getMonth()}-${tomorrow.getDate()}`;
const diffDays = Math.round(
(nextDate.getTime() - nowBerlin.getTime()) /
(24 * 60 * 60 * 1000),
);
if (nextStr === todayStr) {
dayLabel = t("medications.today");
} else if (nextStr === tomorrowStr) {
dayLabel = t("medications.tomorrow");
} else if (diffDays <= 5) {
dayLabel = weekdayLabel(nextDate.getDay());
} else {
dayLabel = fmt.dateWithWeekday(nextDate);
}
}
return (
<>
{dayLabel && `${dayLabel}, `}
{formatTimeWindowRange(s.windowStart, s.windowEnd, locale)}
{s.label && (
<span className="hidden sm:inline"> ({s.label})</span>
)}
{s.dose && (
<span className="text-dose-accent hidden font-medium sm:inline">
{" "}
— {s.dose}
</span>
)}
</>
);
})()
: null;
return (
<Card className={cn("h-full", medication.active ? "" : "opacity-60")}>
<MedicationCardHeader
name={medication.name}
dose={medication.dose}
categoryLabel={categoryLabel}
stateBadges={stateBadges}
actions={headerActions}
href={`/medications/${medication.id}`}
linkLabel={t("medications.openDetailPage")}
/>
<CardContent className="flex h-full flex-col space-y-3.5">
{/* Status, last & next intake info */}
{currentWindowStatus.status && (
<MedicationStatusPill
status={currentWindowStatus.status}
windowStart={currentWindowStatus.schedule!.windowStart}
windowEnd={currentWindowStatus.schedule!.windowEnd}
/>
)}
{/* Next / last intake — rendered through the shared
<MedicationNextLastSlot> so the generic and GLP-1 cards keep an
identical slot (order, colour, spacing, gating, reserved
min-height). The card owns only the *content* of each line; the
wrapper + "Next / Last intake" labels live in the shared part. */}
<MedicationNextLastSlot
next={
nextSchedule && currentWindowStatus.status !== "in_window"
? (() => {
const s = nextSchedule;
// Format day label relative to today
let dayLabel = "";
if (nextAt) {
const nextDate = toBerlinDate(new Date(nextAt));
const todayStr = `${nowBerlin.getFullYear()}-${nowBerlin.getMonth()}-${nowBerlin.getDate()}`;
const nextStr = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`;
const tomorrow = new Date(nowBerlin);
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = `${tomorrow.getFullYear()}-${tomorrow.getMonth()}-${tomorrow.getDate()}`;
const diffDays = Math.round(
(nextDate.getTime() - nowBerlin.getTime()) /
(24 * 60 * 60 * 1000),
);
if (nextStr === todayStr) {
dayLabel = t("medications.today");
} else if (nextStr === tomorrowStr) {
dayLabel = t("medications.tomorrow");
} else if (diffDays <= 5) {
dayLabel = weekdayLabel(nextDate.getDay());
} else {
dayLabel = fmt.dateWithWeekday(nextDate);
}
}
return (
<>
{dayLabel && `${dayLabel}, `}
{formatTimeWindowRange(s.windowStart, s.windowEnd, locale)}
{s.label && (
<span className="hidden sm:inline"> ({s.label})</span>
)}
{s.dose && (
<span className="text-dose-accent hidden font-medium sm:inline">
{" "}
— {s.dose}
</span>
)}
</>
);
})()
: null
}
last={
medication.lastTakenAt
? formatLastTakenAt(medication.lastTakenAt)
: null
}
/>
{/* Compliance bars — always two rows. The server scales the two
windows to the dosing cadence (7 / 30 days for dense meds,
stepping up to 90 / 365 for sparse ones); the labels follow the
chosen windows. A constant-height skeleton holds the slot while
the per-card compliance query is in flight (or returns null) so
the card body keeps a fixed footprint and the action row pins to
the same baseline as its grid-row sibling. */}
{medication.active &&
(compliance ? (
<MedicationComplianceBars
rate7={rate7}
rate30={rate30}
streak={streak}
shortDays={shortDays}
longDays={longDays}
takenShort={takenShort}
expectedShort={expectedShort}
takenLong={takenLong}
expectedLong={expectedLong}
/>
) : (
<MedicationComplianceSkeleton />
))}
{/* Open-cycle status line — calm, rate-decoupled. A sparse weekly /
rolling med between doses reports "next dose in N days" / "due
today" / "overdue" (or a neutral "no closed cycles yet") instead of
leaning on a percentage that reads as a scary 0% for a med that is
actually on schedule. Rendered only when the server supplies the
descriptor; `state: "none"` collapses to nothing inside the part. */}
{medication.active && display?.currentCycle && (
<MedicationCycleStatus cycle={display.currentCycle} />
)}
{/* Quick actions — primary buttons of the medication card. The
content above reserves constant-height slots, so the card bodies
in a grid row are equal height and `mt-auto` pins the action row
to the same baseline without opening the void that an unequal-
height pin produced before. */}
{medication.active && (
<div className="mt-auto pt-0">
<MedicationIntakeActions
intakeLoading={intakeLoading}
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
/>
</div>
)}
</CardContent>
<MedicationCardBody
name={medication.name}
dose={medication.dose}
categoryLabel={categoryLabel}
active={medication.active}
href={`/medications/${medication.id}`}
linkLabel={t("medications.openDetailPage")}
stateBadges={stateBadges}
headerActions={headerActions}
windowStatus={
currentWindowStatus.status
? {
status: currentWindowStatus.status,
windowStart: currentWindowStatus.schedule!.windowStart,
windowEnd: currentWindowStatus.schedule!.windowEnd,
}
: null
}
doseStatus={doseStatus}
nextLine={nextLine}
lastLine={
medication.lastTakenAt ? formatLastTakenAt(medication.lastTakenAt) : null
}
compliance={
compliance ? { rate7, rate30, streak, shortDays, longDays } : null
}
currentCycle={display?.currentCycle ?? null}
intakeLoading={intakeLoading}
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
>
{/* v1.8.5 — post-dose injection-site capture (optional, skippable). */}
{tracksInjection && (
<LogInjectionSiteDialog
@@ -454,6 +385,6 @@ export function MedicationCard({
onSkip={() => setSiteIntakeId(null)}
/>
)}
</Card>
</MedicationCardBody>
);
}
@@ -14,11 +14,14 @@
*/
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import {
DOSE_WINDOW_DEFAULTS,
MIN_STABLE_DOSES,
buildComplianceDisplay,
buildComplianceMedicationContext,
calculateCompliance,
classifyIntakeTiming,
deriveDoseStatus,
doseCadenceFamily,
expectedSlotCountForDay,
expectedSlotsBetween,
lastNonSkippedTakenAt,
@@ -1601,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
@@ -1881,3 +1947,262 @@ describe("expectedSlotsBetween", () => {
expect(slots.length).toBeGreaterThanOrEqual(7);
});
});
// ────────────────────────────────────────────────────────────────────
// v1.15.9 — forgotten doses count as MISSED (BUG #2) + user-skip vs
// auto-miss semantics (BUG #3). A never-acted dose the auto-miss cron
// flipped carries `autoMissed: true`; the engine must pair it to a
// `missed` slot (against the rate). A deliberate user skip
// (`skipped: true`) stays excluded from the denominator.
// ────────────────────────────────────────────────────────────────────
describe("calculateCompliance — autoMissed forgotten doses (BUG #2 / #3)", () => {
const NOW = new Date("2025-01-15T12:00:00Z");
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
const daily: ComplianceSchedule[] = [
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
];
it("a forgotten daily dose (autoMissed) lowers the rate — not neutralised", () => {
// Three days: two taken + one forgotten (autoMissed). Before v1.15.9 the
// auto-skip cron flipped the forgotten dose to skipped, which the engine
// excluded → 2/2 = 100% (inflated). Now it is a real miss → 2/3 = 67%.
const events = [
eventAt(new Date("2025-01-14T08:30:00Z"), true),
{
scheduledFor: new Date("2025-01-13T08:30:00Z"),
takenAt: null,
skipped: false,
autoMissed: true,
},
eventAt(new Date("2025-01-12T08:30:00Z"), true),
];
const result = calculateCompliance(events, daily, 7);
expect(result.taken).toBe(2);
expect(result.skipped).toBe(0);
expect(result.missed).toBeGreaterThanOrEqual(1);
// The autoMissed day is inside the denominator and drags the rate below
// the inflated 100% the old skip-neutralisation produced.
expect(result.rate).toBeLessThan(100);
});
it("a deliberate user-skip is excluded; an auto-miss on the same day-grid counts (BUG #3)", () => {
// One taken, one user-skip (excluded), one auto-miss (counted). The
// user-skip must NOT appear in the denominator; the auto-miss must.
const events = [
eventAt(new Date("2025-01-14T08:30:00Z"), true),
{
scheduledFor: new Date("2025-01-13T08:30:00Z"),
takenAt: null,
skipped: true, // deliberate pause
autoMissed: false,
},
{
scheduledFor: new Date("2025-01-12T08:30:00Z"),
takenAt: null,
skipped: false,
autoMissed: true, // forgotten
},
];
const result = calculateCompliance(events, daily, 7);
expect(result.skipped).toBe(1);
expect(result.taken).toBe(1);
// Denominator = taken + missed (the user-skip is out). At least the
// auto-miss counts, so the rate is taken/(taken+missed) < 100.
expect(result.missed).toBeGreaterThanOrEqual(1);
expect(result.rate).toBeLessThan(100);
// The user-skip alone (no auto-miss) would have kept 100% — confirm the
// auto-miss is what pulls it down by re-running with the skip only.
// Cover every slot the 7-day window emits (today's 08:00 is past NOW =
// 12:00, so the window holds Jan 9..15). Take all but one user-skip.
const skipOnlyEvents = [];
for (let day = 9; day <= 15; day++) {
const at = new Date(`2025-01-${String(day).padStart(2, "0")}T08:30:00Z`);
if (day === 13) {
skipOnlyEvents.push({
scheduledFor: at,
takenAt: null,
skipped: true,
autoMissed: false,
});
} else {
skipOnlyEvents.push(eventAt(at, true));
}
}
const skipOnly = calculateCompliance(skipOnlyEvents, daily, 7);
// Every non-skipped day taken → the deliberate skip never lowers the rate.
expect(skipOnly.rate).toBe(100);
});
it("buildComplianceDisplay surfaces taken / expected / missed counts per row", () => {
const events = [
eventAt(new Date("2025-01-14T08:30:00Z"), true),
{
scheduledFor: new Date("2025-01-13T08:30:00Z"),
takenAt: null,
skipped: false,
autoMissed: true,
},
];
const ctxVal = buildComplianceMedicationContext(
{ startsOn: null, endsOn: null, oneShot: false, createdAt: new Date("2025-01-08T00:00:00Z") },
lastNonSkippedTakenAt(events),
"UTC",
);
const display = buildComplianceDisplay(events, daily, ctxVal, { now: NOW });
// `expected` is the rate denominator (taken + missed), `missed` counts the
// forgotten doses, `taken` the numerator — the "taken / expected" triple.
expect(display.short.expected).toBe(display.short.taken + display.short.missed);
expect(display.short.missed).toBeGreaterThanOrEqual(1);
expect(display.long.expected).toBe(display.long.taken + display.long.missed);
});
});
// ────────────────────────────────────────────────────────────────────
// v1.15.9 — cadence-aware per-dose grace/miss window model. The card
// reads `deriveDoseStatus` to render on-time / overdue / missed states.
// ────────────────────────────────────────────────────────────────────
describe("deriveDoseStatus — daily / intraday windows", () => {
const target = new Date("2025-01-15T08:00:00Z");
it("on-time within ±60 min of target", () => {
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T08:30:00Z"))).toBe(
"on_time_window",
);
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T07:15:00Z"))).toBe(
"on_time_window",
);
});
it("upcoming before the on-time window opens", () => {
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T06:30:00Z"))).toBe(
"upcoming",
);
});
it("overdue between +60 min and +240 min", () => {
// +90 min → past on-time (60), before miss cutoff (240).
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T09:30:00Z"))).toBe(
"overdue",
);
// +239 min → still overdue (takeable).
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T11:59:00Z"))).toBe(
"overdue",
);
});
it("missed past +240 min", () => {
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T12:30:00Z"))).toBe(
"missed",
);
});
it("never overlaps the next dose's on-time window", () => {
// Next dose at 10:00; its on-time window opens at 09:00 (−60 min). The
// 08:00 dose's miss cutoff is therefore clamped to 09:00, well before its
// own +240 min (12:00). At 09:30 the 08:00 dose is already missed.
const nextDoseAt = new Date("2025-01-15T10:00:00Z");
expect(
deriveDoseStatus(target, "daily", new Date("2025-01-15T09:30:00Z"), {
nextDoseAt,
}),
).toBe("missed");
});
it("taken inside the on-time window → taken_on_time, late → taken_late", () => {
expect(
deriveDoseStatus(target, "daily", new Date("2025-01-15T20:00:00Z"), {
takenAt: new Date("2025-01-15T08:30:00Z"),
}),
).toBe("taken_on_time");
expect(
deriveDoseStatus(target, "daily", new Date("2025-01-15T20:00:00Z"), {
takenAt: new Date("2025-01-15T10:00:00Z"),
}),
).toBe("taken_late");
});
it("a deliberate skip short-circuits to skipped", () => {
expect(
deriveDoseStatus(target, "daily", new Date("2025-01-15T20:00:00Z"), {
skipped: true,
}),
).toBe("skipped");
});
});
describe("deriveDoseStatus — weekly GLP-1 (4-day rule)", () => {
const target = new Date("2025-01-13T08:00:00Z"); // a Monday shot
it("on-time within ±1 day of the target day", () => {
expect(deriveDoseStatus(target, "weekly", new Date("2025-01-13T20:00:00Z"))).toBe(
"on_time_window",
);
expect(deriveDoseStatus(target, "weekly", new Date("2025-01-14T07:00:00Z"))).toBe(
"on_time_window",
);
});
it("overdue up to +4 days (still counts when taken — the clinical rule)", () => {
// +3 days from target → past the ±1-day on-time window, before +4 days.
expect(deriveDoseStatus(target, "weekly", new Date("2025-01-16T12:00:00Z"))).toBe(
"overdue",
);
// Taken on day +3 still counts as late, not missed.
expect(
deriveDoseStatus(target, "weekly", new Date("2025-01-20T00:00:00Z"), {
takenAt: new Date("2025-01-16T12:00:00Z"),
}),
).toBe("taken_late");
});
it("missed past +4 days", () => {
expect(deriveDoseStatus(target, "weekly", new Date("2025-01-18T12:00:00Z"))).toBe(
"missed",
);
});
it("defaults match the documented 60-min / 240-min / 4-day boundaries", () => {
expect(DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes).toBe(60);
expect(
DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes + DOSE_WINDOW_DEFAULTS.dailyOverdueMinutes,
).toBe(240);
expect(DOSE_WINDOW_DEFAULTS.weeklyOverdueDays).toBe(4);
});
});
describe("doseCadenceFamily", () => {
it("rolling ≥ 2 days → weekly", () => {
expect(
doseCadenceFamily({ windowStart: "08:00", windowEnd: "09:00", rollingIntervalDays: 7 }),
).toBe("weekly");
});
it("WEEKLY rrule → weekly", () => {
expect(
doseCadenceFamily({
windowStart: "08:00",
windowEnd: "09:00",
rrule: "FREQ=WEEKLY;BYDAY=MO",
}),
).toBe("weekly");
});
it("daily rrule → daily", () => {
expect(
doseCadenceFamily({ windowStart: "08:00", windowEnd: "09:00", rrule: "FREQ=DAILY" }),
).toBe("daily");
});
it("plain legacy daily → daily", () => {
expect(
doseCadenceFamily({ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }),
).toBe("daily");
});
});
+242 -9
View File
@@ -40,6 +40,14 @@ interface IntakeEvent {
takenAt: Date | null;
skipped: boolean;
scheduledFor: Date;
/**
* v1.15.9 — true when the auto-miss cron marked this never-acted dose as
* a forgotten miss. Threaded into the cadence timeline so it counts as a
* `missed` slot (against the rate) instead of being neutralised. Optional
* so pre-v1.15.9 callers / fixtures default it to a normal pending/taken
* row.
*/
autoMissed?: boolean;
}
export type IntakeTimingClass =
@@ -164,6 +172,166 @@ export function classifyIntakeTiming(
return "very_late";
}
/**
* v1.15.9 — cadence-aware per-dose grace/miss window model.
*
* A medication's dose either is on-time, is still takeable but late
* (counts when taken), or has slipped past the point where a self-hoster
* could plausibly still take it (a MISS). The cutoffs differ by cadence:
* an intraday/daily dose has tight hour-scale windows; a weekly/rolling
* injectable (the GLP-1 case) follows the clinical "take it within 4 days,
* otherwise skip to the next scheduled dose" rule.
*
* The defaults below are centralised named constants so a future revision
* can expose them per-medication without re-deriving the boundaries. Make
* it correct first; configurability later.
*
* DAILY / INTRADAY (minute-scale windows around the target instant):
* - on-time: target − 60 min … target + 60 min
* - overdue: target + 60 min … target + 240 min (still takeable)
* - missed: > target + 240 min, OR the next dose's on-time window
* starts first (whichever is sooner — adjacent doses never
* overlap).
*
* WEEKLY / ROLLING injectable (day-scale windows; the 4-day clinical rule):
* - on-time: target day ± 1 day
* - overdue: up to target + 4 days (late-but-counts)
* - missed: > target + 4 days
*/
export const DOSE_WINDOW_DEFAULTS = {
/** Daily / intraday on-time half-width around the target instant (min). */
dailyOnTimeMinutes: 60,
/**
* Daily / intraday overdue tail past the on-time window (min). A dose
* taken inside `[onTime, onTime + overdue]` still counts; beyond it the
* dose is missed.
*/
dailyOverdueMinutes: 240 - 60,
/** Weekly / rolling on-time half-width around the target day (days). */
weeklyOnTimeDays: 1,
/**
* Weekly / rolling overdue tail past the target day (days) — the clinical
* 4-day GLP-1 rule. A shot inside `[target, target + 4d]` still counts;
* beyond it the dose is missed and the user skips to the next slot.
*/
weeklyOverdueDays: 4,
} as const;
/**
* v1.15.9 — the derived per-dose state the medication card renders.
*
* - `on_time_window` — due now, inside the on-time window (green).
* - `overdue` — past on-time, before the miss cutoff (still
* takeable; the card escalates the tint as it nears
* the cutoff — "stark überfällig").
* - `missed` — past the miss cutoff, never acted on.
* - `taken_on_time` — taken inside the on-time window.
* - `taken_late` — taken in the overdue window (still counts).
* - `skipped` — a deliberate user skip (excluded from the rate).
* - `upcoming` — the on-time window has not opened yet.
*
* PRN doses are never scheduled and so never produce a status — callers
* exclude them before calling {@link deriveDoseStatus}.
*/
export type DoseStatus =
| "upcoming"
| "on_time_window"
| "overdue"
| "missed"
| "taken_on_time"
| "taken_late"
| "skipped";
/** Cadence family the window math keys off. */
export type DoseCadenceFamily = "daily" | "weekly";
const HOUR_MS = 60 * 60 * 1000;
const ONE_DAY_MS = 24 * HOUR_MS;
/**
* v1.15.9 — derive a single dose's {@link DoseStatus} from its target
* instant, the cadence family, `now`, and (optionally) when it was taken /
* whether the user skipped it. Pure and deterministic.
*
* `nextDoseAt` (when supplied) caps a daily dose's miss cutoff at the next
* dose's on-time window start, so two adjacent intraday doses never claim
* overlapping overdue windows — the earlier dose flips to `missed` the
* moment the later one comes due if that is sooner than its own cutoff.
*
* The defaults come from {@link DOSE_WINDOW_DEFAULTS}; callers may override
* per-medication once that configurability lands.
*/
export function deriveDoseStatus(
targetAt: Date,
cadence: DoseCadenceFamily,
now: Date,
options?: {
takenAt?: Date | null;
skipped?: boolean;
nextDoseAt?: Date | null;
windows?: Partial<typeof DOSE_WINDOW_DEFAULTS>;
},
): DoseStatus {
if (options?.skipped) return "skipped";
const w = { ...DOSE_WINDOW_DEFAULTS, ...(options?.windows ?? {}) };
const target = targetAt.getTime();
let onTimeStart: number;
let onTimeEnd: number;
let overdueEnd: number;
if (cadence === "weekly") {
onTimeStart = target - w.weeklyOnTimeDays * ONE_DAY_MS;
onTimeEnd = target + w.weeklyOnTimeDays * ONE_DAY_MS;
overdueEnd = target + w.weeklyOverdueDays * ONE_DAY_MS;
} else {
onTimeStart = target - w.dailyOnTimeMinutes * 60_000;
onTimeEnd = target + w.dailyOnTimeMinutes * 60_000;
overdueEnd = onTimeEnd + w.dailyOverdueMinutes * 60_000;
}
// Never let the miss cutoff bleed into the next dose's on-time window.
if (options?.nextDoseAt) {
const nextOnTimeStart =
cadence === "weekly"
? options.nextDoseAt.getTime() - w.weeklyOnTimeDays * ONE_DAY_MS
: options.nextDoseAt.getTime() - w.dailyOnTimeMinutes * 60_000;
if (nextOnTimeStart < overdueEnd) overdueEnd = nextOnTimeStart;
}
const takenAt = options?.takenAt ?? null;
if (takenAt) {
const t = takenAt.getTime();
return t <= onTimeEnd ? "taken_on_time" : "taken_late";
}
const n = now.getTime();
if (n < onTimeStart) return "upcoming";
if (n <= onTimeEnd) return "on_time_window";
if (n <= overdueEnd) return "overdue";
return "missed";
}
/**
* v1.15.9 — classify a schedule's cadence into the {@link DoseCadenceFamily}
* the window model uses. A rolling cadence, or any RRULE / legacy weekly
* cadence that emits less often than daily, is `weekly` (day-scale windows +
* the 4-day rule); everything denser (daily, intraday multi-dose) is
* `daily` (minute-scale windows).
*/
export function doseCadenceFamily(schedule: ComplianceSchedule): DoseCadenceFamily {
if (schedule.rollingIntervalDays != null && schedule.rollingIntervalDays >= 2) {
return "weekly";
}
const rrule = schedule.rrule ?? "";
if (/FREQ=(WEEKLY|MONTHLY|YEARLY)/i.test(rrule)) return "weekly";
// A legacy daysOfWeek restriction to specific weekdays (not every day)
// dosed once a day is still effectively daily-cadence on the days it
// fires; the per-slot window is minute-scale either way. Only the rolling
// / calendar-sparse shapes use the day-scale 4-day rule.
return "daily";
}
/**
* v1.5.0 — schedule view consumed by the cadence-aware adapter.
*
@@ -637,19 +805,30 @@ export interface ComplianceDisplay {
/** Echo of the density floor so a client can re-derive the rung. */
minStableDoses: number;
/**
* Compliance percentage + taken-dose count + day-streak over the short
* window. `taken` is the count of doses taken in the window (the numerator
* the card renders next to the rate so two identical percentages stay
* distinguishable and trustworthy).
* Compliance percentage + counts + day-streak over the short window.
* `taken` is the numerator the card renders next to the rate so two
* identical percentages stay distinguishable and trustworthy; `expected`
* is the rate denominator (taken + missed, EXCLUDING user skips) and
* `missed` the count counting against the rate. The card can render
* "26 / 30 · 87%" from `taken` / `expected`.
*/
short: { rate: number; taken: number; streak: number };
/** Compliance percentage + taken-dose count over the long window. */
long: { rate: number; taken: number };
short: { rate: number; taken: number; expected: number; missed: number; streak: number };
/** Compliance percentage + counts over the long window. */
long: { rate: number; taken: number; expected: number; missed: number };
/**
* v1.13.x Fix 4 — the open-cycle state, decoupled from the percentage
* 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 };
}
/**
@@ -784,18 +963,68 @@ 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,
expectedShort,
expectedLong,
minStableDoses: MIN_STABLE_DOSES,
short: { rate: short.rate, taken: short.taken, streak: short.streak },
long: { rate: long.rate, taken: long.taken },
// `expected` is the rate denominator (taken + missed) so the card can
// render the trustworthy "taken / expected · rate%" triple; user skips
// are excluded from it by construction (they never enter `missed`).
short: {
rate: short.rate,
taken: short.taken,
expected: short.taken + short.missed,
missed: short.missed,
streak: short.streak,
},
long: {
rate: long.rate,
taken: long.taken,
expected: long.taken + long.missed,
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.
*
@@ -917,6 +1146,10 @@ export function calculateCompliance(
scheduledFor: e.scheduledFor,
takenAt: e.takenAt,
skipped: e.skipped,
// v1.15.9 — carry the forgotten-dose flag into the timeline so an
// auto-missed slot pairs to a `missed` status (counts against the
// rate) rather than a neutral `skipped`.
autoMissed: e.autoMissed ?? false,
}));
// v1.13.x — ROLLING retrospective expansion. A rolling cadence
+45 -18
View File
@@ -1,15 +1,20 @@
/**
* v1.4.46 — coverage for the hourly intake-auto-skip helper.
* v1.4.46 — coverage for the hourly intake-auto-miss helper.
*
* The helper runs a single `updateMany` against `MedicationIntakeEvent`
* with the `skipped = false AND takenAt IS NULL AND scheduledFor < cutoff`
* gate, where `cutoff = now - 24 h`. These tests pin:
* v1.15.9 — the terminal state is now `auto_missed = true`, NOT
* `skipped = true`. A forgotten dose is a real MISS that must count against
* adherence; flipping `skipped` (which the compliance engine excludes from
* the denominator) silently inflated the rate. The helper runs a single
* `updateMany` with the `skipped = false AND auto_missed = false AND
* takenAt IS NULL AND scheduledFor < cutoff` gate, where `cutoff = now - 24 h`.
* These tests pin:
* * cron + queue contract (constants don't drift),
* * cutoff is `now - 24 h` exactly (off-by-one safety),
* * Prisma `updateMany` is called with the spec'd shape,
* * idempotency-by-construction (a second pass on the same state
* finds zero candidates because the predicate has already flipped
* them).
* * Prisma `updateMany` is called with the spec'd shape
* (`auto_missed = true`, not `skipped = true`),
* * a user-skipped row is left untouched (a deliberate pause is never
* reclassified as a miss),
* * idempotency-by-construction (a second pass finds zero candidates).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { PrismaClient } from "@/generated/prisma/client";
@@ -27,10 +32,12 @@ interface FakeIntakeRow {
scheduledFor: Date;
takenAt: Date | null;
skipped: boolean;
autoMissed: boolean;
}
interface FakeUpdateManyWhere {
skipped?: boolean;
autoMissed?: boolean;
takenAt?: null;
scheduledFor?: { lt: Date };
}
@@ -44,19 +51,24 @@ function makeFakePrisma(state: { rows: FakeIntakeRow[] }) {
data,
}: {
where: FakeUpdateManyWhere;
data: { skipped: boolean };
data: { autoMissed: boolean };
}) => {
let count = 0;
for (const row of state.rows) {
if (where.skipped !== undefined && row.skipped !== where.skipped)
continue;
if (
where.autoMissed !== undefined &&
row.autoMissed !== where.autoMissed
)
continue;
if (where.takenAt === null && row.takenAt !== null) continue;
if (
where.scheduledFor?.lt !== undefined &&
row.scheduledFor.getTime() >= where.scheduledFor.lt.getTime()
)
continue;
row.skipped = data.skipped;
row.autoMissed = data.autoMissed;
count += 1;
}
return { count };
@@ -90,7 +102,7 @@ describe("runIntakeAutoSkipPass", () => {
vi.clearAllMocks();
});
it("flips pending rows older than 24 h to skipped=true", async () => {
it("flips pending rows older than 24 h to auto_missed=true (NOT skipped)", async () => {
const rows: FakeIntakeRow[] = [
// Stale + unmarked → must flip.
{
@@ -99,6 +111,7 @@ describe("runIntakeAutoSkipPass", () => {
scheduledFor: new Date(NOW_MS - 30 * HOUR_MS),
takenAt: null,
skipped: false,
autoMissed: false,
},
// Stale + unmarked (different user) → must flip.
{
@@ -107,6 +120,7 @@ describe("runIntakeAutoSkipPass", () => {
scheduledFor: new Date(NOW_MS - 48 * HOUR_MS),
takenAt: null,
skipped: false,
autoMissed: false,
},
// Recent (< 24 h) → must stay pending.
{
@@ -115,6 +129,7 @@ describe("runIntakeAutoSkipPass", () => {
scheduledFor: new Date(NOW_MS - 6 * HOUR_MS),
takenAt: null,
skipped: false,
autoMissed: false,
},
// Already taken → must stay.
{
@@ -123,14 +138,17 @@ describe("runIntakeAutoSkipPass", () => {
scheduledFor: new Date(NOW_MS - 36 * HOUR_MS),
takenAt: new Date(NOW_MS - 30 * HOUR_MS),
skipped: false,
autoMissed: false,
},
// Already manually skipped → must stay.
// Already manually skipped → must stay a deliberate skip, never
// reclassified as an auto-miss.
{
id: "i-already-skipped",
userId: "u1",
scheduledFor: new Date(NOW_MS - 48 * HOUR_MS),
takenAt: null,
skipped: true,
autoMissed: false,
},
];
const state = { rows };
@@ -140,13 +158,19 @@ describe("runIntakeAutoSkipPass", () => {
expect(result.skippedCount).toBe(2);
expect(result.cutoff.getTime()).toBe(NOW_MS - DAY_MS);
expect(state.rows.find((r) => r.id === "i-stale-1")?.skipped).toBe(true);
expect(state.rows.find((r) => r.id === "i-stale-2")?.skipped).toBe(true);
expect(state.rows.find((r) => r.id === "i-recent")?.skipped).toBe(false);
expect(state.rows.find((r) => r.id === "i-taken")?.skipped).toBe(false);
expect(state.rows.find((r) => r.id === "i-stale-1")?.autoMissed).toBe(true);
expect(state.rows.find((r) => r.id === "i-stale-2")?.autoMissed).toBe(true);
expect(state.rows.find((r) => r.id === "i-recent")?.autoMissed).toBe(false);
expect(state.rows.find((r) => r.id === "i-taken")?.autoMissed).toBe(false);
// The forgotten doses are NOT marked as a user skip.
expect(state.rows.find((r) => r.id === "i-stale-1")?.skipped).toBe(false);
// A deliberate user skip is left exactly as it was.
expect(
state.rows.find((r) => r.id === "i-already-skipped")?.skipped,
).toBe(true);
expect(
state.rows.find((r) => r.id === "i-already-skipped")?.autoMissed,
).toBe(false);
});
it("calls prisma.updateMany with the spec'd where + data shape", async () => {
@@ -159,10 +183,11 @@ describe("runIntakeAutoSkipPass", () => {
expect(prisma.medicationIntakeEvent.updateMany).toHaveBeenCalledWith({
where: {
skipped: false,
autoMissed: false,
takenAt: null,
scheduledFor: { lt: new Date(NOW_MS - DAY_MS) },
},
data: { skipped: true },
data: { autoMissed: true },
});
});
@@ -174,6 +199,7 @@ describe("runIntakeAutoSkipPass", () => {
scheduledFor: new Date(NOW_MS - 30 * HOUR_MS),
takenAt: null,
skipped: false,
autoMissed: false,
},
];
const state = { rows };
@@ -197,6 +223,7 @@ describe("runIntakeAutoSkipPass", () => {
scheduledFor: new Date(NOW_MS - DAY_MS),
takenAt: null,
skipped: false,
autoMissed: false,
},
];
const state = { rows };
@@ -204,7 +231,7 @@ describe("runIntakeAutoSkipPass", () => {
const result = await runIntakeAutoSkipPass(prisma, { nowMs: NOW_MS });
expect(result.skippedCount).toBe(0);
expect(state.rows[0].skipped).toBe(false);
expect(state.rows[0].autoMissed).toBe(false);
});
it("uses Date.now() when no nowMs override is supplied", async () => {
+31 -24
View File
@@ -1,33 +1,39 @@
/**
* v1.4.46 — hourly cron that auto-skips medication intake events the
* user never marked. Without it the dashboard "compliance" tile keeps
* counting a missed dose as `pending` forever (the row's `takenAt`
* stays NULL and `skipped` stays false), which inflates the "missing"
* column on the streak chart and prevents the next day's intake from
* rendering cleanly in the "today" window.
* v1.4.46 — hourly cron that gives a forgotten medication dose a terminal
* state. Without it the dashboard "compliance" tile keeps counting a never-
* acted dose as `pending` forever (the row's `takenAt` stays NULL), which
* prevents the next day's intake from rendering cleanly in the "today"
* window.
*
* Rule: flip `skipped = true` for every `MedicationIntakeEvent` where:
* v1.15.9 — the terminal state is now `auto_missed = true`, NOT
* `skipped = true`. A forgotten dose is a real MISS — it must count against
* adherence. The old behaviour flipped `skipped = true`, and because the
* compliance engine EXCLUDES skipped doses from the denominator (a
* deliberate user pause), a forgotten dose then vanished from the rate
* entirely, silently inflating adherence. Setting `auto_missed` keeps the
* never-acted dose distinct from a user-initiated skip: the engine pairs an
* `auto_missed` slot to a `missed` status (counts against the rate) while a
* `skipped` row stays excluded (a deliberate drug holiday).
*
* Rule: set `auto_missed = true` for every `MedicationIntakeEvent` where:
* * `skipped = false` AND
* * `auto_missed = false` AND
* * `takenAt IS NULL` AND
* * `scheduledFor < NOW() - INTERVAL '24 hours'`
*
* The 24 h grace window is intentional: a slightly late mark
* (user took the morning dose at noon when the schedule was 09:00)
* shouldn't get auto-skipped before the user has had a full day to
* record it. Anything older than 24 h is reliably forgotten — the
* compliance rollup needs a terminal state to count it as a real miss.
* The 24 h grace window is intentional: a slightly late mark (user took the
* morning dose at noon when the schedule was 09:00) shouldn't be flipped
* before the user has had a full day to record it. Anything older than 24 h
* is reliably forgotten — the compliance engine needs a terminal state to
* count it as a real miss.
*
* Idempotent by construction: the second pass within the same hour
* finds zero candidate rows because the first pass already flipped
* them. Safe to re-run on worker restart.
* Idempotent by construction: the second pass within the same hour finds
* zero candidate rows because the first pass already flipped them. Safe to
* re-run on worker restart.
*
* Compliance-rollup coupling: the `skipped = true` transition is the
* same terminal state the user's manual "Skip" button writes, so the
* compliance rollup recompute path triggered by the next ingest /
* dashboard read picks the new rows up without any extra plumbing.
* The helper deliberately does NOT touch the rollup directly — the
* next read-path call lazily folds the day, and a fresh
* compliance-rollup backfill (boot-time) sweeps any historical gaps.
* Compliance coupling: the engine-backed compliance paths read `auto_missed`
* directly. The helper deliberately does NOT touch any rollup — the next
* read-path call recomputes from the live rows.
*/
import type { PrismaClient } from "@/generated/prisma/client";
@@ -52,7 +58,7 @@ export interface IntakeAutoSkipPayload {
}
export interface IntakeAutoSkipResult {
/** Rows that flipped from pending to `skipped = true` on this pass. */
/** Rows that flipped from pending to `auto_missed = true` on this pass. */
skippedCount: number;
/** Cutoff timestamp the pass applied — useful for the audit trail. */
cutoff: Date;
@@ -79,10 +85,11 @@ export async function runIntakeAutoSkipPass(
const { count } = await prisma.medicationIntakeEvent.updateMany({
where: {
skipped: false,
autoMissed: false,
takenAt: null,
scheduledFor: { lt: cutoff },
},
data: { skipped: true },
data: { autoMissed: true },
});
return { skippedCount: count, cutoff };
+18 -1
View File
@@ -150,6 +150,14 @@ export interface IntakeEventLike {
scheduledFor: Date;
takenAt: Date | null;
skipped: boolean;
/**
* v1.15.9 — true when the hourly auto-miss cron marked this never-acted
* dose as a forgotten miss (NOT a deliberate user skip). The pairing pass
* reads it so an auto-missed row counts as `missed` (against the rate)
* rather than `skipped` (excluded). Optional so every legacy caller /
* fixture that omits it keeps the user-skip-vs-taken-vs-missed contract.
*/
autoMissed?: boolean;
}
/** One slot the schedule expected the user to dose. */
@@ -563,7 +571,16 @@ export function pairDoses(
if (bestIdx >= 0) {
claimed.add(bestIdx);
match = events[bestIdx];
if (match.skipped) status = "skipped";
// v1.15.9 — an auto-missed dose (the cron marked a never-acted row
// past its miss cutoff) counts as `missed` even though it carries no
// `takenAt`. Check it BEFORE the user-skip branch: a forgotten dose
// must count against the rate, while a deliberate user skip stays
// `skipped` (excluded from the denominator). `autoMissed` and a
// user-`skipped` are mutually exclusive on a live row by construction
// (the cron only touches `skipped = false` pending rows), but the
// ordering makes the precedence explicit and regression-proof.
if (match.autoMissed) status = "missed";
else if (match.skipped) status = "skipped";
else if (match.takenAt) status = "taken";
else if (slot.windowEnd > now) status = "upcoming";
else status = "missed";
+47
View File
@@ -1274,11 +1274,35 @@ const complianceDisplay = z
short: z.object({
rate: z.number().int().min(0).max(100),
taken: z.number().int().nonnegative(),
expected: z
.number()
.int()
.nonnegative()
.describe(
"v1.15.9 rate denominator over the short window (`taken + missed`); user skips are excluded. Render `taken / expected · rate%`.",
),
missed: z
.number()
.int()
.nonnegative()
.describe(
"v1.15.9 doses counted against the rate over the short window — includes forgotten doses the auto-miss cron flipped.",
),
streak: z.number().int().nonnegative(),
}),
long: z.object({
rate: z.number().int().min(0).max(100),
taken: z.number().int().nonnegative(),
expected: z
.number()
.int()
.nonnegative()
.describe("v1.15.9 rate denominator over the long window (`taken + missed`)."),
missed: z
.number()
.int()
.nonnegative()
.describe("v1.15.9 doses counted against the rate over the long window."),
}),
currentCycle: z
.object({
@@ -1308,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",