mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
e9084410e2
commit
bcbb05c770
18 files changed
+532
-44
No files matched your search
+34
-10
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
Reference in new issue
Block a user