style: apply prettier across the tree

One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 22:20:31 +02:00
1 parent b3fea2ef59
commit 1f4ba5939d
1000 files changed
+12130 -8385

No files matched your search

@@ -68,9 +68,7 @@ vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
const { AddInventoryDialog } = await import(
"../sections/inventory-section"
);
const { AddInventoryDialog } = await import("../sections/inventory-section");
function render(node: React.ReactNode, locale: "en" | "de" = "de"): string {
return renderToStaticMarkup(
@@ -178,7 +176,7 @@ describe("<AddInventoryDialog>", () => {
it("bounds the quantity to 1000 units (dose mode scales the max down)", () => {
expect(src).toContain("units >= 1 && units <= 1000");
expect(src).toContain('Math.floor(1000 / unitsPerDose)');
expect(src).toContain("Math.floor(1000 / unitsPerDose)");
});
it("maps the delivery form onto the register default (PEN for INJECTION, BLISTER for ORAL, OTHER otherwise)", () => {
@@ -45,7 +45,9 @@ function render(node: React.ReactNode, client?: QueryClient) {
) : (
node
);
return renderToStaticMarkup(<I18nProvider initialLocale="en">{tree}</I18nProvider>);
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{tree}</I18nProvider>,
);
}
describe("medication card-parts — shared presentational components", () => {
@@ -170,7 +172,10 @@ describe("medication card-parts — shared presentational components", () => {
it("intake actions row carries exactly the take + skip buttons", () => {
const html = render(
<MedicationIntakeActions intakeLoading={null} onRecordIntake={() => {}} />,
<MedicationIntakeActions
intakeLoading={null}
onRecordIntake={() => {}}
/>,
);
expect(html).toContain("lucide-check");
expect(html).toContain("lucide-skip-forward");
@@ -253,7 +258,11 @@ describe("medication cycle status — open-cycle line", () => {
// 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)]) {
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 }}
@@ -429,7 +438,13 @@ describe("medication card body — shared shell + dose-state presentation", () =
doseStatus={doseStatus}
nextLine="Tomorrow, 08:00"
lastLine="Today, 07:30"
compliance={{ rate7: 90, rate30: 88, streak: 0, shortDays: 7, longDays: 30 }}
compliance={{
rate7: 90,
rate30: 88,
streak: 0,
shortDays: 7,
longDays: 30,
}}
currentCycle={null}
intakeLoading={null}
onRecordIntake={() => {}}
@@ -23,10 +23,7 @@
*/
import { describe, it, expect } from "vitest";
import {
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import { medicationDependentKeys, queryKeys } from "@/lib/query-keys";
function asStrings(keys: readonly unknown[]): string[] {
return keys.map((k) => JSON.stringify(k));
@@ -41,7 +38,7 @@ function isUnderPrefix(key: readonly unknown[], prefix: readonly unknown[]) {
}
describe("medication-detail-page query-key cascade (D-3 §9)", () => {
it("§9.1 header reads sit under the `[\"medications\"]` prefix so the bundle catches them", () => {
it('§9.1 header reads sit under the `["medications"]` prefix so the bundle catches them', () => {
const detail = queryKeys.medicationDetail("med-1");
expect(isUnderPrefix(detail, queryKeys.medications())).toBe(true);
});
@@ -68,7 +65,7 @@ describe("medication-detail-page query-key cascade (D-3 §9)", () => {
expect(phaseKey).toEqual(["phase-config", "med-1"]);
});
it("§9.5 intake-history list keys ride under `[\"medications\", id, \"intake\", \"list\"]`", () => {
it('§9.5 intake-history list keys ride under `["medications", id, "intake", "list"]`', () => {
const listKey = queryKeys.medicationIntakeList("med-1", {
sortBy: "takenAt",
sortDir: "desc",
@@ -86,7 +83,7 @@ describe("medication-detail-page query-key cascade (D-3 §9)", () => {
expect(isUnderPrefix(listKey, queryKeys.medications())).toBe(true);
});
it("§9.7 api-endpoint per-medication slot rides under the `[\"medications\"]` prefix", () => {
it('§9.7 api-endpoint per-medication slot rides under the `["medications"]` prefix', () => {
// The `<ApiTokensRow>` uses a per-medication key
// `["medications", id, "api-endpoint"]` so the bundle catches it
// alongside every other medication-scoped tile.
@@ -22,7 +22,13 @@ function slotRow(
status: LedgerRow["status"],
intake?: LedgerRow["intake"],
): LedgerRow {
return { kind: "slot", at, timeOfDay: "07:00", status, intake: intake ?? null };
return {
kind: "slot",
at,
timeOfDay: "07:00",
status,
intake: intake ?? null,
};
}
describe("complianceFromLedger", () => {
@@ -156,9 +162,7 @@ describe("applyOptimisticSlotMark", () => {
"2026-06-01T05:00:00.000Z",
"taken",
);
const marked = next.rows.find(
(r) => r.at === "2026-06-01T05:00:00.000Z",
)!;
const marked = next.rows.find((r) => r.at === "2026-06-01T05:00:00.000Z")!;
expect(marked.status).toBe("taken_on_time");
expect(marked.intake?.takenAt).toBe("2026-06-01T05:00:00.000Z");
expect(marked.intake?.skipped).toBe(false);
@@ -171,9 +175,7 @@ describe("applyOptimisticSlotMark", () => {
"2026-06-01T05:00:00.000Z",
"skipped",
);
const marked = next.rows.find(
(r) => r.at === "2026-06-01T05:00:00.000Z",
)!;
const marked = next.rows.find((r) => r.at === "2026-06-01T05:00:00.000Z")!;
expect(marked.status).toBe("skipped");
expect(marked.intake?.skipped).toBe(true);
// skip excluded → denominator drops to 0 → null rate.
@@ -30,10 +30,7 @@ vi.mock("@tanstack/react-query", () => ({
},
}));
import {
DoseStrengthCurve,
buildCurvePoints,
} from "../dose-strength-curve";
import { DoseStrengthCurve, buildCurvePoints } from "../dose-strength-curve";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
@@ -125,9 +122,7 @@ describe("<DoseStrengthCurve>", () => {
it("renders the empty state with no current dose for zero rows", () => {
setQueryResult(KEY, { doseChanges: [] });
const html = render(
<DoseStrengthCurve medicationId="med-1" asOf={ASOF} />,
);
const html = render(<DoseStrengthCurve medicationId="med-1" asOf={ASOF} />);
expect(html).toContain('data-slot="dose-strength-curve-empty"');
expect(html).toContain("No dose recorded yet.");
@@ -147,9 +142,7 @@ describe("<DoseStrengthCurve>", () => {
],
});
const html = render(
<DoseStrengthCurve medicationId="med-1" asOf={ASOF} />,
);
const html = render(<DoseStrengthCurve medicationId="med-1" asOf={ASOF} />);
expect(html).toContain('data-slot="dose-strength-curve-empty"');
expect(html).toContain("Current dose: 5 mg");
@@ -174,9 +167,7 @@ describe("<DoseStrengthCurve>", () => {
],
});
const html = render(
<DoseStrengthCurve medicationId="med-1" asOf={ASOF} />,
);
const html = render(<DoseStrengthCurve medicationId="med-1" asOf={ASOF} />);
expect(html).toContain('data-slot="dose-strength-curve-area"');
expect(html).toContain('data-slot="dose-strength-curve-caption"');
@@ -56,10 +56,9 @@ const pkCalls: Array<{
}> = [];
vi.mock("@/lib/medications/glp1-pk", async () => {
const actual =
await vi.importActual<typeof import("@/lib/medications/glp1-pk")>(
"@/lib/medications/glp1-pk",
);
const actual = await vi.importActual<
typeof import("@/lib/medications/glp1-pk")
>("@/lib/medications/glp1-pk");
return {
...actual,
computeOneCompartment: (
@@ -102,7 +102,12 @@ describe("IntakeEditDialog guardrails (P0-4)", () => {
const html = renderToStaticMarkup(
<IntakeEditDialog
medicationId="med-1"
event={{ id: "evt-1", takenAt: taken, skipped: false, scheduledFor: slot }}
event={{
id: "evt-1",
takenAt: taken,
skipped: false,
scheduledFor: slot,
}}
onClose={() => {}}
/>,
);
@@ -120,7 +125,12 @@ describe("IntakeEditDialog guardrails (P0-4)", () => {
const html = renderToStaticMarkup(
<IntakeEditDialog
medicationId="med-1"
event={{ id: "evt-1", takenAt: taken, skipped: false, scheduledFor: slot }}
event={{
id: "evt-1",
takenAt: taken,
skipped: false,
scheduledFor: slot,
}}
onClose={() => {}}
/>,
);
@@ -86,10 +86,7 @@ describe("<IntakeHistoryListV2> — F-1 C-2 row actions", () => {
it("does NOT render the kebab when no callbacks are passed (back-compat)", () => {
const client = makeClient();
seed(client, "med-1", [ROW]);
const html = render(
<IntakeHistoryListV2 medicationId="med-1" />,
client,
);
const html = render(<IntakeHistoryListV2 medicationId="med-1" />, client);
expect(html).not.toContain("intake-history-row-kebab");
});
@@ -97,10 +94,7 @@ describe("<IntakeHistoryListV2> — F-1 C-2 row actions", () => {
const client = makeClient();
seed(client, "med-1", [ROW]);
const html = render(
<IntakeHistoryListV2
medicationId="med-1"
onEditIntake={() => {}}
/>,
<IntakeHistoryListV2 medicationId="med-1" onEditIntake={() => {}} />,
client,
);
expect(html).toContain("intake-history-row-kebab");
@@ -110,10 +104,7 @@ describe("<IntakeHistoryListV2> — F-1 C-2 row actions", () => {
const client = makeClient();
seed(client, "med-1", [ROW]);
const html = render(
<IntakeHistoryListV2
medicationId="med-1"
onDeleteIntake={() => {}}
/>,
<IntakeHistoryListV2 medicationId="med-1" onDeleteIntake={() => {}} />,
client,
);
expect(html).toContain("intake-history-row-kebab");
@@ -122,10 +113,7 @@ describe("<IntakeHistoryListV2> — F-1 C-2 row actions", () => {
it("does NOT render the selection column when selection prop is absent", () => {
const client = makeClient();
seed(client, "med-1", [ROW]);
const html = render(
<IntakeHistoryListV2 medicationId="med-1" />,
client,
);
const html = render(<IntakeHistoryListV2 medicationId="med-1" />, client);
expect(html).not.toContain("intake-history-row-select");
});
@@ -30,8 +30,13 @@ vi.mock("@/components/ui/dialog", () => {
<div>{children}</div>
);
return {
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-slot="mock-dialog">{children}</div> : null,
Dialog: ({
open,
children,
}: {
open: boolean;
children: React.ReactNode;
}) => (open ? <div data-slot="mock-dialog">{children}</div> : null),
DialogContent: Pass,
DialogDescription: Pass,
DialogFooter: Pass,
@@ -74,7 +79,9 @@ describe("<LogInjectionSiteDialog>", () => {
});
it("awaits onConfirm behind a submitting guard and re-enables in finally", () => {
expect(src).toContain("const [submitting, setSubmitting] = useState(false)");
expect(src).toContain(
"const [submitting, setSubmitting] = useState(false)",
);
expect(src).toMatch(
/async function handleConfirm\(\) \{[\s\S]*setSubmitting\(true\)[\s\S]*await onConfirm\(selected\)[\s\S]*finally \{[\s\S]*setSubmitting\(false\)/,
);
@@ -75,9 +75,7 @@ describe("<MedicationCardHeader>", () => {
}
/>,
);
expect(html).toContain(
'data-slot="medication-card-header-state-badges"',
);
expect(html).toContain('data-slot="medication-card-header-state-badges"');
// Both badges land inside the state row, not adjacent to the
// category badge. Verifying via document order: the state-badges
// wrapper must paint AFTER the category badge.
@@ -185,9 +185,7 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
expect(html).toContain('aria-label="More options"');
expect(html).not.toContain("lucide-chevron-right");
// The card body itself navigates to the detail page.
expect(html).toContain(
'data-slot="medication-card-header-link"',
);
expect(html).toContain('data-slot="medication-card-header-link"');
expect(html).toContain(`href="${href}"`);
}
});
@@ -90,7 +90,9 @@ const pastWindow = {
dose: null,
};
function med(partial: Partial<TableMedication> & { id: string; name: string }): TableMedication {
function med(
partial: Partial<TableMedication> & { id: string; name: string },
): TableMedication {
return {
dose: "5 mg",
active: true,
@@ -442,7 +444,12 @@ describe("nextSortState — tri-state cycle", () => {
});
describe("sortMedicationRows — null-last semantics", () => {
const a = med({ id: "a", name: "Alpha", nextDueAt: null, stockDosesRemaining: null });
const a = med({
id: "a",
name: "Alpha",
nextDueAt: null,
stockDosesRemaining: null,
});
const b = med({
id: "b",
name: "Beta",
@@ -457,9 +464,11 @@ describe("sortMedicationRows — null-last semantics", () => {
});
it("returns the input order untouched for a null sort", () => {
expect(
sortMedicationRows([b, a, c], null).map((m) => m.id),
).toEqual(["b", "a", "c"]);
expect(sortMedicationRows([b, a, c], null).map((m) => m.id)).toEqual([
"b",
"a",
"c",
]);
});
it("sorts by next due ascending, rows without a due date last", () => {
@@ -26,9 +26,7 @@ function schedule(partial: Partial<RunwaySchedule>): RunwaySchedule {
describe("estimateDailyDoseCount", () => {
it("counts a plain daily schedule as its times-of-day per day", () => {
expect(
estimateDailyDoseCount([
schedule({ timesOfDay: ["08:00", "20:00"] }),
]),
estimateDailyDoseCount([schedule({ timesOfDay: ["08:00", "20:00"] })]),
).toBe(2);
});
@@ -88,9 +86,9 @@ describe("estimateRunwayDays", () => {
});
it("floors to whole days", () => {
expect(estimateRunwayDays(5, [schedule({ timesOfDay: ["08:00", "20:00"] })])).toBe(
2,
);
expect(
estimateRunwayDays(5, [schedule({ timesOfDay: ["08:00", "20:00"] })]),
).toBe(2);
});
it("returns null when no supply remains", () => {
@@ -159,26 +157,42 @@ describe("lowStockTriggerDays — v1.17.0", () => {
it("keeps a daily med with no lead at the bare floor (never shrinks)", () => {
expect(
lowStockTriggerDays({ lowStockRunwayDays: 7, leadDays: 0, schedules: daily }),
lowStockTriggerDays({
lowStockRunwayDays: 7,
leadDays: 0,
schedules: daily,
}),
).toBe(7);
});
it("widens a weekly med to cover the reorder lead PLUS one dose-interval", () => {
// max(7, 10 lead + 7 interval) = 17 → fires ~10 days before the last dose.
expect(
lowStockTriggerDays({ lowStockRunwayDays: 7, leadDays: 10, schedules: weekly }),
lowStockTriggerDays({
lowStockRunwayDays: 7,
leadDays: 10,
schedules: weekly,
}),
).toBe(17);
});
it("never drops below the user floor even when lead + cadence are small", () => {
expect(
lowStockTriggerDays({ lowStockRunwayDays: 14, leadDays: 2, schedules: daily }),
lowStockTriggerDays({
lowStockRunwayDays: 14,
leadDays: 2,
schedules: daily,
}),
).toBe(14);
});
it("falls back to the bare floor for a schedule-less medication", () => {
expect(
lowStockTriggerDays({ lowStockRunwayDays: 7, leadDays: 10, schedules: [] }),
lowStockTriggerDays({
lowStockRunwayDays: 7,
leadDays: 10,
schedules: [],
}),
).toBe(7);
});
});
@@ -188,19 +202,31 @@ describe("classifyLowStockState — v1.17.0", () => {
it("returns null when comfortably above the trigger", () => {
expect(
classifyLowStockState({ runwayDays: 20, triggerDays: 17, schedules: weekly }),
classifyLowStockState({
runwayDays: 20,
triggerDays: 17,
schedules: weekly,
}),
).toBeNull();
});
it("flags running_low within the trigger but above one cadence interval", () => {
expect(
classifyLowStockState({ runwayDays: 14, triggerDays: 17, schedules: weekly }),
classifyLowStockState({
runwayDays: 14,
triggerDays: 17,
schedules: weekly,
}),
).toBe("running_low");
});
it("flags last_dose at one cadence interval", () => {
expect(
classifyLowStockState({ runwayDays: 7, triggerDays: 17, schedules: weekly }),
classifyLowStockState({
runwayDays: 7,
triggerDays: 17,
schedules: weekly,
}),
).toBe("last_dose");
});
});
@@ -386,7 +386,13 @@ describe("runTakeAllDue — per-medication loop with failure isolation", () => {
const slotA = new Date(Date.now() - 60 * 60 * 1000);
const slotB = new Date(Date.now() - 30 * 60 * 1000);
const dueTwo: DueMedication[] = [
{ id: "med-a", name: "A", dose: "10 mg", window: null, scheduledFor: slotA },
{
id: "med-a",
name: "A",
dose: "10 mg",
window: null,
scheduledFor: slotA,
},
{ id: "med-b", name: "B", dose: "5 mg", window: null, scheduledFor: slotB },
];
@@ -489,10 +495,7 @@ describe("runTakeAllDue — per-medication loop with failure isolation", () => {
// The bundle includes the dashboard snapshot key (v1.16.11), so a
// batch take refreshes the hero dose tally too. Pin the source so a
// refactor to a hand-rolled key list cannot slip through.
const src = readFileSync(
resolve(__dirname, "../take-all-due.ts"),
"utf8",
);
const src = readFileSync(resolve(__dirname, "../take-all-due.ts"), "utf8");
expect(src).toContain(
"await invalidateKeys(queryClient, medicationDependentKeys)",
);
@@ -64,7 +64,12 @@ describe("buildTitrationSteps", () => {
[
change("a", "2026-04-01T00:00:00Z", 2.5),
change("bad", "not-a-date", 5),
{ id: "nan", effectiveFrom: "2026-05-01T00:00:00Z", doseValue: NaN, doseUnit: "mg" },
{
id: "nan",
effectiveFrom: "2026-05-01T00:00:00Z",
doseValue: NaN,
doseUnit: "mg",
},
],
ASOF,
);
@@ -181,7 +181,9 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ data: { id: "evt-am" } }),
text: vi.fn().mockResolvedValue(JSON.stringify({ data: { id: "evt-am" } })),
text: vi
.fn()
.mockResolvedValue(JSON.stringify({ data: { id: "evt-am" } })),
});
vi.stubGlobal("fetch", fetchMock);
@@ -319,7 +321,10 @@ describe("runLogIntake — manual backdated intake from the Add choice", () => {
expect(url).toBe("/api/medications/med-1/intake");
const body = JSON.parse((init as RequestInit).body as string);
// Backdated instant is carried verbatim; no slot → no scheduledFor.
expect(body).toEqual({ skipped: false, takenAt: "2026-06-01T08:30:00.000Z" });
expect(body).toEqual({
skipped: false,
takenAt: "2026-06-01T08:30:00.000Z",
});
expect(body).not.toHaveProperty("scheduledFor");
expect(queryClient.invalidateQueries).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith(
@@ -381,7 +386,10 @@ describe("runLogIntake — manual backdated intake from the Add choice", () => {
});
it("surfaces the failure toast and returns false on a non-ok POST", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, json: vi.fn() }));
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({ ok: false, json: vi.fn() }),
);
const queryClient = fakeQueryClient();
const ok = await runLogIntake({
@@ -134,8 +134,12 @@ function parseHm(value: string): { hour: number; minute: number } | null {
export function resolveDisplayedSlotInstant(
input: ResolveDisplayedSlotInstantInput,
): Date | null {
const { currentWindowStatus, nextDueAt, now, timeZone = "Europe/Berlin" } =
input;
const {
currentWindowStatus,
nextDueAt,
now,
timeZone = "Europe/Berlin",
} = input;
// The card surfaces the current/overdue dose band — record that band's
// slot on today's calendar day. The matched band's `timeOfDay` is the
@@ -148,7 +148,10 @@ export function MedicationComplianceBars({
expiring) never changes the card height and the action row below
stays on one baseline across a grid row. The skeleton and error
fallbacks reserve the same slot through the same component. */}
<ComplianceMetaRow streak={streak} lowStockRunwayDays={lowStockRunwayDays} />
<ComplianceMetaRow
streak={streak}
lowStockRunwayDays={lowStockRunwayDays}
/>
</div>
);
}
@@ -505,25 +505,25 @@ export function MedicationDetailTabs({
</MedicationDetailSection>
{!asNeeded && (
<MedicationDetailSection
titleId="medication-uebersicht-compliance-heading"
title={t("medications.detail.uebersicht.complianceTitle")}
dataSlot="medication-uebersicht-compliance"
>
{compliance ? (
<MedicationComplianceBars
rate7={rate7}
rate30={rate30}
streak={streak}
shortDays={shortDays}
longDays={longDays}
/>
) : (
<p className="text-muted-foreground text-sm">
{t("medications.detail.uebersicht.complianceEmpty")}
</p>
)}
</MedicationDetailSection>
<MedicationDetailSection
titleId="medication-uebersicht-compliance-heading"
title={t("medications.detail.uebersicht.complianceTitle")}
dataSlot="medication-uebersicht-compliance"
>
{compliance ? (
<MedicationComplianceBars
rate7={rate7}
rate30={rate30}
streak={streak}
shortDays={shortDays}
longDays={longDays}
/>
) : (
<p className="text-muted-foreground text-sm">
{t("medications.detail.uebersicht.complianceEmpty")}
</p>
)}
</MedicationDetailSection>
)}
</TabsContent>
@@ -74,7 +74,9 @@ export function estimateRunwayDays(
* weekly injection, ≈30 for a monthly RRULE. Returns `null` for a
* schedule with no derivable consumption (as-needed / empty).
*/
export function cadenceIntervalDays(schedules: RunwaySchedule[]): number | null {
export function cadenceIntervalDays(
schedules: RunwaySchedule[],
): number | null {
const perDay = estimateDailyDoseCount(schedules);
if (perDay <= 0) return null;
return Math.ceil(1 / perDay);
@@ -151,6 +153,7 @@ export function supplyRunwayDates(input: {
}): { runsOutOn: Date; reorderBy: Date } {
const runsOutOn = addDaysUtc(input.today, Math.max(0, input.runwayDays));
const reorderRaw = addDaysUtc(runsOutOn, -input.leadDays);
const reorderBy = reorderRaw < input.today ? new Date(input.today) : reorderRaw;
const reorderBy =
reorderRaw < input.today ? new Date(input.today) : reorderRaw;
return { runsOutOn, reorderBy };
}
@@ -48,7 +48,8 @@ export interface LedgerRow {
* (+) / before (−) the slot". Sub-minute deltas collapse to "±0 min".
*/
export function formatSlotDelta(takenAtIso: string, slotAtIso: string): string {
const deltaMs = new Date(takenAtIso).getTime() - new Date(slotAtIso).getTime();
const deltaMs =
new Date(takenAtIso).getTime() - new Date(slotAtIso).getTime();
const sign = deltaMs < 0 ? "-" : "+";
const totalMinutes = Math.round(Math.abs(deltaMs) / 60_000);
if (totalMinutes < 60) return `${sign}${totalMinutes} min`;
@@ -102,7 +103,9 @@ export function complianceFromLedger(rows: LedgerRow[]): LedgerCompliance {
const taken = takenOnTime + takenLate;
const denominator = taken + missed;
const rate =
denominator > 0 ? Math.min(100, Math.round((taken / denominator) * 100)) : null;
denominator > 0
? Math.min(100, Math.round((taken / denominator) * 100))
: null;
return { rate, takenOnTime, takenLate, missed, denominator };
}
@@ -146,7 +149,9 @@ export function groupLedgerByDay(
}),
);
// Most-recent day first; chronological within the day above.
groups.sort((a, b) => (a.dayKey < b.dayKey ? 1 : a.dayKey > b.dayKey ? -1 : 0));
groups.sort((a, b) =>
a.dayKey < b.dayKey ? 1 : a.dayKey > b.dayKey ? -1 : 0,
);
return groups;
}
@@ -185,13 +185,19 @@ export function DoseHistoryLedger({
};
});
const queryKey = queryKeys.medicationDoseHistory(medicationId, fromIso, toIso);
const queryKey = queryKeys.medicationDoseHistory(
medicationId,
fromIso,
toIso,
);
const { data, isLoading, isError } = useQuery<LedgerPayload>({
queryKey,
queryFn: async () => {
const search = new URLSearchParams({ from: fromIso, to: toIso });
return apiGet<LedgerPayload>(`/api/medications/${medicationId}/dose-history?${search.toString()}`);
return apiGet<LedgerPayload>(
`/api/medications/${medicationId}/dose-history?${search.toString()}`,
);
},
staleTime: 15_000,
});
@@ -542,8 +548,7 @@ function LedgerRowItem({
const actionable = isSlotActionable(row);
// A missed slot from an earlier day stays markable, but only through the
// kebab — the visible button is reserved for doses still in play.
const showTakenButton =
actionable && (row.status === "upcoming" || isToday);
const showTakenButton = actionable && (row.status === "upcoming" || isToday);
const intake = row.intake;
const takeBusy = marking === `${row.at}:taken`;
const skipBusy = marking === `${row.at}:skipped`;
@@ -678,10 +683,7 @@ function LedgerRowItem({
<MoreVertical aria-hidden="true" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
data-slot="ledger-row-menu-content"
>
<DropdownMenuContent align="end" data-slot="ledger-row-menu-content">
{actionable && !showTakenButton && (
<DropdownMenuItem
onClick={() => onMark(row, "taken")}
@@ -709,9 +711,7 @@ function LedgerRowItem({
)}
{canPin && (
<DropdownMenuItem
onClick={() =>
onPin(intake!.id as string, dueContext!.at)
}
onClick={() => onPin(intake!.id as string, dueContext!.at)}
data-slot="ledger-pin"
>
<Link2 aria-hidden="true" className="mr-2 size-4" />
@@ -761,10 +761,7 @@ function LedgerRowItem({
{intake.skipped ? (
<Check aria-hidden="true" className="mr-2 size-4" />
) : (
<SkipForward
aria-hidden="true"
className="mr-2 size-4"
/>
<SkipForward aria-hidden="true" className="mr-2 size-4" />
)}
{intake.skipped
? t("medications.detail.verlauf.markAsTaken")
@@ -128,10 +128,7 @@ export function DoseStrengthCurve({
});
const now = useMemo(() => asOf ?? new Date(), [asOf]);
const doseChanges = useMemo(
() => details?.doseChanges ?? [],
[details],
);
const doseChanges = useMemo(() => details?.doseChanges ?? [], [details]);
const points = useMemo(
() => buildCurvePoints(doseChanges, now),
[doseChanges, now],
+65 -70
View File
@@ -283,7 +283,6 @@ function GatedUnknownDrug({ medicationName }: { medicationName: string }) {
);
}
function EmptyState() {
const { t } = useTranslations();
return (
@@ -370,50 +369,46 @@ function ChartBody({
style={{ height: "240px" }}
data-slot="drug-level-chart-area"
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={chartData}
margin={{ top: 10, right: 12, bottom: 16, left: 8 }}
>
<defs>
<linearGradient
id="drug-level-gradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={CHART_COLOR}
stopOpacity={CHART_FILL_OPACITY * 1.4}
/>
<stop
offset="100%"
stopColor={CHART_COLOR}
stopOpacity={0}
/>
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
{/* v1.4.27 MB6 CF-16 — dropped a stray empty `<text>` child
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={chartData}
margin={{ top: 10, right: 12, bottom: 16, left: 8 }}
>
<defs>
<linearGradient
id="drug-level-gradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={CHART_COLOR}
stopOpacity={CHART_FILL_OPACITY * 1.4}
/>
<stop offset="100%" stopColor={CHART_COLOR} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
{/* v1.4.27 MB6 CF-16 — dropped a stray empty `<text>` child
that previously declared no content and rendered an
invisible SVG node beneath the x-axis. */}
<XAxis
dataKey="dayOffset"
type="number"
domain={[-windowDays, 0]}
ticks={ticks}
tickFormatter={(v) => (v === 0 ? "0" : `${v}d`)}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
/>
{/* Research §2.3 — y-axis is unit-less. We hide tick labels
<XAxis
dataKey="dayOffset"
type="number"
domain={[-windowDays, 0]}
ticks={ticks}
tickFormatter={(v) => (v === 0 ? "0" : `${v}d`)}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
/>
{/* Research §2.3 — y-axis is unit-less. We hide tick labels
and the axis line entirely; the human-readable label sits
outside the chart frame, above. The `tick={false}` flag
suppresses the textual ticks per Recharts. v1.4.27 MB6
@@ -422,33 +417,33 @@ function ChartBody({
`width={1}` axis, where it could never be read. The
external `<p>` above the chart is the single source of
truth for the caption. */}
<YAxis
domain={[0, "auto"]}
tick={false}
tickLine={false}
axisLine={false}
width={1}
/>
<Tooltip
content={() => null}
cursor={{
stroke: "var(--muted-foreground)",
strokeOpacity: 0.3,
strokeDasharray: "3 3",
}}
/>
<Area
type="monotone"
dataKey="level"
stroke={CHART_COLOR}
strokeWidth={2}
fill="url(#drug-level-gradient)"
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
/>
</AreaChart>
</ResponsiveContainer>
<YAxis
domain={[0, "auto"]}
tick={false}
tickLine={false}
axisLine={false}
width={1}
/>
<Tooltip
content={() => null}
cursor={{
stroke: "var(--muted-foreground)",
strokeOpacity: 0.3,
strokeDasharray: "3 3",
}}
/>
<Area
type="monotone"
dataKey="level"
stroke={CHART_COLOR}
strokeWidth={2}
fill="url(#drug-level-gradient)"
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</>
);
@@ -349,8 +349,7 @@ export function Glp1MedicationCard({
// (max(lowStockRunwayDays, lead + cadenceIntervalDays)), so the weekly
// GLP-1 cadence is warned before its last dose. Stock 0 with a consuming
// schedule is runway 0 (mirrors `evaluateMedicationRunway`).
const lowStockFloor =
thresholds == null ? 7 : thresholds.lowStockRunwayDays;
const lowStockFloor = thresholds == null ? 7 : thresholds.lowStockRunwayDays;
const leadDays =
medication.reorderLeadDays != null
? medication.reorderLeadDays
@@ -206,7 +206,13 @@ export function InjectionSitePicker({
strokeOpacity="0.35"
/>
{/* Navel reference */}
<circle cx="50" cy="88" r="0.9" fill="currentColor" fillOpacity="0.45" />
<circle
cx="50"
cy="88"
r="0.9"
fill="currentColor"
fillOpacity="0.45"
/>
{/* Click targets */}
{INJECTION_SITE_KEYS.map((site) => {
@@ -188,7 +188,9 @@ export function IntakeHistoryListV2({
offset: String(offset),
status: STATUS_FILTER,
});
return apiGet<IntakeListResponse>(`/api/medications/${medicationId}/intake?${params.toString()}`);
return apiGet<IntakeListResponse>(
`/api/medications/${medicationId}/intake?${params.toString()}`,
);
},
enabled: isAuthenticated,
});
@@ -358,7 +360,7 @@ export function IntakeHistoryListV2({
{isTaken ? (
<Badge
variant="secondary"
className="bg-success/15 text-success dark:bg-green-500/20 dark:text-green-400 gap-1 text-xs"
className="bg-success/15 text-success gap-1 text-xs dark:bg-green-500/20 dark:text-green-400"
>
<Check aria-hidden="true" className="h-3 w-3" />
{t("medications.intakeHistoryStatusTaken")}
+109 -109
View File
@@ -225,127 +225,127 @@ export function LogIntakeDialog({
}}
className="space-y-4"
>
{medications.length === 0 ? (
<p className="text-muted-foreground py-4 text-sm">
{t("medications.logIntake.noMedications")}
</p>
) : (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="log-intake-medication">
{t("medications.logIntake.medicationLabel")}
</Label>
<NativeSelect
id="log-intake-medication"
value={medicationId}
onChange={(e) => handleMedicationChange(e.target.value)}
>
{medications.map((m) => (
<option key={m.id} value={m.id}>
{m.dose ? `${m.name} — ${m.dose}` : m.name}
</option>
))}
</NativeSelect>
</div>
{slotTimes.length > 0 && (
{medications.length === 0 ? (
<p className="text-muted-foreground py-4 text-sm">
{t("medications.logIntake.noMedications")}
</p>
) : (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="log-intake-slot">
{t("medications.logIntake.slotLabel")}
<Label htmlFor="log-intake-medication">
{t("medications.logIntake.medicationLabel")}
</Label>
<NativeSelect
id="log-intake-slot"
value={slot}
onChange={(e) => {
setSlot(e.target.value);
// The configured baseline tracks the slot's schedule
// dose, so an untouched field re-prefills on change.
setDoseOverride(null);
}}
id="log-intake-medication"
value={medicationId}
onChange={(e) => handleMedicationChange(e.target.value)}
>
<option value={NO_SLOT}>
{t("medications.logIntake.slotNone")}
</option>
{slotTimes.map((time) => (
<option key={time} value={time}>
{time}
{medications.map((m) => (
<option key={m.id} value={m.id}>
{m.dose ? `${m.name} — ${m.dose}` : m.name}
</option>
))}
</NativeSelect>
</div>
)}
<div className="space-y-2">
<Label htmlFor="log-intake-dose">
{t("medications.logIntake.doseLabel")}
</Label>
<Input
id="log-intake-dose"
value={dose}
maxLength={50}
onChange={(e) => setDoseOverride(e.target.value)}
placeholder={configuredDose}
autoComplete="off"
disabled={skipped}
/>
<p className="text-muted-foreground text-xs">
{t("medications.logIntake.doseHint")}
</p>
{slotTimes.length > 0 && (
<div className="space-y-2">
<Label htmlFor="log-intake-slot">
{t("medications.logIntake.slotLabel")}
</Label>
<NativeSelect
id="log-intake-slot"
value={slot}
onChange={(e) => {
setSlot(e.target.value);
// The configured baseline tracks the slot's schedule
// dose, so an untouched field re-prefills on change.
setDoseOverride(null);
}}
>
<option value={NO_SLOT}>
{t("medications.logIntake.slotNone")}
</option>
{slotTimes.map((time) => (
<option key={time} value={time}>
{time}
</option>
))}
</NativeSelect>
</div>
)}
<div className="space-y-2">
<Label htmlFor="log-intake-dose">
{t("medications.logIntake.doseLabel")}
</Label>
<Input
id="log-intake-dose"
value={dose}
maxLength={50}
onChange={(e) => setDoseOverride(e.target.value)}
placeholder={configuredDose}
autoComplete="off"
disabled={skipped}
/>
<p className="text-muted-foreground text-xs">
{t("medications.logIntake.doseHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="log-intake-taken-at">
{t("medications.logIntake.takenAtLabel")}
</Label>
<Input
id="log-intake-taken-at"
type="datetime-local"
value={takenAt}
max={toDateTimeLocal(new Date())}
onChange={(e) => setTakenAt(e.target.value)}
disabled={skipped}
/>
<p className="text-muted-foreground text-xs">
{t("medications.logIntake.takenAtHint")}
</p>
</div>
<label
htmlFor="log-intake-skipped"
className="flex items-center justify-between gap-3"
>
<span className="text-sm font-medium">
{t("medications.logIntake.skippedLabel")}
</span>
<Switch
id="log-intake-skipped"
checked={skipped}
onCheckedChange={setSkipped}
/>
</label>
</div>
)}
<div className="space-y-2">
<Label htmlFor="log-intake-taken-at">
{t("medications.logIntake.takenAtLabel")}
</Label>
<Input
id="log-intake-taken-at"
type="datetime-local"
value={takenAt}
max={toDateTimeLocal(new Date())}
onChange={(e) => setTakenAt(e.target.value)}
disabled={skipped}
/>
<p className="text-muted-foreground text-xs">
{t("medications.logIntake.takenAtHint")}
</p>
</div>
<label
htmlFor="log-intake-skipped"
className="flex items-center justify-between gap-3"
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={busy}
>
<span className="text-sm font-medium">
{t("medications.logIntake.skippedLabel")}
</span>
<Switch
id="log-intake-skipped"
checked={skipped}
onCheckedChange={setSkipped}
/>
</label>
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={busy}
>
{t("medications.logIntake.cancel")}
</Button>
<Button
type="submit"
disabled={busy || medications.length === 0 || !medicationId}
aria-busy={busy || undefined}
>
{busy && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.logIntake.submit")}
</Button>
</DialogFooter>
{t("medications.logIntake.cancel")}
</Button>
<Button
type="submit"
disabled={busy || medications.length === 0 || !medicationId}
aria-busy={busy || undefined}
>
{busy && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.logIntake.submit")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
@@ -105,7 +105,7 @@ export function MedicationCardHeader({
href={href}
aria-label={linkLabel}
data-slot="medication-card-header-link"
className="-m-1 min-w-0 space-y-1 rounded-md p-1 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
className="focus-visible:ring-ring -m-1 min-w-0 space-y-1 rounded-md p-1 focus-visible:ring-2 focus-visible:outline-none"
>
{body}
</Link>
@@ -299,8 +299,7 @@ export function MedicationCard({
// `reorderLeadDays` overrides the default. The notice lights at runway
// ≤ trigger (matching the daily cron), so a weekly med is warned before
// its last dose, not at it.
const lowStockFloor =
thresholds == null ? 7 : thresholds.lowStockRunwayDays;
const lowStockFloor = thresholds == null ? 7 : thresholds.lowStockRunwayDays;
const leadDays =
medication.reorderLeadDays != null
? medication.reorderLeadDays
@@ -378,7 +377,10 @@ export function MedicationCard({
// No due pill, no overdue escalation, ever (structurally there is no
// schedule, so the window status is already null).
const asNeededLine = medication.asNeeded ? (
<span className="text-muted-foreground" data-slot="medication-as-needed-marker">
<span
className="text-muted-foreground"
data-slot="medication-as-needed-marker"
>
{t("medications.asNeededMarker")}
</span>
) : null;
@@ -59,7 +59,7 @@ export function MedicationDetailSection({
<header className="border-border/60 flex items-center justify-between gap-2 border-b pb-3">
<h2
id={titleId}
className="text-foreground text-base font-semibold leading-6 tracking-tight"
className="text-foreground text-base leading-6 font-semibold tracking-tight"
>
{title}
</h2>
@@ -261,14 +261,22 @@ export function MedicationTable({
className: "bg-card sticky left-0 z-10",
},
{ key: "status", label: t("medications.tableColStatus"), sortable: false },
{ key: "nextDue", label: t("medications.tableColNextDose"), sortable: true },
{
key: "nextDue",
label: t("medications.tableColNextDose"),
sortable: true,
},
{
key: "compliance",
label: t("medications.tableColCompliance"),
sortable: true,
},
{ key: "stock", label: t("medications.tableColStock"), sortable: true },
{ key: "actions", label: t("medications.tableColActions"), sortable: false },
{
key: "actions",
label: t("medications.tableColActions"),
sortable: false,
},
];
function ariaSort(
@@ -651,7 +659,13 @@ function MedicationTableRowItem({
</TableCell>
<TableCell>{statusCell}</TableCell>
<TableCell className="text-sm">{nextCell}</TableCell>
<TableCell>{medication.active && !medication.asNeeded ? complianceCell : <span className="text-muted-foreground text-sm">–</span>}</TableCell>
<TableCell>
{medication.active && !medication.asNeeded ? (
complianceCell
) : (
<span className="text-muted-foreground text-sm">–</span>
)}
</TableCell>
<TableCell className="text-sm">{stockCell}</TableCell>
<TableCell>
{medication.active ? (
@@ -28,7 +28,9 @@ import {
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(<I18nProvider initialLocale="en">{node}</I18nProvider>);
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
function makeValue(kind: CadenceKind): CadenceValue {
@@ -138,12 +140,12 @@ describe("encodeCadence", () => {
});
it("clamps dayOfMonth to [1, 31]", () => {
expect(
encodeCadence("monthly", { ...baseSub, dayOfMonth: 99 }).rrule,
).toBe("FREQ=MONTHLY;BYMONTHDAY=31");
expect(
encodeCadence("monthly", { ...baseSub, dayOfMonth: 0 }).rrule,
).toBe("FREQ=MONTHLY;BYMONTHDAY=1");
expect(encodeCadence("monthly", { ...baseSub, dayOfMonth: 99 }).rrule).toBe(
"FREQ=MONTHLY;BYMONTHDAY=31",
);
expect(encodeCadence("monthly", { ...baseSub, dayOfMonth: 0 }).rrule).toBe(
"FREQ=MONTHLY;BYMONTHDAY=1",
);
});
it("clamps rollingDays to [1, 365]", () => {
@@ -19,7 +19,9 @@ import {
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(<I18nProvider initialLocale="en">{node}</I18nProvider>);
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const NOOP = () => undefined;
@@ -70,11 +72,7 @@ describe("<CourseWindowRow> — SSR render", () => {
it("renders both date inputs + the no-end Switch", () => {
const start = new Date(Date.UTC(2026, 4, 28));
const html = render(
<CourseWindowRow
startsOn={start}
endsOn={null}
onChange={NOOP}
/>,
<CourseWindowRow startsOn={start} endsOn={null} onChange={NOOP} />,
);
expect(html).toContain('data-slot="course-window-row"');
expect(html).toContain('data-slot="course-window-starts"');
@@ -86,11 +84,7 @@ describe("<CourseWindowRow> — SSR render", () => {
it("disables the endsOn input when no-end-date is on (endsOn=null)", () => {
const start = new Date(Date.UTC(2026, 4, 28));
const html = render(
<CourseWindowRow
startsOn={start}
endsOn={null}
onChange={NOOP}
/>,
<CourseWindowRow startsOn={start} endsOn={null} onChange={NOOP} />,
);
expect(html).toMatch(/data-slot="course-window-ends"[^>]*disabled/);
});
@@ -117,11 +111,7 @@ describe("<CourseWindowRow> — SSR render", () => {
const start = new Date(Date.UTC(2026, 4, 28));
const end = new Date(Date.UTC(2026, 4, 1));
const html = render(
<CourseWindowRow
startsOn={start}
endsOn={end}
onChange={NOOP}
/>,
<CourseWindowRow startsOn={start} endsOn={end} onChange={NOOP} />,
);
expect(html).toContain('data-slot="course-window-error"');
expect(html).toMatch(/aria-invalid="true"/);
@@ -21,7 +21,9 @@ import {
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(<I18nProvider initialLocale="en">{node}</I18nProvider>);
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const NOOP = () => undefined;
@@ -60,10 +62,7 @@ describe("addTime", () => {
});
it("refuses to add beyond maxChips", () => {
expect(addTime(["08:00", "12:00"], "18:00", 2)).toEqual([
"08:00",
"12:00",
]);
expect(addTime(["08:00", "12:00"], "18:00", 2)).toEqual(["08:00", "12:00"]);
});
});
@@ -116,9 +115,7 @@ describe("<TimesOfDayChips> — SSR render", () => {
});
it("marks a preset chip active when its time is in value", () => {
const html = render(
<TimesOfDayChips value={["08:00"]} onChange={NOOP} />,
);
const html = render(<TimesOfDayChips value={["08:00"]} onChange={NOOP} />);
expect(html).toMatch(/data-preset="morning"[^>]*data-active="true"/);
expect(html).toMatch(/data-preset="evening"[^>]*data-active="false"/);
});
@@ -156,9 +153,7 @@ describe("<TimesOfDayChips> — SSR render", () => {
});
it("renders the preset row by default (showPresets defaults true)", () => {
const html = render(
<TimesOfDayChips value={["08:00"]} onChange={NOOP} />,
);
const html = render(<TimesOfDayChips value={["08:00"]} onChange={NOOP} />);
expect(html).toContain('data-slot="times-of-day-presets"');
});
});
@@ -196,7 +196,9 @@ function clamp(n: number, min: number, max: number): number {
return Math.max(min, Math.min(max, Math.trunc(n)));
}
function parseIsoDate(s: string): { year: number; month: number; day: number } | null {
function parseIsoDate(
s: string,
): { year: number; month: number; day: number } | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
const [y, m, d] = s.split("-").map(Number);
if (!y || !m || !d) return null;
@@ -322,7 +324,7 @@ function CadenceOption({
checked={selected}
disabled={disabled}
onChange={onPick}
className="text-primary focus-visible:ring-ring h-4 w-4 cursor-pointer focus-visible:outline-none focus-visible:ring-2"
className="text-primary focus-visible:ring-ring h-4 w-4 cursor-pointer focus-visible:ring-2 focus-visible:outline-none"
data-slot="cadence-option-radio"
/>
<span className="text-sm font-medium">{label}</span>
@@ -483,7 +485,7 @@ function WeekdayChips({
data-active={isOn ? "true" : "false"}
onClick={() => toggle(tok)}
className={[
"focus-visible:ring-ring inline-flex h-11 min-w-11 items-center justify-center rounded-md border px-2 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:opacity-50",
"focus-visible:ring-ring inline-flex h-11 min-w-11 items-center justify-center rounded-md border px-2 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50",
isOn
? "bg-primary text-primary-foreground border-primary"
: "border-border bg-background text-foreground hover:bg-muted",
@@ -587,7 +589,13 @@ interface YearlyDateProps {
t: (key: string) => string;
}
function YearlyDate({ value, disabled, i18nPrefix, onChange, t }: YearlyDateProps) {
function YearlyDate({
value,
disabled,
i18nPrefix,
onChange,
t,
}: YearlyDateProps) {
const id = useId();
return (
<div className="flex items-center gap-2">
@@ -65,7 +65,10 @@ export function isValidHhmm(value: string): boolean {
* default range — the whole-day on-time tolerance is the engine's
* concern; the editor only ever stores a wall-clock HH:mm range.
*/
export function defaultBandForTime(time: string): { start: string; end: string } {
export function defaultBandForTime(time: string): {
start: string;
end: string;
} {
if (!isValidHhmm(time)) return { start: time, end: time };
const half = DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes;
const centre = hhmmToMinutes(time);
@@ -90,9 +90,10 @@ export function weekdayIndexesToTokens(indexes: number[]): WeekdayToken[] {
* own sub-control is empty). The fallback branch covers pathological
* inputs (negative intervalWeeks, NaN, etc.) without throwing.
*/
export function inferCadenceFromLegacy(
schedule: LegacyScheduleSnapshot,
): { value: CadenceValue; subControls: CadenceSubControls } {
export function inferCadenceFromLegacy(schedule: LegacyScheduleSnapshot): {
value: CadenceValue;
subControls: CadenceSubControls;
} {
const tokens = weekdayIndexesToTokens(schedule.daysOfWeek ?? []);
const interval = Number.isFinite(schedule.intervalWeeks)
? Math.trunc(schedule.intervalWeeks)
@@ -161,10 +161,7 @@ export function NaturalLanguageExtractor({
const body = (await res.json().catch(() => null)) as {
error?: string;
} | null;
if (
body?.error &&
/budget/i.test(body.error)
) {
if (body?.error && /budget/i.test(body.error)) {
setError({ kind: "budget" });
return;
}
@@ -247,9 +244,11 @@ export function NaturalLanguageExtractor({
maxLength={MAX_TEXT_LENGTH + 200}
disabled={busy}
aria-invalid={overLimit || error?.kind === "empty" || undefined}
aria-describedby={errorMessage ? `${textareaId}-error` : undefined}
aria-describedby={
errorMessage ? `${textareaId}-error` : undefined
}
/>
<div className="mt-1 text-right text-xs text-muted-foreground">
<div className="text-muted-foreground mt-1 text-right text-xs">
{charCount} / {MAX_TEXT_LENGTH}
</div>
</div>
@@ -258,14 +257,14 @@ export function NaturalLanguageExtractor({
<p
id={`${textareaId}-error`}
role="alert"
className="text-sm text-destructive"
className="text-destructive text-sm"
>
{errorMessage}
</p>
) : null}
<fieldset className="flex flex-col gap-2">
<legend className="text-xs font-medium text-muted-foreground">
<legend className="text-muted-foreground text-xs font-medium">
{t("medications.scheduling.naturalLanguage.examples.label")}
</legend>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
@@ -341,7 +340,7 @@ function ExampleChip({ label, onPick, disabled }: ExampleChipProps) {
type="button"
onClick={() => onPick(label)}
disabled={disabled}
className="min-h-11 rounded-md border border-input bg-background px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 sm:min-h-9"
className="border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground min-h-11 rounded-md border px-3 py-2 text-left text-sm transition-colors disabled:pointer-events-none disabled:opacity-50 sm:min-h-9"
>
{label}
</button>
@@ -205,9 +205,7 @@ export function ScheduleTimesEditor({
<TimesOfDayChips
value={edit.timesOfDay}
onChange={(timesOfDay) => patchSchedule(i, { timesOfDay })}
maxChips={
typeof s.rollingIntervalDays === "number" ? 1 : 8
}
maxChips={typeof s.rollingIntervalDays === "number" ? 1 : 8}
/>
</div>
@@ -49,10 +49,7 @@ describe("<NotificationsSection> — split title ids (F-1 H-1)", () => {
it("renders the section heading and the row label with distinct ids", () => {
const client = makeClient();
const html = render(
<NotificationsSection
medicationId="med-1"
notificationsEnabled={true}
/>,
<NotificationsSection medicationId="med-1" notificationsEnabled={true} />,
client,
);
@@ -197,10 +197,7 @@ export function ApiTokensRow({
}, [mintedToken]);
return (
<div
className="space-y-2"
data-slot="medication-detail-api-tokens-row"
>
<div className="space-y-2" data-slot="medication-detail-api-tokens-row">
<p className="text-muted-foreground text-xs">
{t("medications.detail.api.caption", { name: medicationName })}
</p>
@@ -301,7 +298,10 @@ export function ApiTokensRow({
)}
</Button>
{examplesOpen && (
<div className="mt-2 space-y-1.5" data-slot="api-tokens-examples-body">
<div
className="mt-2 space-y-1.5"
data-slot="api-tokens-examples-body"
>
<div className="flex items-center justify-end gap-2">
<Label className="sr-only">
{t("medications.requestExample")}
@@ -415,4 +415,3 @@ export function DangerZoneBody({
</div>
);
}
@@ -130,7 +130,10 @@ interface InventoryResponse {
meta?: { total: number };
}
const STATE_BADGE: Record<InventoryState, "secondary" | "outline" | "destructive"> = {
const STATE_BADGE: Record<
InventoryState,
"secondary" | "outline" | "destructive"
> = {
ACTIVE: "secondary",
IN_USE: "secondary",
EXPIRED: "destructive",
@@ -181,7 +184,9 @@ export function InventorySection({
const { data, isLoading } = useQuery<InventoryResponse>({
queryKey: queryKeys.medicationInventory(medicationId),
queryFn: async () => {
return apiGet<InventoryResponse>(`/api/medications/${medicationId}/inventory`);
return apiGet<InventoryResponse>(
`/api/medications/${medicationId}/inventory`,
);
},
staleTime: 30_000,
// v1.16.12 (#316) — fresh on every mount so reopening the supply tab
@@ -501,8 +506,7 @@ export function AddInventoryDialog({
const parsed = Number(quantity);
const effectiveMode = unitsPerDose > 1 ? quantityMode : "units";
const units =
effectiveMode === "doses" ? parsed * unitsPerDose : parsed;
const units = effectiveMode === "doses" ? parsed * unitsPerDose : parsed;
const quantityValid =
Number.isInteger(parsed) && parsed >= 1 && units >= 1 && units <= 1000;
@@ -607,7 +611,11 @@ export function AddInventoryDialog({
type="number"
inputMode="numeric"
min={1}
max={effectiveMode === "doses" ? Math.floor(1000 / unitsPerDose) : 1000}
max={
effectiveMode === "doses"
? Math.floor(1000 / unitsPerDose)
: 1000
}
step={1}
required
autoComplete="off"
@@ -767,8 +775,7 @@ function AdjustInventoryDialog({
>
{t("medications.detail.bestand.adjustHelper", {
total:
item.unitsTotal ??
t("medications.detail.bestand.unknown"),
item.unitsTotal ?? t("medications.detail.bestand.unknown"),
})}
</p>
</div>
@@ -824,7 +831,9 @@ function PackagingDialog({
const parsedPerDose = Number(perDoseValue);
const perDoseValid =
Number.isInteger(parsedPerDose) && parsedPerDose >= 1 && parsedPerDose <= 100;
Number.isInteger(parsedPerDose) &&
parsedPerDose >= 1 &&
parsedPerDose <= 100;
const parsedPack = packValue.trim() === "" ? null : Number(packValue);
const packValid =
parsedPack === null ||
@@ -933,7 +942,10 @@ function PackagingDialog({
>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={!perDoseValid || !packValid || busy}>
<Button
type="submit"
disabled={!perDoseValid || !packValid || busy}
>
{busy && (
<Loader2
aria-hidden="true"
@@ -347,4 +347,3 @@ export function PhasesRow({
</>
);
}
@@ -84,7 +84,7 @@ export function SettingsGroup({
</CollapsiblePrimitive.Trigger>
<CollapsiblePrimitive.Content
data-slot="settings-group-content"
className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down motion-reduce:animate-none"
className="data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down overflow-hidden motion-reduce:animate-none"
>
<div className="divide-border/60 divide-y">{children}</div>
</CollapsiblePrimitive.Content>
@@ -98,7 +98,10 @@ export function buildTitrationSteps(
}));
}
export function TitrationTimeline({ medicationId, asOf }: TitrationTimelineProps) {
export function TitrationTimeline({
medicationId,
asOf,
}: TitrationTimelineProps) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -152,10 +155,14 @@ export function TitrationTimeline({ medicationId, asOf }: TitrationTimelineProps
first planned step (or at the top when nothing is yet
in effect). */}
{markerAfter === i - 1 && i > 0 && (
<TitrationMarker label={t("medications.titration.youAreHere")} />
<TitrationMarker
label={t("medications.titration.youAreHere")}
/>
)}
{markerAfter === -1 && i === 0 && (
<TitrationMarker label={t("medications.titration.youAreHere")} />
<TitrationMarker
label={t("medications.titration.youAreHere")}
/>
)}
<div
className="flex items-start gap-3 py-2.5"
@@ -176,7 +183,7 @@ export function TitrationTimeline({ medicationId, asOf }: TitrationTimelineProps
? "border-primary bg-primary/15 text-primary"
: step.isPast
? "border-border bg-muted text-muted-foreground"
: "border-border/60 border-dashed text-muted-foreground/70",
: "border-border/60 text-muted-foreground/70 border-dashed",
)}
aria-hidden="true"
>
@@ -204,8 +204,15 @@ export async function runLogIntake(deps: {
t: Translator;
queryClient: QueryClient;
}): Promise<boolean> {
const { medication, skipped, takenAt, scheduledFor, doseTaken, t, queryClient } =
deps;
const {
medication,
skipped,
takenAt,
scheduledFor,
doseTaken,
t,
queryClient,
} = deps;
try {
const body: Record<string, unknown> = { skipped };
if (!skipped) body.takenAt = takenAt;
@@ -43,10 +43,7 @@ import {
*/
function makeStubT() {
return (
key: string,
params?: Record<string, string | number>,
): string => {
return (key: string, params?: Record<string, string | number>): string => {
if (!params) return key;
return `${key}(${JSON.stringify(params)})`;
};
@@ -109,9 +106,9 @@ describe("validateStep", () => {
it("step 2: requires a treatment row", () => {
const base = emptyWizardPayload();
expect(validateStep(base, 2)).toBe(false);
expect(
validateStep({ ...base, treatmentRow: "bloodPressure" }, 2),
).toBe(true);
expect(validateStep({ ...base, treatmentRow: "bloodPressure" }, 2)).toBe(
true,
);
});
it("step 3: requires populated dose amount", () => {
@@ -129,9 +126,9 @@ describe("validateStep", () => {
const base = emptyWizardPayload();
const start = new Date(Date.UTC(2026, 5, 1));
const earlier = new Date(Date.UTC(2026, 4, 1));
expect(
validateStep({ ...base, startsOn: start, endsOn: earlier }, 4),
).toBe(false);
expect(validateStep({ ...base, startsOn: start, endsOn: earlier }, 4)).toBe(
false,
);
});
it("step 4: accepts null endsOn (chronic)", () => {
@@ -152,30 +149,30 @@ describe("validateStep", () => {
});
it("step 6: weekdays requires >= 1 chip", () => {
expect(
validateStep(withCadence("weekdays", { weekdays: [] }), 6),
).toBe(false);
expect(
validateStep(withCadence("weekdays", { weekdays: ["MO"] }), 6),
).toBe(true);
expect(validateStep(withCadence("weekdays", { weekdays: [] }), 6)).toBe(
false,
);
expect(validateStep(withCadence("weekdays", { weekdays: ["MO"] }), 6)).toBe(
true,
);
});
it("step 6: monthly accepts day-of-month 1..31", () => {
expect(
validateStep(withCadence("monthly", { dayOfMonth: 1 }), 6),
).toBe(true);
expect(
validateStep(withCadence("monthly", { dayOfMonth: 31 }), 6),
).toBe(true);
expect(validateStep(withCadence("monthly", { dayOfMonth: 1 }), 6)).toBe(
true,
);
expect(validateStep(withCadence("monthly", { dayOfMonth: 31 }), 6)).toBe(
true,
);
});
it("step 6: rolling accepts 1..365 days", () => {
expect(
validateStep(withCadence("rolling", { rollingDays: 1 }), 6),
).toBe(true);
expect(
validateStep(withCadence("rolling", { rollingDays: 365 }), 6),
).toBe(true);
expect(validateStep(withCadence("rolling", { rollingDays: 1 }), 6)).toBe(
true,
);
expect(validateStep(withCadence("rolling", { rollingDays: 365 }), 6)).toBe(
true,
);
});
it("step 7: requires >= 1 valid HH:mm time", () => {
@@ -189,9 +186,9 @@ describe("validateStep", () => {
const base = withCadence("daily");
expect(validateStep({ ...base, startsOn: null }, 8)).toBe(false);
const start = new Date(Date.UTC(2026, 5, 1));
expect(
validateStep({ ...base, startsOn: start, endsOn: null }, 8),
).toBe(true);
expect(validateStep({ ...base, startsOn: start, endsOn: null }, 8)).toBe(
true,
);
});
});
@@ -397,7 +394,9 @@ describe("buildCreateBody", () => {
// The default "1" still ships (an edit back to 1 must reach the server).
expect(buildCreateBody({ ...p, unitsPerDose: "1" }).unitsPerDose).toBe(1);
// An empty field omits it (server default applies).
expect(buildCreateBody({ ...p, unitsPerDose: "" }).unitsPerDose).toBeUndefined();
expect(
buildCreateBody({ ...p, unitsPerDose: "" }).unitsPerDose,
).toBeUndefined();
const hydrated = hydrateWizardPayload({
id: "m1",
@@ -634,7 +633,9 @@ describe("summariseScheduleDraft — shared cadence-line helper", () => {
});
it("threads the everyNWeeks interval into the cadence key", () => {
const p = commitActiveDraft(withCadence("everyNWeeks", { intervalWeeks: 3 }));
const p = commitActiveDraft(
withCadence("everyNWeeks", { intervalWeeks: 3 }),
);
const out = summariseScheduleDraft(p.schedules[p.activeScheduleIndex], t);
expect(out).toMatch(/cadence\.everyNWeeks[^|]*"n":3/);
});
@@ -686,16 +687,12 @@ describe("hydrateWizardPayload", () => {
});
it("hydrates a DIABETES medication as the diabetes row", () => {
const out = hydrateWizardPayload(
makeInitial({ category: "DIABETES" }),
);
const out = hydrateWizardPayload(makeInitial({ category: "DIABETES" }));
expect(out.treatmentRow).toBe("diabetes");
});
it("hydrates an ANTIBIOTIC medication as the antibiotic row", () => {
const out = hydrateWizardPayload(
makeInitial({ category: "ANTIBIOTIC" }),
);
const out = hydrateWizardPayload(makeInitial({ category: "ANTIBIOTIC" }));
expect(out.treatmentRow).toBe("antibiotic");
});
@@ -25,10 +25,7 @@ import {
import type { StepProps } from "./step1-name";
// Each row's Lucide glyph. maintainer-confirmed assignment in D-1 §3 Step 2.
const ROW_ICONS: Record<
WizardTreatmentRow,
ComponentType<LucideProps>
> = {
const ROW_ICONS: Record<WizardTreatmentRow, ComponentType<LucideProps>> = {
bloodPressure: Stethoscope,
diabetes: Droplet,
hormone: Activity,
@@ -21,9 +21,7 @@ export function Step4Window({ payload, applyPartial }: StepProps) {
startsOn={payload.startsOn}
endsOn={payload.endsOn}
lockEndsToStart={isOneShot}
onChange={({ startsOn, endsOn }) =>
applyPartial({ startsOn, endsOn })
}
onChange={({ startsOn, endsOn }) => applyPartial({ startsOn, endsOn })}
/>
</div>
);
@@ -173,7 +173,7 @@ function WeekdayChips({ selected, onChange }: WeekdayChipsProps) {
data-active={isOn ? "true" : "false"}
onClick={() => toggle(tok)}
className={[
"focus-visible:ring-ring inline-flex h-11 min-w-11 items-center justify-center rounded-md border px-2 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2",
"focus-visible:ring-ring inline-flex h-11 min-w-11 items-center justify-center rounded-md border px-2 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none",
isOn
? "bg-primary text-primary-foreground border-primary"
: "border-border bg-background text-foreground hover:bg-muted",
@@ -63,9 +63,7 @@ export function Step7Times({ payload, applyPartial }: StepProps) {
>
{PRESETS.map(({ time, icon: Icon, key }) => {
const isOn = payload.timesOfDay.includes(time);
const label = t(
`medications.wizard.steps.step7.presets.${key}`,
);
const label = t(`medications.wizard.steps.step7.presets.${key}`);
return (
<button
key={time}
@@ -77,7 +75,7 @@ export function Step7Times({ payload, applyPartial }: StepProps) {
data-active={isOn ? "true" : "false"}
onClick={() => togglePreset(time)}
className={[
"focus-visible:ring-ring inline-flex h-11 items-center gap-1.5 rounded-md border px-3 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2",
"focus-visible:ring-ring inline-flex h-11 items-center gap-1.5 rounded-md border px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none",
isOn
? "bg-primary text-primary-foreground border-primary"
: "border-border bg-background text-foreground hover:bg-muted",
@@ -158,7 +158,7 @@ export function Step8Summary({
"border-border/70 hover:bg-muted/40 focus-visible:ring-ring",
"flex w-full items-center justify-center gap-2 rounded-md border border-dashed",
"p-3 text-sm font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2",
"focus-visible:ring-2 focus-visible:outline-none",
].join(" ")}
>
<Plus className="h-4 w-4" aria-hidden="true" />
@@ -170,28 +170,25 @@ export function Step8Summary({
{/* v1.16.11 — an as-needed medication never reminds (no slots),
so the reminders toggle is hidden alongside the schedules. */}
{payload.mode !== "asNeeded" && (
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<Label
htmlFor="wizard-reminders"
className="text-sm font-medium"
>
{t("medications.wizard.steps.step8.remindersLabel")}
</Label>
<p className="text-muted-foreground text-xs">
{t("medications.wizard.steps.step8.remindersDescription")}
</p>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<Label htmlFor="wizard-reminders" className="text-sm font-medium">
{t("medications.wizard.steps.step8.remindersLabel")}
</Label>
<p className="text-muted-foreground text-xs">
{t("medications.wizard.steps.step8.remindersDescription")}
</p>
</div>
<Switch
id="wizard-reminders"
checked={payload.notificationsEnabled}
onCheckedChange={(checked) =>
applyPartial({ notificationsEnabled: checked })
}
data-slot="wizard-reminders-toggle"
aria-label={t("medications.wizard.steps.step8.remindersLabel")}
/>
</div>
<Switch
id="wizard-reminders"
checked={payload.notificationsEnabled}
onCheckedChange={(checked) =>
applyPartial({ notificationsEnabled: checked })
}
data-slot="wizard-reminders-toggle"
aria-label={t("medications.wizard.steps.step8.remindersLabel")}
/>
</div>
)}
{payload.mode !== "asNeeded" && showNoChannelHint && (
@@ -116,7 +116,10 @@ export const WIZARD_TREATMENT_ROWS: readonly WizardTreatmentRow[] = [
*/
export const WIZARD_TREATMENT_MAPPING: Record<
WizardTreatmentRow,
{ treatmentClass: MedicationTreatmentClass; category: MedicationCategoryValue }
{
treatmentClass: MedicationTreatmentClass;
category: MedicationCategoryValue;
}
> = {
bloodPressure: { treatmentClass: "GENERIC", category: "BLOOD_PRESSURE" },
diabetes: { treatmentClass: "GENERIC", category: "DIABETES" },
@@ -696,7 +699,10 @@ export function buildCreateBody(
const draftsToEmit = isAsNeeded
? []
: isOneShot
? [committed.schedules[committed.activeScheduleIndex] ?? committed.schedules[0]]
? [
committed.schedules[committed.activeScheduleIndex] ??
committed.schedules[0],
]
: committed.schedules;
const parsedDosesPerUnit = Number.parseInt(committed.dosesPerUnit, 10);
@@ -738,7 +744,9 @@ export function buildCreateBody(
}),
oneShot: isOneShot,
asNeeded: isAsNeeded,
schedules: draftsToEmit.map((draft) => encodeScheduleDraft(draft, isOneShot)),
schedules: draftsToEmit.map((draft) =>
encodeScheduleDraft(draft, isOneShot),
),
};
if (committed.startsOn) {
body.startsOn = dateToIsoString(committed.startsOn);
@@ -812,7 +820,8 @@ type CadenceShape = Pick<ScheduleDraft, "mode" | "cadence" | "subControls">;
* summaries can't drift apart.
*/
function cadenceInterpolationN(shape: CadenceShape): number {
if (shape.cadence.kind === "everyNWeeks") return shape.subControls.intervalWeeks;
if (shape.cadence.kind === "everyNWeeks")
return shape.subControls.intervalWeeks;
if (shape.cadence.kind === "rolling")
return shape.cadence.rollingIntervalDays ?? 0;
return 0;
@@ -974,7 +983,9 @@ export interface MedicationPayload {
}
/** Coerce a persisted `deliveryForm` string onto the closed enum. */
function normaliseDeliveryForm(value: string | undefined): MedicationDeliveryForm {
function normaliseDeliveryForm(
value: string | undefined,
): MedicationDeliveryForm {
return value === "INJECTION" || value === "OTHER" ? value : "ORAL";
}
@@ -1037,7 +1048,9 @@ function hydrateScheduleDraft(
* the `(daysOfWeek, intervalWeeks)` pair when the v1.5 `rrule` /
* `rollingIntervalDays` fields are absent.
*/
export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload {
export function hydrateWizardPayload(
initial: MedicationPayload,
): WizardPayload {
const base = emptyWizardPayload();
const parsedDose = parseDoseExpression(initial.dose ?? "");
@@ -1140,9 +1153,9 @@ function subControlsFromRrule(rrule: string): CadenceSubControls {
const sub: CadenceSubControls = { ...DEFAULT_SUB_CONTROLS };
const byday = /BYDAY=([A-Z,]+)/.exec(rrule);
if (byday) {
const tokens = byday[1].split(",").filter((t) =>
["MO", "TU", "WE", "TH", "FR", "SA", "SU"].includes(t),
);
const tokens = byday[1]
.split(",")
.filter((t) => ["MO", "TU", "WE", "TH", "FR", "SA", "SU"].includes(t));
if (tokens.length > 0) {
sub.weekdays = tokens as CadenceSubControls["weekdays"];
}
@@ -84,114 +84,114 @@ export function WizardStepper({
aria-label={srLabel}
>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="relative h-8 w-8 shrink-0 before:absolute before:-inset-1.5 before:content-['']"
onClick={onFirst}
disabled={!firstEnabled}
data-slot="wizard-stepper-first"
aria-label={firstLabel}
>
<SkipBack className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="relative h-8 w-8 shrink-0 before:absolute before:-inset-1.5 before:content-['']"
onClick={onFirst}
disabled={!firstEnabled}
data-slot="wizard-stepper-first"
aria-label={firstLabel}
>
<SkipBack className="h-4 w-4" aria-hidden="true" />
</Button>
<ol className="flex min-w-0 flex-1 items-center justify-between">
{steps.map((stepNumber, index) => {
const isActive = index === activeIndex;
const isCompleted = index < activeIndex;
const isReachable = index <= reachableUntil;
const label = labels[stepNumber] ?? String(stepNumber);
// Backward + current are always navigable; forward is gated
// on the reachability ceiling.
const enabled = index <= activeIndex || isReachable;
// A connector segment leads INTO this dot from the previous
// one; it reads "completed" (solid) once we've passed it.
const connectorSolid = index <= activeIndex;
<ol className="flex min-w-0 flex-1 items-center justify-between">
{steps.map((stepNumber, index) => {
const isActive = index === activeIndex;
const isCompleted = index < activeIndex;
const isReachable = index <= reachableUntil;
const label = labels[stepNumber] ?? String(stepNumber);
// Backward + current are always navigable; forward is gated
// on the reachability ceiling.
const enabled = index <= activeIndex || isReachable;
// A connector segment leads INTO this dot from the previous
// one; it reads "completed" (solid) once we've passed it.
const connectorSolid = index <= activeIndex;
return (
<li
key={stepNumber}
className={cn(
"flex min-w-0 items-center",
index === 0 ? "flex-initial" : "flex-1",
)}
>
{index > 0 && (
<span
aria-hidden="true"
className={cn(
"mx-1 h-0 flex-1 border-t",
connectorSolid
? "border-primary"
: "border-muted-foreground/30 border-dashed",
)}
/>
)}
<button
type="button"
onClick={() => onJump(stepNumber)}
disabled={!enabled}
data-slot="wizard-stepper-dot"
data-step-dot={stepNumber}
data-active={isActive || undefined}
data-completed={isCompleted || undefined}
aria-current={isActive ? "step" : undefined}
aria-label={`${index + 1}. ${label}`}
return (
<li
key={stepNumber}
className={cn(
// 44 px hit target via padding even though the visual
// dot is small.
"group focus-visible:ring-ring/50 flex shrink-0 flex-col items-center gap-1 rounded-md p-2 transition-colors focus-visible:ring-2 focus-visible:outline-none",
enabled
? "cursor-pointer"
: "cursor-not-allowed opacity-60",
"flex min-w-0 items-center",
index === 0 ? "flex-initial" : "flex-1",
)}
>
<span
{index > 0 && (
<span
aria-hidden="true"
className={cn(
"mx-1 h-0 flex-1 border-t",
connectorSolid
? "border-primary"
: "border-muted-foreground/30 border-dashed",
)}
/>
)}
<button
type="button"
onClick={() => onJump(stepNumber)}
disabled={!enabled}
data-slot="wizard-stepper-dot"
data-step-dot={stepNumber}
data-active={isActive || undefined}
data-completed={isCompleted || undefined}
aria-current={isActive ? "step" : undefined}
aria-label={`${index + 1}. ${label}`}
className={cn(
"grid h-3 w-3 place-items-center rounded-full border transition-colors",
isActive
? "border-primary bg-primary ring-primary/30 ring-2 ring-offset-1 ring-offset-transparent"
: isCompleted
? "border-primary bg-primary"
: isReachable
? "border-primary bg-transparent"
: "border-muted-foreground/30 bg-transparent",
)}
/>
<span
className={cn(
"max-w-[6rem] truncate text-[0.6875rem] leading-none",
isActive
? "text-foreground font-medium"
: "text-muted-foreground",
// Crowding guard: narrow viewports drop the per-dot
// labels entirely (the caption below names the active
// step instead); from `sm+` every dot is labelled.
"hidden sm:inline",
// 44 px hit target via padding even though the visual
// dot is small.
"group focus-visible:ring-ring/50 flex shrink-0 flex-col items-center gap-1 rounded-md p-2 transition-colors focus-visible:ring-2 focus-visible:outline-none",
enabled
? "cursor-pointer"
: "cursor-not-allowed opacity-60",
)}
>
{label}
</span>
</button>
</li>
);
})}
</ol>
<span
className={cn(
"grid h-3 w-3 place-items-center rounded-full border transition-colors",
isActive
? "border-primary bg-primary ring-primary/30 ring-2 ring-offset-1 ring-offset-transparent"
: isCompleted
? "border-primary bg-primary"
: isReachable
? "border-primary bg-transparent"
: "border-muted-foreground/30 bg-transparent",
)}
/>
<span
className={cn(
"max-w-[6rem] truncate text-[0.6875rem] leading-none",
isActive
? "text-foreground font-medium"
: "text-muted-foreground",
// Crowding guard: narrow viewports drop the per-dot
// labels entirely (the caption below names the active
// step instead); from `sm+` every dot is labelled.
"hidden sm:inline",
)}
>
{label}
</span>
</button>
</li>
);
})}
</ol>
<Button
type="button"
variant="ghost"
size="icon"
className="relative h-8 w-8 shrink-0 before:absolute before:-inset-1.5 before:content-['']"
onClick={onLast}
disabled={!lastEnabled}
data-slot="wizard-stepper-last"
aria-label={lastLabel}
>
<SkipForward className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="relative h-8 w-8 shrink-0 before:absolute before:-inset-1.5 before:content-['']"
onClick={onLast}
disabled={!lastEnabled}
data-slot="wizard-stepper-last"
aria-label={lastLabel}
>
<SkipForward className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
{/* Compact caption — on narrow viewports the per-dot labels collapse