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

+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) {