fix(charts,medications): hold the overdue dose on the card and floor non-negative axes

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 22:41:28 +02:00
1 parent e9084410e2
commit bcbb05c770
18 files changed
+532 -44

No files matched your search

+34 -10
View File
@@ -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
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+19 -4
View File
@@ -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,
});
},
);
+36 -5
View File
@@ -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<string, Date[]>();
@@ -92,6 +111,11 @@ async function buildMedicationsList(
else resolvedSlotsByMedId.set(e.medicationId, [e.scheduledFor]);
}
const eraStartByMedId = new Map<string, Date>();
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,
};
});
}
+2 -14
View File
@@ -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,
@@ -213,3 +213,76 @@ describe("<MedicationCard> — next-due reads from server nextDueAt", () => {
expect(html).toContain("Tomorrow,");
});
});
describe("<MedicationCard> — 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<string, unknown>) {
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(
<MedicationCard
medication={med}
onEdit={() => {}}
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(
<MedicationCard
medication={med}
onEdit={() => {}}
onOpenHistory={() => {}}
/>,
client,
);
expect(html).toContain("Next intake:");
expect(html).toContain("Tomorrow");
expect(html).not.toContain("Overdue ·");
expect(html).not.toContain("can still be taken");
});
});
@@ -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 && (
<span className="text-dose-accent hidden font-medium sm:inline">
{" "}
— {schedule.dose}
</span>
)}
</>
overdueLabel ? (
<span className="font-medium text-amber-600 dark:text-amber-400">
{t("medications.nextIntakeOverdue", { time: overdueLabel })}
</span>
) : (
<>
{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
@@ -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 (
<span className="font-medium text-amber-600 dark:text-amber-400">
{t("medications.nextIntakeOverdue", {
time: formatTime(new Date(nextAt).toISOString()),
})}
</span>
);
}
// Format day label relative to today
let dayLabel = "";
if (nextAt) {
@@ -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);
});
});
+40
View File
@@ -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];
}
@@ -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> = {},
): 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);
});
});
+114
View File
@@ -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;
}
+6 -1
View File
@@ -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 })