mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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:
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],
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user