mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(medications): drop the duplicate next-dose line and the injection-site recommendation from the card
The open-cycle status line repeated the upcoming-dose timing already shown in the card's next-intake slot; the calm on-track "next dose in N days" phrasing is now suppressed, leaving only the actionable due / overdue tiers. Remove the "recommended next site" nudge from the GLP-1 card — it is noise on the at-a-glance surface. Injection-site tracking stays: the last site still reads on the last-injection line and the post-dose picker still captures the next site.
This commit is contained in:
1 parent
19af59ebfe
commit
0e184e7f1b
4 files changed
+41
-128
No files matched your search
@@ -207,49 +207,18 @@ describe("medication cycle status — open-cycle line", () => {
|
||||
expect(html).not.toContain("Next dose in");
|
||||
});
|
||||
|
||||
it("on_track several days out reads the relative-day phrasing in success tone", () => {
|
||||
const html = render(
|
||||
<MedicationCycleStatus
|
||||
cycle={{ ...baseCycle, state: "on_track", nextDueAt: inDays(4) }}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Next dose in 4 days");
|
||||
expect(html).toContain("text-success");
|
||||
expect(html).toContain("lucide-circle-check");
|
||||
});
|
||||
|
||||
it("on_track reads the same day count when nextDueAt arrives as an ISO string", () => {
|
||||
// Over the wire `nextDueAt` is JSON, so it reaches the card as a string
|
||||
// even though the type says `Date`. The component must re-hydrate it for
|
||||
// the Berlin day-bucketing — a raw string would silently drift the count.
|
||||
const iso = inDays(4).toISOString() as unknown as Date;
|
||||
const html = render(
|
||||
<MedicationCycleStatus
|
||||
cycle={{ ...baseCycle, state: "on_track", nextDueAt: iso }}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Next dose in 4 days");
|
||||
});
|
||||
|
||||
it("on_track one day out reads tomorrow", () => {
|
||||
const html = render(
|
||||
<MedicationCycleStatus
|
||||
cycle={{ ...baseCycle, state: "on_track", nextDueAt: inDays(1) }}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Next dose tomorrow");
|
||||
expect(html).not.toContain("in 1 days");
|
||||
});
|
||||
|
||||
it("on_track later today reads today", () => {
|
||||
// Six hours past the pinned noon — still the same Berlin calendar day.
|
||||
const later = new Date(Date.now() + 6 * 60 * 60 * 1000);
|
||||
const html = render(
|
||||
<MedicationCycleStatus
|
||||
cycle={{ ...baseCycle, state: "on_track", nextDueAt: later }}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Next dose today");
|
||||
it("on_track renders nothing — the next-dose timing already reads on the card's top slot", () => {
|
||||
// The calm on_track "next dose in N days" phrasing duplicates the
|
||||
// next-intake slot at the top of the card, so the open-cycle line stays
|
||||
// silent for an on-track med; only due / overdue earn a line here.
|
||||
for (const nextDueAt of [inDays(4), inDays(1), new Date(Date.now() + 6 * 60 * 60 * 1000)]) {
|
||||
const html = render(
|
||||
<MedicationCycleStatus
|
||||
cycle={{ ...baseCycle, state: "on_track", nextDueAt }}
|
||||
/>,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
it("due reads the amber due-today line", () => {
|
||||
|
||||
@@ -370,15 +370,11 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
||||
expect(html).not.toContain("Dosis-Historie");
|
||||
});
|
||||
|
||||
it("renders the injection-site rotation nudge folded into the upcoming line", () => {
|
||||
// The rotation nudge surfaces only when `lastSite` and the recommender
|
||||
// disagree. We feed a left-cluster history → the recommender lands on a
|
||||
// right-side or thigh/arm site. The nudge is no longer a separate
|
||||
// bordered block (which made the GLP-1 card taller than the generic card
|
||||
// in a grid row); it is folded into the upcoming-injection line as a
|
||||
// "· Recommended next: …" suffix, so the card keeps the same row
|
||||
// inventory as the generic card. The last site stays visible on the
|
||||
// last-injection line.
|
||||
it("does not render an injection-site recommendation on the card", () => {
|
||||
// The card no longer carries a "recommended next site" nudge — the
|
||||
// recommendation is noise on the at-a-glance surface. Site TRACKING stays
|
||||
// (the last site reads on the last-injection line; the post-dose picker
|
||||
// still captures the next site), only the recommendation copy is gone.
|
||||
const client = makeClient();
|
||||
seedCompliance(client, med7p5.id);
|
||||
seedGlp1Details(client, med7p5.id, {
|
||||
@@ -401,11 +397,9 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
||||
client,
|
||||
);
|
||||
|
||||
// The recommended-next nudge renders in the upcoming line.
|
||||
expect(html).toContain("Recommended next:");
|
||||
// The standalone bordered rotation block is gone (height-neutral fold).
|
||||
expect(html).not.toContain("bg-muted/40 rounded-md border");
|
||||
// Last site stays visible on the last-injection line.
|
||||
// No recommendation copy anywhere on the card.
|
||||
expect(html).not.toContain("Recommended next:");
|
||||
// Last site stays visible on the last-injection line (tracking preserved).
|
||||
expect(html).toContain("Abdomen, lower left");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,30 +1,13 @@
|
||||
import { AlertTriangle, CalendarClock, CircleCheck, CircleDashed } from "lucide-react";
|
||||
import { AlertTriangle, CalendarClock, CircleDashed } from "lucide-react";
|
||||
|
||||
import type { CurrentCycle } from "@/lib/analytics/compliance";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { toBerlinDate } from "@/lib/medications/window-status";
|
||||
|
||||
interface MedicationCycleStatusProps {
|
||||
/** The open-cycle descriptor from `complianceDisplay.currentCycle`. */
|
||||
cycle: CurrentCycle;
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Whole-day delta between two instants, measured on the user's calendar
|
||||
* (Europe/Berlin) so "in 2 days" lines up with the day a sparse dose lands on
|
||||
* rather than a raw 48-hour count. Positive = future, 0 = today, negative =
|
||||
* past. Mirrors the day-bucketing the card already does for its next/last slot.
|
||||
*/
|
||||
function calendarDayDelta(target: Date, now: Date): number {
|
||||
const a = toBerlinDate(target);
|
||||
const b = toBerlinDate(now);
|
||||
const targetMidnight = new Date(a.getFullYear(), a.getMonth(), a.getDate());
|
||||
const nowMidnight = new Date(b.getFullYear(), b.getMonth(), b.getDate());
|
||||
return Math.round((targetMidnight.getTime() - nowMidnight.getTime()) / DAY_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.14.0 — the open-cycle status line for the medication cards.
|
||||
*
|
||||
@@ -32,15 +15,20 @@ function calendarDayDelta(target: Date, now: Date): number {
|
||||
* rolling med (a GLP-1 injection) that is simply between doses has no scored
|
||||
* cycle in flight, so the percentage alone reads as a scary 0% / 100% that
|
||||
* says nothing about whether the user is actually on schedule. This line is
|
||||
* decoupled from the rate: it reports the open cycle's calm status — when the
|
||||
* next dose lands, whether it's due or overdue, or that there's not enough
|
||||
* history to score yet.
|
||||
* decoupled from the rate: it reports the open cycle's calm status — whether
|
||||
* the dose is due or overdue, or that there's not enough history to score yet.
|
||||
*
|
||||
* The calm `on_track` "next dose in N days" phrasing is intentionally NOT
|
||||
* rendered here: the upcoming-dose timing already reads on the card's
|
||||
* next-intake slot at the top, so repeating it would be a duplicate. Only the
|
||||
* actionable due / overdue tiers (and the neutral no-closed-cycles note) earn
|
||||
* this line.
|
||||
*
|
||||
* Driven by `currentCycle.state` (`on_track` / `due` / `missed` / `none`) plus
|
||||
* `hasClosedCycles`. Colour follows the same semantic ramp the status pill
|
||||
* uses (success / warning / destructive) and pairs every tier with a Lucide
|
||||
* glyph so colour-blind users can disambiguate — WCAG 1.4.1 (Use of Color).
|
||||
* It complements the compliance number; it does not replace it.
|
||||
* uses (warning / destructive) and pairs every tier with a Lucide glyph so
|
||||
* colour-blind users can disambiguate — WCAG 1.4.1 (Use of Color). It
|
||||
* complements the compliance number; it does not replace it.
|
||||
*/
|
||||
export function MedicationCycleStatus({ cycle }: MedicationCycleStatusProps) {
|
||||
const { t } = useTranslations();
|
||||
@@ -61,6 +49,12 @@ export function MedicationCycleStatus({ cycle }: MedicationCycleStatusProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// on_track — the next dose simply hasn't come round yet. The upcoming-dose
|
||||
// timing already reads on the card's next-intake slot at the top, so a second
|
||||
// "next dose in N days" line here is a duplicate. Render nothing for the
|
||||
// on-track case; only the actionable due / overdue tiers earn this line.
|
||||
if (cycle.state !== "missed" && cycle.state !== "due") return null;
|
||||
|
||||
let tone: string;
|
||||
let icon: React.ReactNode;
|
||||
let label: string;
|
||||
@@ -69,30 +63,11 @@ export function MedicationCycleStatus({ cycle }: MedicationCycleStatusProps) {
|
||||
tone = "text-destructive";
|
||||
icon = <AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />;
|
||||
label = t("medications.cycleOverdue");
|
||||
} else if (cycle.state === "due") {
|
||||
} else {
|
||||
// due
|
||||
tone = "text-warning";
|
||||
icon = <CalendarClock className="size-3.5 shrink-0" aria-hidden="true" />;
|
||||
label = t("medications.cycleDueToday");
|
||||
} else {
|
||||
// on_track — the next dose simply hasn't come round yet.
|
||||
tone = "text-success";
|
||||
icon = <CircleCheck className="size-3.5 shrink-0" aria-hidden="true" />;
|
||||
const now = new Date();
|
||||
// `nextDueAt` is typed `Date` but arrives as an ISO string over the wire;
|
||||
// re-hydrate it so the Berlin day-bucketing (toLocaleString with a
|
||||
// timeZone) actually applies — a raw string would silently skip it and
|
||||
// drift the count by a day near midnight / off Berlin.
|
||||
const days = cycle.nextDueAt
|
||||
? calendarDayDelta(new Date(cycle.nextDueAt), now)
|
||||
: null;
|
||||
if (days === null || days <= 0) {
|
||||
// on_track with the slot today (now < dueAt but same calendar day).
|
||||
label = t("medications.cycleNextDoseToday");
|
||||
} else if (days === 1) {
|
||||
label = t("medications.cycleNextDoseTomorrow");
|
||||
} else {
|
||||
label = t("medications.cycleNextDoseInDays", { days });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -29,10 +29,7 @@ import {
|
||||
} from "@/lib/query-keys";
|
||||
import { formatDateTime, formatTime } from "@/lib/format";
|
||||
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
|
||||
import {
|
||||
nextInjectionSite,
|
||||
type InjectionSiteKey,
|
||||
} from "@/lib/medications/injection-sites";
|
||||
import { type InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
|
||||
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
|
||||
import {
|
||||
@@ -324,11 +321,6 @@ export function Glp1MedicationCard({
|
||||
const recentInjections = details?.recentIntakes ?? [];
|
||||
const lastSite =
|
||||
recentInjections.find((i) => i.injectionSite)?.injectionSite ?? null;
|
||||
const recommendedNextSite = nextInjectionSite(
|
||||
recentInjections
|
||||
.map((r) => r.injectionSite)
|
||||
.filter(Boolean) as InjectionSiteKey[],
|
||||
);
|
||||
|
||||
function lastInjectionLabel(): string | null {
|
||||
if (!medication.lastTakenAt) return null;
|
||||
@@ -433,23 +425,6 @@ export function Glp1MedicationCard({
|
||||
— {schedule.dose}
|
||||
</span>
|
||||
)}
|
||||
{/* Rotation nudge — folded into the upcoming-injection line
|
||||
instead of a separate bordered block, so the GLP-1 card
|
||||
keeps the same row inventory (and height) as the generic
|
||||
card. Only when we have a last + recommended site that
|
||||
differs from it. */}
|
||||
{lastSite &&
|
||||
recommendedNextSite &&
|
||||
recommendedNextSite !== lastSite && (
|
||||
<span className="hidden sm:inline">
|
||||
{" · "}
|
||||
{t("medications.glp1RotationSuggested", {
|
||||
site: t(
|
||||
`medications.site${siteSuffix(recommendedNextSite)}`,
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user