mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(dashboard): wire the GLP-1 drug-level pane behind a tab strip and range picker
The GLP-1 tile previously rendered a single weight chart — a downstream proxy for the dose-response. The maintainer-flagged headline is the estimated drug-level curve because it surfaces the sawtooth shape of the weekly cycle directly. Add a two-tab segmented control (Drug-Level default / Weight) inside the chart pane and a 7d / 30d / 90d / All range strip above. The Drug-Level tab mounts <DrugLevelChart compact …>, which now drops its outer card wrapper and renders at 160 px instead of 240 px when 'compact' is set, so the dashboard tile owns the card surface. Range selections thread through a new windowHoursBefore prop on DrugLevelChart; the sample step scales 6/12/24/48 h with window length so 'All' mode does not paint thousands of points. The standalone surface at /medications/[id]/history keeps its full 21-day default and full-card chrome — both consumers share one implementation. New translation keys referenced (B6 sweep picks up): - dashboard.glp1.tabLevel - dashboard.glp1.tabWeight - dashboard.glp1.tabsAria - dashboard.glp1.rangeStripLabel - dashboard.glp1.levelUnavailable - dashboard.glp1.weightUnavailable Test coverage extended: glp1-tile test asserts tab strip + range strip DOM, drug-level default selection, level-unavailable hint when the medication has no id; the existing DrugLevelChart test suite continues to pin the standalone-surface contract.
This commit is contained in:
1 parent
e8fb0a7521
commit
c0ec1fecfb
3 files changed
+376
-42
No files matched your search
@@ -261,8 +261,102 @@ describe("<Glp1Tile>", () => {
|
||||
expect(html).not.toContain("Ozempic 0mg");
|
||||
// No weight delta caption when both sides are null.
|
||||
expect(html).not.toContain('data-slot="glp1-tile-delta"');
|
||||
// No chart when the series is empty.
|
||||
expect(html).not.toContain('data-slot="glp1-tile-chart"');
|
||||
// v1.4.27 B1 — the tab strip + range strip always render; the
|
||||
// weight pane shows the muted "no data yet" hint when the series
|
||||
// is empty rather than suppressing the whole chart row.
|
||||
expect(html).toContain('data-slot="glp1-tile-chart"');
|
||||
expect(html).toContain('data-slot="glp1-tile-tab-level"');
|
||||
expect(html).toContain('data-slot="glp1-tile-tab-weight"');
|
||||
});
|
||||
|
||||
it("v1.4.27 B1 — exposes the tab strip and range strip with drug-level default", () => {
|
||||
queryReturn = {
|
||||
data: {
|
||||
active: true,
|
||||
medications: [
|
||||
{
|
||||
name: "Mounjaro",
|
||||
genericName: "tirzepatide",
|
||||
medicationId: "med-1",
|
||||
currentDose: {
|
||||
value: 7.5,
|
||||
unit: "mg",
|
||||
since: "2026-04-01",
|
||||
weeksOnDose: 6,
|
||||
},
|
||||
doseHistory: [],
|
||||
lastInjection: { date: "2026-05-10", site: null, weeksAgo: 0 },
|
||||
nextInjection: { date: "2026-05-17", daysAway: 3 },
|
||||
startWeight: 92.0,
|
||||
currentWeight: 87.8,
|
||||
weightDeltaKg: -4.2,
|
||||
weightSeries: [{ date: "2026-05-13", weight: 87.8 }],
|
||||
injectionDates: ["2026-04-26", "2026-05-03", "2026-05-10"],
|
||||
},
|
||||
],
|
||||
},
|
||||
isPending: false,
|
||||
};
|
||||
const html = render();
|
||||
// Tab strip + range strip are present on a populated tile.
|
||||
expect(html).toContain('data-slot="glp1-tile-tabs"');
|
||||
expect(html).toContain('data-slot="glp1-tile-tab-level"');
|
||||
expect(html).toContain('data-slot="glp1-tile-tab-weight"');
|
||||
expect(html).toContain('data-slot="glp1-tile-range-strip"');
|
||||
// Drug-Level tab is the default selection.
|
||||
expect(html).toMatch(
|
||||
/data-slot="glp1-tile-tab-level"[^>]*data-active="true"/,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/data-slot="glp1-tile-tab-weight"[^>]*data-active="false"/,
|
||||
);
|
||||
// Range strip exposes 7d / 30d / 90d / All buttons.
|
||||
expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="7"/);
|
||||
expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="30"/);
|
||||
expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="90"/);
|
||||
expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="0"/);
|
||||
// 30d preset is the default.
|
||||
expect(html).toMatch(
|
||||
/data-slot="glp1-tile-range-button"[^>]*data-points="30"[^>]*data-active="true"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("v1.4.27 B1 — surfaces the level-unavailable hint when no medicationId is wired", () => {
|
||||
// Some legacy meds don't have a Medication.id surfaced through the
|
||||
// dashboard route (the route returns medicationId: null). The
|
||||
// drug-level pane needs a stable medication id to mount the
|
||||
// chart, so we render a muted hint instead of the chart.
|
||||
queryReturn = {
|
||||
data: {
|
||||
active: true,
|
||||
medications: [
|
||||
{
|
||||
name: "Ozempic",
|
||||
genericName: "semaglutide",
|
||||
medicationId: null,
|
||||
currentDose: {
|
||||
value: 0.5,
|
||||
unit: "mg",
|
||||
since: "2026-04-01",
|
||||
weeksOnDose: 6,
|
||||
},
|
||||
doseHistory: [],
|
||||
lastInjection: null,
|
||||
nextInjection: null,
|
||||
startWeight: 92.0,
|
||||
currentWeight: 87.8,
|
||||
weightDeltaKg: -4.2,
|
||||
weightSeries: [{ date: "2026-05-13", weight: 87.8 }],
|
||||
injectionDates: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
isPending: false,
|
||||
};
|
||||
const html = render();
|
||||
// Drug-level tab stays selected by default; pane renders the
|
||||
// unavailable hint because medicationId is null.
|
||||
expect(html).toContain('data-slot="glp1-tile-level-unavailable"');
|
||||
});
|
||||
|
||||
it("renders German copy under the de locale", () => {
|
||||
|
||||
@@ -26,13 +26,14 @@
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Syringe, ArrowDown, ArrowUp, Minus, Calendar } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CHART_RANGE_PRESETS } from "@/lib/charts/constants";
|
||||
|
||||
const HealthChart = dynamic(
|
||||
() =>
|
||||
@@ -42,6 +43,28 @@ const HealthChart = dynamic(
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const DrugLevelChart = dynamic(
|
||||
() =>
|
||||
import("@/components/medications/DrugLevelChart").then((mod) => ({
|
||||
default: mod.DrugLevelChart,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
/**
|
||||
* Map a `CHART_RANGE_PRESETS` `points` value to the drug-level chart's
|
||||
* window length in hours. `points: 0` (the "All" preset) maps to 365
|
||||
* days — past that point the unit-less curve flattens to invisibility,
|
||||
* so a year of history is the practical ceiling.
|
||||
*/
|
||||
const HOURS_PER_DAY = 24;
|
||||
function rangePointsToHours(points: number): number {
|
||||
if (points === 0) return 365 * HOURS_PER_DAY;
|
||||
return points * HOURS_PER_DAY;
|
||||
}
|
||||
|
||||
type Glp1ChartTab = "level" | "weight";
|
||||
|
||||
interface DoseHistoryEntry {
|
||||
value: number;
|
||||
unit: string;
|
||||
@@ -166,6 +189,14 @@ export function Glp1Tile() {
|
||||
const dateWithWeekday = useDateWithWeekday();
|
||||
const deltaDisplay = useDeltaDisplay();
|
||||
|
||||
// v1.4.27 B1 — the chart pane carries two views (drug-level / weight)
|
||||
// and a 7d / 30d / 90d / All range strip above. Drug-level is the
|
||||
// default because it's the more informative pane the maintainer
|
||||
// asked for; the weight pane stays one tap away. Both states live
|
||||
// on the tile so a parent re-render doesn't reset the user's pick.
|
||||
const [activeTab, setActiveTab] = useState<Glp1ChartTab>("level");
|
||||
const [rangePoints, setRangePoints] = useState<number>(30);
|
||||
|
||||
const { data, isPending } = useQuery({
|
||||
queryKey: queryKeys.dashboardGlp1(),
|
||||
enabled: isAuthenticated,
|
||||
@@ -287,12 +318,84 @@ export function Glp1Tile() {
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{/* Compact weight chart with the vertical injection-day markers
|
||||
the v1.4.25 W6 chart-extension adds. Skipped when we have no
|
||||
weight readings since the GLP-1 was started — the empty
|
||||
chart would draw nothing useful. */}
|
||||
{med.weightSeries.length > 0 && (
|
||||
<div data-slot="glp1-tile-chart">
|
||||
{/* v1.4.27 B1 — chart pane carrying a two-tab segmented control
|
||||
(Drug-Level default / Weight) plus a 7d / 30d / 90d / All
|
||||
range strip above. Drug-Level reads the unit-less curve from
|
||||
`<DrugLevelChart compact …>` (Research Mode gating is owned
|
||||
by the chart itself); Weight retains the v1.4.25 W6 mini
|
||||
chart with vertical injection markers. */}
|
||||
<div data-slot="glp1-tile-chart" className="space-y-2 pt-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div
|
||||
data-slot="glp1-tile-tabs"
|
||||
role="tablist"
|
||||
aria-label={t("dashboard.glp1.tabsAria")}
|
||||
className="bg-muted/40 inline-flex rounded-md p-0.5"
|
||||
>
|
||||
<TabButton
|
||||
active={activeTab === "level"}
|
||||
onClick={() => setActiveTab("level")}
|
||||
label={t("dashboard.glp1.tabLevel")}
|
||||
slot="glp1-tile-tab-level"
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === "weight"}
|
||||
onClick={() => setActiveTab("weight")}
|
||||
label={t("dashboard.glp1.tabWeight")}
|
||||
slot="glp1-tile-tab-weight"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
data-slot="glp1-tile-range-strip"
|
||||
role="radiogroup"
|
||||
aria-label={t("dashboard.glp1.rangeStripLabel")}
|
||||
className="inline-flex items-center gap-1 text-[11px]"
|
||||
>
|
||||
{CHART_RANGE_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.points}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={rangePoints === preset.points}
|
||||
data-slot="glp1-tile-range-button"
|
||||
data-points={preset.points}
|
||||
data-active={rangePoints === preset.points ? "true" : "false"}
|
||||
title={t(preset.titleKey)}
|
||||
onClick={() => setRangePoints(preset.points)}
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 font-medium tabular-nums",
|
||||
"hover:text-foreground focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
|
||||
rangePoints === preset.points
|
||||
? "bg-card text-foreground shadow-sm"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t(preset.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === "level" && med.medicationId ? (
|
||||
<DrugLevelChart
|
||||
compact
|
||||
medication={{
|
||||
id: med.medicationId,
|
||||
name: med.name,
|
||||
dose: med.currentDose
|
||||
? `${med.currentDose.value}${med.currentDose.unit}`
|
||||
: "",
|
||||
}}
|
||||
windowHoursBefore={rangePointsToHours(rangePoints)}
|
||||
/>
|
||||
) : activeTab === "level" ? (
|
||||
<p
|
||||
data-slot="glp1-tile-level-unavailable"
|
||||
className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs"
|
||||
>
|
||||
{t("dashboard.glp1.levelUnavailable")}
|
||||
</p>
|
||||
) : med.weightSeries.length > 0 ? (
|
||||
<HealthChart
|
||||
mini
|
||||
types={["WEIGHT"]}
|
||||
@@ -302,12 +405,57 @@ export function Glp1Tile() {
|
||||
verticalMarkers={med.injectionDates.map((date) => ({ date }))}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<p
|
||||
data-slot="glp1-tile-weight-unavailable"
|
||||
className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs"
|
||||
>
|
||||
{t("dashboard.glp1.weightUnavailable")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.27 B1 — small segmented-control button used by the tile's
|
||||
* tab strip. Carved into its own component so the active/inactive
|
||||
* styling lives in one place and the test can pin the data-slot
|
||||
* markers per tab.
|
||||
*/
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
slot,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
slot: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
data-slot={slot}
|
||||
data-active={active ? "true" : "false"}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 text-xs font-medium",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
|
||||
active
|
||||
? "bg-card text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Carve the delta caption pill into its own component so the
|
||||
* loss/gain/flat tone selection is colocated with the rendering. The
|
||||
|
||||
@@ -70,7 +70,6 @@ const HOURS_PER_DAY = 24;
|
||||
* for a research-view chart that emphasises the sawtooth shape. */
|
||||
const WINDOW_HOURS_BEFORE = 21 * HOURS_PER_DAY;
|
||||
const WINDOW_HOURS_AFTER = 0;
|
||||
const SAMPLE_STEP_HOURS = 6;
|
||||
|
||||
interface IntakeEvent {
|
||||
id: string;
|
||||
@@ -112,9 +111,34 @@ export interface DrugLevelChartProps {
|
||||
* cost low.
|
||||
*/
|
||||
asOf?: Date;
|
||||
/**
|
||||
* v1.4.27 B1 — compact rendering for tile-internal mounts (the GLP-1
|
||||
* dashboard tile carves out a mini pane for this chart). When set:
|
||||
* - the outer `<section class="bg-card …">` wrapper drops so the
|
||||
* parent tile owns the card surface (no card-inside-a-card).
|
||||
* - the chart body shrinks (160 px instead of 240 px) to match
|
||||
* mini-mode density of the other tile-internal charts.
|
||||
* - the header + disclaimer collapse to a single inline caption.
|
||||
*/
|
||||
compact?: boolean;
|
||||
/**
|
||||
* v1.4.27 B1 — window length override (in hours of history before
|
||||
* `asOf`). Defaults to 21 × 24 = 504 h (three weekly cycles), the
|
||||
* shape the standalone surface ships. The dashboard mini-chart
|
||||
* exposes 7d / 30d / 90d / All via a range strip and threads the
|
||||
* mapped window length through this prop. The sample step scales
|
||||
* with the window so an "All"-mode chart doesn't paint thousands of
|
||||
* points: 6 h / 12 h / 24 h / 48 h for ≤ 21 d / ≤ 60 d / ≤ 180 d / >.
|
||||
*/
|
||||
windowHoursBefore?: number;
|
||||
}
|
||||
|
||||
export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) {
|
||||
export function DrugLevelChart({
|
||||
medication,
|
||||
asOf,
|
||||
compact = false,
|
||||
windowHoursBefore,
|
||||
}: DrugLevelChartProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Resolve the catalog key via the shared helper. v1.4.25 W21 Fix-N
|
||||
@@ -194,27 +218,46 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) {
|
||||
const isLoading = rmLoading || (gateOpen && (detailsLoading || intakeLoading));
|
||||
const hasDoses = doses.length > 0;
|
||||
const animationsEnabled = !prefersReducedMotion();
|
||||
// Resolve the window length once, then derive a sample step that
|
||||
// keeps the AreaChart inside a sensible point count even on the
|
||||
// "All" branch of the dashboard's range strip.
|
||||
const resolvedWindowHours = windowHoursBefore ?? WINDOW_HOURS_BEFORE;
|
||||
const resolvedStepHours = pickSampleStepHours(resolvedWindowHours);
|
||||
const chartBodyHeightPx = compact ? 160 : 240;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="drug-level-chart-title"
|
||||
className="bg-card border-border rounded-xl border p-4 md:p-6"
|
||||
data-slot="drug-level-chart"
|
||||
>
|
||||
<header className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Activity className="text-dracula-purple h-4 w-4 shrink-0" />
|
||||
<h3
|
||||
id="drug-level-chart-title"
|
||||
className="text-sm font-semibold"
|
||||
>
|
||||
// Compact mode mounts the chart inside a host card (the GLP-1 tile)
|
||||
// and skips the outer `<section>` wrapper so the user doesn't see a
|
||||
// card painted inside a card. The disclaimer also collapses to a
|
||||
// single muted line to keep the tile's vertical footprint tight.
|
||||
const wrapperClass = compact
|
||||
? "space-y-2"
|
||||
: "bg-card border-border rounded-xl border p-4 md:p-6";
|
||||
const wrapperProps = {
|
||||
"aria-labelledby": "drug-level-chart-title",
|
||||
className: wrapperClass,
|
||||
"data-slot": "drug-level-chart",
|
||||
"data-compact": compact ? "true" : undefined,
|
||||
} as const;
|
||||
const body = (
|
||||
<>
|
||||
{!compact && (
|
||||
<header className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Activity className="text-dracula-purple h-4 w-4 shrink-0" />
|
||||
<h3 id="drug-level-chart-title" className="text-sm font-semibold">
|
||||
{t("medications.researchMode.chart.title")}
|
||||
</h3>
|
||||
{drugId && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
· {t(`medications.glp1.drug.${drugId}.name`)}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
)}
|
||||
{compact && (
|
||||
<h3 id="drug-level-chart-title" className="sr-only">
|
||||
{t("medications.researchMode.chart.title")}
|
||||
</h3>
|
||||
{drugId && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
· {t(`medications.glp1.drug.${drugId}.name`)}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* Decision tree:
|
||||
1. drug not in catalog → gated-unknown-drug placeholder
|
||||
@@ -232,7 +275,11 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) {
|
||||
/>
|
||||
) : isLoading ? (
|
||||
<div
|
||||
className="flex h-[220px] items-center justify-center"
|
||||
className={
|
||||
compact
|
||||
? "flex h-[160px] items-center justify-center"
|
||||
: "flex h-[220px] items-center justify-center"
|
||||
}
|
||||
data-slot="drug-level-chart-loading"
|
||||
>
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin" />
|
||||
@@ -246,19 +293,43 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) {
|
||||
asOf={now}
|
||||
animationsEnabled={animationsEnabled}
|
||||
axisLabel={t("medications.researchMode.chart.axisLabel")}
|
||||
windowHoursBefore={resolvedWindowHours}
|
||||
stepHours={resolvedStepHours}
|
||||
heightPx={chartBodyHeightPx}
|
||||
/>
|
||||
)}
|
||||
|
||||
{gateOpen && hasDoses && (
|
||||
<p
|
||||
className="text-muted-foreground mt-2 text-xs italic"
|
||||
className={
|
||||
compact
|
||||
? "text-muted-foreground text-[10px] leading-snug italic"
|
||||
: "text-muted-foreground mt-2 text-xs italic"
|
||||
}
|
||||
data-slot="drug-level-chart-disclaimer"
|
||||
>
|
||||
{t("medications.researchMode.chart.estimateNote")}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
return compact ? <div {...wrapperProps}>{body}</div> : <section {...wrapperProps}>{body}</section>;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.27 B1 — pick a sample step in hours appropriate for the chosen
|
||||
* window length. The default 6 h is fine at ≤ 21 days (≤ 84 samples);
|
||||
* "All"-mode windows need a coarser grid so the AreaChart stays under
|
||||
* a few hundred points. The chart is qualitative (research §2.3) so a
|
||||
* coarse step does not change the rising/peak/fading shape.
|
||||
*/
|
||||
function pickSampleStepHours(windowHoursBefore: number): number {
|
||||
const days = windowHoursBefore / 24;
|
||||
if (days <= 21) return 6;
|
||||
if (days <= 60) return 12;
|
||||
if (days <= 180) return 24;
|
||||
return 48;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
@@ -354,6 +425,9 @@ function ChartBody({
|
||||
asOf,
|
||||
animationsEnabled,
|
||||
axisLabel,
|
||||
windowHoursBefore,
|
||||
stepHours,
|
||||
heightPx,
|
||||
}: {
|
||||
drug: Glp1DrugId;
|
||||
doses: readonly DoseEvent[];
|
||||
@@ -366,20 +440,27 @@ function ChartBody({
|
||||
* users and SSR snapshots both surface the framing.
|
||||
*/
|
||||
axisLabel: string;
|
||||
/** Window length in hours; the chart's x-axis domain is `[-days, 0]`. */
|
||||
windowHoursBefore: number;
|
||||
/** Sample step in hours; scales with the window length. */
|
||||
stepHours: number;
|
||||
/** Chart-area height in CSS pixels; compact mode passes 160. */
|
||||
heightPx: number;
|
||||
}) {
|
||||
const windowDays = windowHoursBefore / HOURS_PER_DAY;
|
||||
const samples = useMemo(
|
||||
() =>
|
||||
computeOneCompartment(drug, doses, asOf, {
|
||||
windowHoursBefore: WINDOW_HOURS_BEFORE,
|
||||
windowHoursBefore,
|
||||
windowHoursAfter: WINDOW_HOURS_AFTER,
|
||||
stepHours: SAMPLE_STEP_HOURS,
|
||||
stepHours,
|
||||
}),
|
||||
[drug, doses, asOf],
|
||||
[drug, doses, asOf, windowHoursBefore, stepHours],
|
||||
);
|
||||
|
||||
// Map samples to chart points. x-axis is "days since now" — negative
|
||||
// values for the past 21 days, with 0 = now. Recharts uses the raw
|
||||
// domain so categorical day labels don't crowd a 21-day window.
|
||||
// values for the past window, with 0 = now. Recharts uses the raw
|
||||
// domain so categorical day labels don't crowd the visible range.
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
samples.map((s) => ({
|
||||
@@ -389,6 +470,16 @@ function ChartBody({
|
||||
[samples],
|
||||
);
|
||||
|
||||
// Pick four x-axis ticks evenly across the window so the strip
|
||||
// reads as quarter / midpoint / three-quarter / now regardless of
|
||||
// whether the user picked 7d, 30d, 90d, or All.
|
||||
const ticks = useMemo(() => {
|
||||
const quarter = -Math.round(windowDays * 0.75);
|
||||
const midpoint = -Math.round(windowDays * 0.5);
|
||||
const lastQuarter = -Math.round(windowDays * 0.25);
|
||||
return [-Math.round(windowDays), quarter, midpoint, lastQuarter, 0];
|
||||
}, [windowDays]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p
|
||||
@@ -398,7 +489,8 @@ function ChartBody({
|
||||
{axisLabel}
|
||||
</p>
|
||||
<div
|
||||
className="h-[240px] touch-pan-y"
|
||||
className="touch-pan-y"
|
||||
style={{ height: `${heightPx}px` }}
|
||||
data-slot="drug-level-chart-area"
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
@@ -434,8 +526,8 @@ function ChartBody({
|
||||
<XAxis
|
||||
dataKey="dayOffset"
|
||||
type="number"
|
||||
domain={[-21, 0]}
|
||||
ticks={[-21, -14, -7, 0]}
|
||||
domain={[-windowDays, 0]}
|
||||
ticks={ticks}
|
||||
tickFormatter={(v) => (v === 0 ? "0" : `${v}d`)}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
|
||||
Reference in new issue
Block a user