From bcbb05c7706667a5fd8721055caa5512db438caa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 22:41:28 +0200 Subject: [PATCH] fix(charts,medications): hold the overdue dose on the card and floor non-negative axes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dose past its slot time but still inside its catch-up window now stays on the medication card as overdue and takeable instead of yielding to the next occurrence — the next-due field can therefore carry a past open slot, flagged with nextDueOverdue. Y-axis padding no longer dips below zero for series that cannot be negative, so a steps chart stops suggesting minus nine hundred steps. --- docs/api/openapi.yaml | 44 +++++-- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + src/app/api/medications/[id]/route.ts | 23 +++- src/app/api/medications/route.ts | 41 ++++++- src/components/charts/health-chart.tsx | 16 +-- .../medication-card-next-due.test.tsx | 73 +++++++++++ .../medications/glp1-medication-card.tsx | 42 +++++-- .../medications/medication-card.tsx | 21 ++++ .../insights/__tests__/chart-y-domain.test.ts | 57 +++++++++ src/lib/insights/chart-y-domain.ts | 40 ++++++ .../scheduling/__tests__/next-due.test.ts | 92 +++++++++++++- src/lib/medications/scheduling/next-due.ts | 114 ++++++++++++++++++ src/lib/openapi/routes.ts | 7 +- 18 files changed, 532 insertions(+), 44 deletions(-) create mode 100644 src/lib/insights/__tests__/chart-y-domain.test.ts create mode 100644 src/lib/insights/chart-y-domain.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5d212b1aa..18bb1967f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.16.1 + version: 1.16.3 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -8027,10 +8027,17 @@ components: 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: v1.7.0 server-computed next due instant across all the medication's schedules (earliest + description: "v1.7.0 server-computed next due instant across all the medication's schedules (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot - (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). The list GET is cached 60 s, so a 60 s - staleness is accepted. + (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's + anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era), + this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future + slot. The list GET is cached 60 s, so a 60 s staleness is accepted." + nextDueOverdue: + type: boolean + description: "v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the + slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future + next-due (and when `nextDueAt` is null). Read-only — computed, not stored." startsOn: anyOf: - type: string @@ -8092,6 +8099,7 @@ components: - pausedAt - snoozedUntil - nextDueAt + - nextDueOverdue - startsOn - endsOn - oneShot @@ -8202,10 +8210,17 @@ components: 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: v1.7.0 server-computed next due instant across all the medication's schedules (earliest + description: "v1.7.0 server-computed next due instant across all the medication's schedules (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot - (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). The list GET is cached 60 s, so a 60 s - staleness is accepted. + (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's + anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era), + this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future + slot. The list GET is cached 60 s, so a 60 s staleness is accepted." + nextDueOverdue: + type: boolean + description: "v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the + slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future + next-due (and when `nextDueAt` is null). Read-only — computed, not stored." startsOn: anyOf: - type: string @@ -8254,6 +8269,7 @@ components: - pausedAt - snoozedUntil - nextDueAt + - nextDueOverdue - startsOn - endsOn - oneShot @@ -12364,10 +12380,17 @@ components: 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: v1.7.0 server-computed next due instant across all the medication's schedules (earliest + description: "v1.7.0 server-computed next due instant across all the medication's schedules (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot - (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). The list GET is cached 60 s, so a 60 s - staleness is accepted. + (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's + anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era), + this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future + slot. The list GET is cached 60 s, so a 60 s staleness is accepted." + nextDueOverdue: + type: boolean + description: "v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the + slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future + next-due (and when `nextDueAt` is null). Read-only — computed, not stored." startsOn: anyOf: - type: string @@ -12414,6 +12437,7 @@ components: - pausedAt - snoozedUntil - nextDueAt + - nextDueOverdue - startsOn - endsOn - oneShot diff --git a/messages/de.json b/messages/de.json index 1adc9678f..941a9dfdb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -728,6 +728,7 @@ "complianceDoses": "{count} Dosen", "lastIntake": "Letzte Einnahme:", "nextIntake": "Nächste Einnahme:", + "nextIntakeOverdue": "Überfällig · {time} — noch nachholbar", "dayStreak": "Tage Serie", "cycleNextDoseToday": "Nächste Dosis heute", "cycleNextDoseTomorrow": "Nächste Dosis morgen", diff --git a/messages/en.json b/messages/en.json index 2b5dbd14d..f8c8b45d7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -728,6 +728,7 @@ "complianceDoses": "{count} doses", "lastIntake": "Last intake:", "nextIntake": "Next intake:", + "nextIntakeOverdue": "Overdue · {time} — can still be taken", "dayStreak": "day streak", "cycleNextDoseToday": "Next dose today", "cycleNextDoseTomorrow": "Next dose tomorrow", diff --git a/messages/es.json b/messages/es.json index 672dc8131..97de64056 100644 --- a/messages/es.json +++ b/messages/es.json @@ -728,6 +728,7 @@ "complianceDoses": "{count} dosis", "lastIntake": "Última toma:", "nextIntake": "Próxima toma:", + "nextIntakeOverdue": "Atrasada · {time} — aún se puede tomar", "dayStreak": "días de racha", "cycleNextDoseToday": "Próxima dosis hoy", "cycleNextDoseTomorrow": "Próxima dosis mañana", diff --git a/messages/fr.json b/messages/fr.json index 968dd8b41..7293a6fb9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -728,6 +728,7 @@ "complianceDoses": "{count} doses", "lastIntake": "Dernière prise :", "nextIntake": "Prochaine prise :", + "nextIntakeOverdue": "En retard · {time} — encore rattrapable", "dayStreak": "jours de série", "cycleNextDoseToday": "Prochaine dose aujourd'hui", "cycleNextDoseTomorrow": "Prochaine dose demain", diff --git a/messages/it.json b/messages/it.json index 3fc1e8e5f..6946a612c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -728,6 +728,7 @@ "complianceDoses": "{count} dosi", "lastIntake": "Ultima assunzione:", "nextIntake": "Prossima assunzione:", + "nextIntakeOverdue": "In ritardo · {time} — ancora recuperabile", "dayStreak": "giorni di serie", "cycleNextDoseToday": "Prossima dose oggi", "cycleNextDoseTomorrow": "Prossima dose domani", diff --git a/messages/pl.json b/messages/pl.json index 9f946a149..a1e74fc03 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -728,6 +728,7 @@ "complianceDoses": "{count} dawek", "lastIntake": "Ostatnie przyjęcie:", "nextIntake": "Następne przyjęcie:", + "nextIntakeOverdue": "Zaległa · {time} — wciąż można przyjąć", "dayStreak": "dni serii", "cycleNextDoseToday": "Następna dawka dzisiaj", "cycleNextDoseTomorrow": "Następna dawka jutro", diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index f191ed264..4cc161078 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -22,7 +22,10 @@ import { toRevisionPayloadEntry, } from "@/lib/medications/scheduling/schedule-eras"; import type { Prisma } from "@/generated/prisma/client"; -import { computeNextDueAt } from "@/lib/medications/scheduling/next-due"; +import { + computeDisplayDue, + OVERDUE_LOOKBACK_MS, +} from "@/lib/medications/scheduling/next-due"; import { dayKeyForScheduledFor, recomputeMedicationComplianceForDay, @@ -118,7 +121,9 @@ export const GET = apiHandler( // slot. Bound the read to [now-1d, now+2d] — the lookahead only needs the // slots adjacent to now. const now = new Date(); - const resolvedFrom = new Date(now.getTime() - 24 * 60 * 60 * 1000); + // v1.16.4 — reach back as far as the widest band tail so a + // long-resolved past slot can never resurface as "overdue". + const resolvedFrom = new Date(now.getTime() - OVERDUE_LOOKBACK_MS); const resolvedTo = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000); const resolvedRows = await prisma.medicationIntakeEvent.findMany({ where: { @@ -134,7 +139,15 @@ export const GET = apiHandler( }, select: { scheduledFor: true }, }); - const nextDue = computeNextDueAt({ + // v1.16.4 — current-era floor: the open-overdue search mints from + // the LIVE schedule rows, so it must not reach past the newest + // revision boundary into a previous era's cadence. + const latestRevision = await prisma.medicationScheduleRevision.findFirst({ + where: { medicationId: id }, + orderBy: { validUntil: "desc" }, + select: { validUntil: true }, + }); + const display = computeDisplayDue({ medication: { id: medication.id, startsOn: medication.startsOn, @@ -147,6 +160,7 @@ export const GET = apiHandler( userTz: user.timezone || "Europe/Berlin", lastIntakeAt: lastIntake?.takenAt ?? null, resolvedSlots: resolvedRows.map((r) => r.scheduledFor), + eraStart: latestRevision?.validUntil ?? null, }); annotate({ @@ -160,7 +174,8 @@ export const GET = apiHandler( return apiSuccess({ ...medication, category, - nextDueAt: nextDue ? nextDue.toISOString() : null, + nextDueAt: display ? display.at.toISOString() : null, + nextDueOverdue: display?.overdue ?? false, }); }, ); diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index e9ec1fdf4..1a784e619 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -15,7 +15,10 @@ import { setMedicationCategory, } from "@/lib/medication-category"; import { serializeScheduleRecurrence } from "@/lib/medication-schedule"; -import { computeNextDueAt } from "@/lib/medications/scheduling/next-due"; +import { + computeDisplayDue, + OVERDUE_LOOKBACK_MS, +} from "@/lib/medications/scheduling/next-due"; import { getUserTodayBounds } from "@/lib/timezone"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; @@ -41,8 +44,15 @@ async function buildMedicationsList( const resolvedWindowEnd = new Date( todayEndUtc.getTime() + 2 * 24 * 60 * 60 * 1000, ); + // v1.16.4 — the open-overdue search reaches back as far as the widest + // band tail (weekly on-time + overdue), so the resolved-slot read must + // cover the same horizon or a long-resolved past slot would resurface + // as "overdue". + const resolvedWindowStart = new Date( + todayStartUtc.getTime() - OVERDUE_LOOKBACK_MS, + ); - const [medications, latestIntakes, todayEvents, resolvedEvents] = + const [medications, latestIntakes, todayEvents, resolvedEvents, eraFloors] = await Promise.all([ prisma.medication.findMany({ where: { userId }, @@ -74,7 +84,7 @@ async function buildMedicationsList( where: { userId, deletedAt: null, - scheduledFor: { gte: todayStartUtc, lte: resolvedWindowEnd }, + scheduledFor: { gte: resolvedWindowStart, lte: resolvedWindowEnd }, OR: [ { takenAt: { not: null } }, { skipped: true }, @@ -83,6 +93,15 @@ async function buildMedicationsList( }, select: { medicationId: true, scheduledFor: true }, }), + // v1.16.4 — current-era floor per medication: the newest revision's + // `validUntil` is where the LIVE schedule rows became valid. The + // open-overdue search mints from the live rows, so it must not reach + // past this boundary into a previous era's cadence. + prisma.medicationScheduleRevision.groupBy({ + by: ["medicationId"], + where: { medication: { userId } }, + _max: { validUntil: true }, + }), ]); const resolvedSlotsByMedId = new Map(); @@ -92,6 +111,11 @@ async function buildMedicationsList( else resolvedSlotsByMedId.set(e.medicationId, [e.scheduledFor]); } + const eraStartByMedId = new Map(); + for (const f of eraFloors) { + if (f._max.validUntil) eraStartByMedId.set(f.medicationId, f._max.validUntil); + } + const lastTakenAtByMedicationId = Object.fromEntries( latestIntakes.map((entry) => [ entry.medicationId, @@ -125,7 +149,12 @@ async function buildMedicationsList( const now = new Date(); return medications.map((m) => { - const nextDue = computeNextDueAt({ + // v1.16.4 — an OPEN overdue slot (anchor passed, still inside its + // catch-up band, unresolved) surfaces FIRST with `nextDueOverdue: + // true`; only a closed or resolved band falls through to the future + // next-due. Keeps the card on the still-takeable dose instead of + // jumping ahead the minute the anchor passes. + const display = computeDisplayDue({ medication: { id: m.id, startsOn: m.startsOn, @@ -138,13 +167,15 @@ async function buildMedicationsList( userTz, lastIntakeAt: lastTakenAtDateByMedicationId[m.id] ?? null, resolvedSlots: resolvedSlotsByMedId.get(m.id) ?? [], + eraStart: eraStartByMedId.get(m.id) ?? null, }); return { ...m, category: categoryMap[m.id] ?? "OTHER", lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null, todayEventCount: todayEventCountByMedId[m.id] ?? 0, - nextDueAt: nextDue ? nextDue.toISOString() : null, + nextDueAt: display ? display.at.toISOString() : null, + nextDueOverdue: display?.overdue ?? false, }; }); } diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index ad4f499ba..19813c9a9 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -31,6 +31,7 @@ import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip"; import { ChartEmptyState } from "./chart-empty-state"; import { TileHeader } from "@/components/insights/tile-header"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; +import { computePaddedYDomain } from "@/lib/insights/chart-y-domain"; import { Button } from "@/components/ui/button"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { makeFormatters } from "@/lib/format-locale"; @@ -1135,20 +1136,7 @@ export function HealthChart({ .filter((value): value is number => typeof value === "number") .filter((value) => Number.isFinite(value)); - if (!values.length) return undefined; - - const min = Math.min(...values); - const max = Math.max(...values); - - if (min === max) { - const delta = Math.max(Math.abs(min) * 0.05, 1); - return [min - delta, max + delta * 1.35]; - } - - const span = max - min; - const paddingBottom = Math.max(span * 0.08, 0.5); - const paddingTop = Math.max(span * 0.16, 1); - return [min - paddingBottom, max + paddingTop]; + return computePaddedYDomain(values); }, [ chartDataWithCompare, effectiveCompareBaseline, diff --git a/src/components/medications/__tests__/medication-card-next-due.test.tsx b/src/components/medications/__tests__/medication-card-next-due.test.tsx index d6b5cbd96..0f14c672c 100644 --- a/src/components/medications/__tests__/medication-card-next-due.test.tsx +++ b/src/components/medications/__tests__/medication-card-next-due.test.tsx @@ -213,3 +213,76 @@ describe(" — next-due reads from server nextDueAt", () => { expect(html).toContain("Tomorrow,"); }); }); + +describe(" — open overdue slot (v1.16.4)", () => { + // Server contract: when an unresolved slot's anchor has passed but the + // catch-up band is still open, the list GET carries THAT slot in + // `nextDueAt` with `nextDueOverdue: true`. The card must render it as a + // calm amber "overdue — still takeable" line instead of jumping to the + // next future slot; with the flag false the regular phrasing returns. + function makeMed(overrides: Record) { + return { + id: "med-overdue-1", + name: "Metformin", + dose: "500 mg", + category: "OTHER", + treatmentClass: undefined as string | undefined, + active: true, + notificationsEnabled: true, + pausedAt: null, + lastTakenAt: null, + todayEventCount: 0, + schedules: [{ id: "s-overdue-1", ...pastWindow }], + ...overrides, + }; + } + + it("renders the overdue slot as an amber still-takeable line, not a future next-intake", () => { + // Pinned now is 2026-06-02T12:00:00Z; the slot anchor sits two hours + // earlier, still inside its catch-up band per the server flag. + const med = makeMed({ + nextDueAt: "2026-06-02T10:00:00Z", + nextDueOverdue: true, + }); + const client = makeClient(); + seedCompliance(client, med.id); + + const html = render( + {}} + onOpenHistory={() => {}} + />, + client, + ); + + expect(html).toContain("Overdue ·"); + expect(html).toContain("can still be taken"); + expect(html).toContain("text-amber-600"); + expect(html).not.toContain("Next intake: Tomorrow,"); + }); + + it("renders the regular upcoming phrasing once the flag is false (band closed, next slot)", () => { + const due = new Date("2026-06-03T10:00:00Z"); // tomorrow relative to pinned now + const med = makeMed({ + nextDueAt: due.toISOString(), + nextDueOverdue: false, + }); + const client = makeClient(); + seedCompliance(client, med.id); + + const html = render( + {}} + onOpenHistory={() => {}} + />, + client, + ); + + expect(html).toContain("Next intake:"); + expect(html).toContain("Tomorrow"); + expect(html).not.toContain("Overdue ·"); + expect(html).not.toContain("can still be taken"); + }); +}); diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index c1adab774..aabb41e1a 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -100,6 +100,12 @@ export interface Glp1Medication { * walked a single hardcoded weekly day-of-week. */ nextDueAt?: string | null; + /** + * v1.16.4 — true when `nextDueAt` is an OPEN overdue slot (anchor + * passed, "now" still inside its catch-up band, unresolved). The card + * renders it as "overdue — still takeable" instead of a future date. + */ + nextDueOverdue?: boolean; schedules: ScheduleLite[]; } @@ -376,17 +382,35 @@ export function Glp1MedicationCard({ // 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. + // v1.16.4 — an open overdue slot stays on the card as a calm amber + // "overdue — still takeable" line until its catch-up band closes + // (auto-miss); only then does the line advance to the next future slot. + // A same-day overdue anchor reads as a clock time, an older one (the + // weekly catch-up tail spans days) as its weekday + date. + const overdueLabel = + medication.nextDueOverdue && next + ? diffDays(next.date, now) === 0 + ? formatTime(next.date.toISOString()) + : `${weekdayLabel(next.date.getDay())}, ${fmt.dateShort(next.date)}` + : null; + const nextLine = next && currentWindowStatus.status !== "in_window" ? ( - <> - {nextInjectionLabel()} - {schedule?.dose && ( - - {" "} - — {schedule.dose} - - )} - + overdueLabel ? ( + + {t("medications.nextIntakeOverdue", { time: overdueLabel })} + + ) : ( + <> + {nextInjectionLabel()} + {schedule?.dose && ( + + {" "} + — {schedule.dose} + + )} + + ) ) : null; // v1.15.9 — the last-injection line drops the injection-site display; it diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index d74f5ced3..cd9f8f339 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -76,6 +76,13 @@ interface Medication { * cadences that the legacy daysOfWeek-only walker ignored. */ nextDueAt?: string | null; + /** + * v1.16.4 — true when `nextDueAt` is an OPEN overdue slot (its anchor + * passed but "now" is still inside the slot's catch-up band and no + * intake row resolves it). The card renders the slot as "overdue — + * still takeable" instead of the regular upcoming-intake phrasing. + */ + nextDueOverdue?: boolean; schedules: Schedule[]; } @@ -300,6 +307,20 @@ export function MedicationCard({ ? (() => { const s = nextSchedule; + // v1.16.4 — an open overdue slot stays on the card as a calm + // amber "overdue · HH:mm — still takeable" line until its + // catch-up band closes (auto-miss); only then does the line + // advance to the next future slot. + if (medication.nextDueOverdue && nextAt) { + return ( + + {t("medications.nextIntakeOverdue", { + time: formatTime(new Date(nextAt).toISOString()), + })} + + ); + } + // Format day label relative to today let dayLabel = ""; if (nextAt) { diff --git a/src/lib/insights/__tests__/chart-y-domain.test.ts b/src/lib/insights/__tests__/chart-y-domain.test.ts new file mode 100644 index 000000000..51c11c51a --- /dev/null +++ b/src/lib/insights/__tests__/chart-y-domain.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { computePaddedYDomain } from "@/lib/insights/chart-y-domain"; + +describe("computePaddedYDomain", () => { + it("returns undefined for an empty series", () => { + expect(computePaddedYDomain([])).toBeUndefined(); + }); + + it("ignores non-finite values", () => { + expect(computePaddedYDomain([NaN, Infinity, -Infinity])).toBeUndefined(); + }); + + it("clamps the lower bound to 0 for a non-negative series", () => { + const domain = computePaddedYDomain([200, 450, 900]); + expect(domain).toBeDefined(); + const [min, max] = domain!; + expect(min).toBeGreaterThanOrEqual(0); + expect(max).toBeGreaterThan(900); + }); + + it("clamps to 0 when bottom padding would dip below 0 (steps-like series)", () => { + // span 8800 → 8% bottom padding = 704 > min 200 → unclamped would be -504 + const domain = computePaddedYDomain([200, 9000]); + expect(domain).toBeDefined(); + expect(domain![0]).toBe(0); + }); + + it("keeps downward padding for a series with genuine negative values", () => { + const domain = computePaddedYDomain([-5, 3, 10]); + expect(domain).toBeDefined(); + const [min, max] = domain!; + expect(min).toBeLessThan(-5); + expect(max).toBeGreaterThan(10); + }); + + it("pads a positive series without touching 0 when padding stays above it", () => { + // span 2 → bottom padding max(0.16, 0.5) = 0.5 → 79.5, no clamp needed + const domain = computePaddedYDomain([80, 82]); + expect(domain).toBeDefined(); + expect(domain![0]).toBeCloseTo(79.5); + }); + + it("clamps a flat zero series to a [0, x] domain", () => { + const domain = computePaddedYDomain([0, 0, 0]); + expect(domain).toBeDefined(); + const [min, max] = domain!; + expect(min).toBe(0); + expect(max).toBeGreaterThan(0); + }); + + it("keeps the symmetric flat-series padding for negative flat series", () => { + const domain = computePaddedYDomain([-10, -10]); + expect(domain).toBeDefined(); + expect(domain![0]).toBeLessThan(-10); + }); +}); diff --git a/src/lib/insights/chart-y-domain.ts b/src/lib/insights/chart-y-domain.ts new file mode 100644 index 000000000..4b278992b --- /dev/null +++ b/src/lib/insights/chart-y-domain.ts @@ -0,0 +1,40 @@ +/** + * Shared Y-axis domain computation for every chart surface (dashboard + * charts, insights sub-page charts, mini charts). One source of truth so + * the padding rules cannot drift per chart. + * + * Padding rules: + * - Flat series (min === max): pad symmetrically by 5% of the magnitude + * (at least 1 unit), with a little extra headroom on top. + * - Otherwise: pad the bottom by 8% of the span (min 0.5) and the top by + * 16% of the span (min 1). + * - Zero clamp: when the series contains no negative values the lower + * bound never dips below 0 — padding only extends upward or down to 0. + * Counts, steps, weights etc. must not render a negative axis. Series + * with genuine negative values (deltas, differences) keep the full + * downward padding. + */ +export function computePaddedYDomain( + values: readonly number[], +): [number, number] | undefined { + const finite = values.filter( + (value): value is number => + typeof value === "number" && Number.isFinite(value), + ); + if (!finite.length) return undefined; + + const min = Math.min(...finite); + const max = Math.max(...finite); + // Non-negative series never get a negative lower bound. + const clampLower = (lower: number) => (min >= 0 ? Math.max(0, lower) : lower); + + if (min === max) { + const delta = Math.max(Math.abs(min) * 0.05, 1); + return [clampLower(min - delta), max + delta * 1.35]; + } + + const span = max - min; + const paddingBottom = Math.max(span * 0.08, 0.5); + const paddingTop = Math.max(span * 0.16, 1); + return [clampLower(min - paddingBottom), max + paddingTop]; +} diff --git a/src/lib/medications/scheduling/__tests__/next-due.test.ts b/src/lib/medications/scheduling/__tests__/next-due.test.ts index cc6abed16..495fecd35 100644 --- a/src/lib/medications/scheduling/__tests__/next-due.test.ts +++ b/src/lib/medications/scheduling/__tests__/next-due.test.ts @@ -11,7 +11,7 @@ */ import { describe, expect, it } from "vitest"; -import { computeNextDueAt } from "../next-due"; +import { computeDisplayDue, computeNextDueAt } from "../next-due"; import type { WorkerMedicationRow, WorkerScheduleRow, @@ -246,3 +246,93 @@ describe("computeNextDueAt — stale degenerate window never wins over timesOfDa expect(next!.toISOString()).toBe("2026-06-10T19:00:00.000Z"); }); }); + +/** + * v1.16.4 — `computeDisplayDue`: an unresolved slot whose anchor has + * passed must stay on the card as an OPEN overdue slot while `now` is + * still inside its catch-up band (`anchor < now ≤ overdueEnd`); only a + * closed or resolved band advances to the future next-due. + */ +function makeDailySchedule( + overrides: Partial = {}, +): WorkerScheduleRow { + return { + id: "sched-daily-1", + windowStart: "09:00", + windowEnd: "21:00", + daysOfWeek: null, + timesOfDay: ["09:00", "21:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + ...overrides, + }; +} + +describe("computeDisplayDue — open overdue slot", () => { + // Berlin is UTC+2 in June: the 21:00 local slot on 2026-06-10 is + // 19:00Z; its default band is 20:00–22:00 local with a 3 h late tail, + // so the catch-up window stays open until 01:00 local (23:00Z). + const medication = makeMedication(); + const schedules = [makeDailySchedule()]; + + it("surfaces the unresolved 21:00 slot at 22:30 (inside the catch-up tail) as overdue", () => { + const now = d("2026-06-10T20:30:00Z"); // 22:30 Berlin + const due = computeDisplayDue({ + medication, + schedules, + now, + userTz: BERLIN, + lastIntakeAt: null, + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(true); + expect(due!.at.toISOString()).toBe("2026-06-10T19:00:00.000Z"); // 21:00 Berlin + }); + + it("advances to the next future slot (09:00) once the band closed at 02:00", () => { + const now = d("2026-06-11T00:00:00Z"); // 02:00 Berlin — past the 01:00 tail end + const due = computeDisplayDue({ + medication, + schedules, + now, + userTz: BERLIN, + lastIntakeAt: null, + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(false); + expect(due!.at.toISOString()).toBe("2026-06-11T07:00:00.000Z"); // 09:00 Berlin + }); + + it("treats a slot with a live taken/skipped row on the anchor as resolved", () => { + const now = d("2026-06-10T20:30:00Z"); + const due = computeDisplayDue({ + medication, + schedules, + now, + userTz: BERLIN, + lastIntakeAt: null, + resolvedSlots: [d("2026-06-10T19:00:00Z")], + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(false); + expect(due!.at.toISOString()).toBe("2026-06-11T07:00:00.000Z"); + }); + + it("never claims an overdue anchor from before the current era floor", () => { + const now = d("2026-06-10T20:30:00Z"); + const due = computeDisplayDue({ + medication, + schedules, + now, + userTz: BERLIN, + lastIntakeAt: null, + eraStart: d("2026-06-10T19:30:00Z"), // schedules replaced after the slot + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(false); + }); +}); diff --git a/src/lib/medications/scheduling/next-due.ts b/src/lib/medications/scheduling/next-due.ts index c561f1907..4f646770a 100644 --- a/src/lib/medications/scheduling/next-due.ts +++ b/src/lib/medications/scheduling/next-due.ts @@ -17,6 +17,8 @@ import { type WorkerScheduleRow, } from "@/lib/medications/scheduling/worker-helpers"; import { nextOccurrenceAfter } from "@/lib/medications/scheduling/recurrence"; +import { buildBandsForMedication } from "@/lib/medications/scheduling/band-minter"; +import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults"; /** * v1.15.10 — radius an intake may sit from a slot's canonical instant and @@ -27,6 +29,22 @@ import { nextOccurrenceAfter } from "@/lib/medications/scheduling/recurrence"; */ const RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000; +/** + * v1.16.4 — how far back the open-overdue search mints bands. The widest + * possible band reach is a weekly slot's on-time half-width plus its + * overdue tail (1 + 4 days); one spare day absorbs DST / timezone skew. + * Exported so the list route can widen its resolved-slot read to the + * same horizon. + */ +export const OVERDUE_LOOKBACK_MS = + (DOSE_WINDOW_DEFAULTS.weeklyOnTimeDays + + DOSE_WINDOW_DEFAULTS.weeklyOverdueDays + + 1) * + 24 * + 60 * + 60 * + 1000; + export function computeNextDueAt(input: { medication: WorkerMedicationRow; schedules: WorkerScheduleRow[]; @@ -76,3 +94,99 @@ export function computeNextDueAt(input: { } return earliest; } + +/** The instant a medication card should surface, plus its overdue state. */ +export interface DisplayDue { + at: Date; + /** + * True when `at` is an OPEN overdue slot: its anchor has passed but `now` + * is still inside the slot's catch-up band (`anchor < now ≤ overdueEnd`) + * and the user has not acted on it. The card renders this slot as + * "overdue — still takeable" instead of jumping to the next future slot. + */ + overdue: boolean; +} + +export interface ComputeDisplayDueInput { + medication: WorkerMedicationRow; + schedules: WorkerScheduleRow[]; + now: Date; + userTz: string; + lastIntakeAt: Date | null; + resolvedSlots?: Date[]; + /** + * Floor of the CURRENT schedule era (the newest revision's `validUntil`), + * when the medication has archived revisions. The overdue search mints + * bands from the LIVE schedule rows only, so it must not reach back past + * the era boundary — a pre-edit slot belongs to the old era's cadence and + * must not be re-minted at the new times. + */ + eraStart?: Date | null; +} + +/** + * v1.16.4 — the display-due resolution for the medication list cards. + * + * `computeNextDueAt` walks strictly forward from `now`, so the moment a + * slot's anchor passed the card jumped to the NEXT slot — even while the + * dose was still takeable inside its catch-up band. This wrapper first + * searches the current era for an open overdue slot (band model: + * `anchor < now ≤ overdueEnd`, no taken / skipped / auto-missed row on the + * anchor) and surfaces it with `overdue: true`; only when every passed + * band is closed or resolved does it fall through to the future next-due. + */ +export function computeDisplayDue( + input: ComputeDisplayDueInput, +): DisplayDue | null { + const open = findOpenOverdueSlot(input); + if (open) return { at: open, overdue: true }; + const next = computeNextDueAt(input); + return next ? { at: next, overdue: false } : null; +} + +function findOpenOverdueSlot(input: ComputeDisplayDueInput): Date | null { + const { medication, schedules, now, userTz, lastIntakeAt } = input; + if (schedules.length === 0) return null; + + const resolved = input.resolvedSlots ?? []; + const isResolved = (slotAt: Date): boolean => { + const slot = slotAt.getTime(); + for (const r of resolved) { + if (Math.abs(r.getTime() - slot) <= RESOLVE_RADIUS_MS) return true; + } + return false; + }; + + let floor = new Date(now.getTime() - OVERDUE_LOOKBACK_MS); + if (input.eraStart && input.eraStart.getTime() > floor.getTime()) { + floor = input.eraStart; + } + if (floor.getTime() >= now.getTime()) return null; + + const ctx = buildRecurrenceContext({ medication, userTz, lastIntakeAt }); + let latest: Date | null = null; + for (const schedule of schedules) { + const { bands } = buildBandsForMedication({ + medication, + schedule: buildCanonicalSchedule(schedule), + ctx, + userTz, + range: { from: floor, to: now }, + now, + intakeInstants: lastIntakeAt ? [lastIntakeAt] : [], + }); + for (const band of bands) { + const anchor = band.at.getTime(); + // Open overdue: the anchor has passed, now is still inside the + // catch-up band, and no live intake row resolves the slot. An anchor + // before the era floor is rejected explicitly — the minter works in + // local-day granularity, so the range floor alone is not a guarantee. + if (anchor < floor.getTime()) continue; + if (anchor >= now.getTime()) continue; + if (now.getTime() > band.overdueEnd.getTime()) continue; + if (isResolved(band.at)) continue; + if (latest === null || anchor > latest.getTime()) latest = band.at; + } + } + return latest; +} diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 7b4f2c218..c768d87d2 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -1093,7 +1093,12 @@ const medicationResource = z .datetime({ offset: true }) .nullable() .describe( - "v1.7.0 server-computed next due instant across all the medication's schedules (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). The list GET is cached 60 s, so a 60 s staleness is accepted.", + "v1.7.0 server-computed next due instant across all the medication's schedules (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era), this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future slot. The list GET is cached 60 s, so a 60 s staleness is accepted.", + ), + nextDueOverdue: z + .boolean() + .describe( + "v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future next-due (and when `nextDueAt` is null). Read-only — computed, not stored.", ), startsOn: z.iso .datetime({ offset: true })