mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user