mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
chore(release): v1.5.6
Pure-history medication detail page, legacy step consolidation, and the finish of the outbound-fetch migration. - /medications/[id] becomes a read-only history surface; every setting moves into a new advanced-settings sheet, and the edit trigger becomes a two-option picker. The list page is unchanged. - A pg-boss job folds pre-v1.5.0 granular step rows into one canonical daily total per user per day and soft-deletes the originals; migration 0087 adds a partial index for the converging discovery scan. - Every remaining server-side fetch routes through safeFetch (Withings, Codex OAuth + SSE, bug-report, Umami, GlitchTip, Loki); a new healthlog/safe-fetch-required ESLint rule guards the boundary. - Fixes: intake-edit dialog seeded from real values, consolidation skips a unique-constraint day instead of stranding it, avatar upload reads the body once under a bounded cap, egress lint rejects absolute URLs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d54addd61a
commit
8528c3b4fe
54 files changed
+2343
-324
No files matched your search
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* v1.5.6 G-1 §5 — AdvancedSettingsSheet composition contract.
|
||||
*
|
||||
* The sheet hosts the three moved sections in routine → rare →
|
||||
* destructive order: Notifications → Settings → destructive zone.
|
||||
* `<ResponsiveSheet>` wraps Radix portals that `renderToStaticMarkup`
|
||||
* does not materialise, so we mock it to a passthrough that renders
|
||||
* children only when `open`, and mock the three sections to ordered
|
||||
* markers. The test pins:
|
||||
* - the three sections render in the documented order;
|
||||
* - `onRequestPhaseSheet` threads through to `<SettingsSection>`;
|
||||
* - the destructive zone's `onAfterAction` closes the sheet;
|
||||
* - the sheet renders nothing when `open` is false.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/responsive-sheet", () => ({
|
||||
ResponsiveSheet: ({
|
||||
open,
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
open: boolean;
|
||||
children: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
}) =>
|
||||
open ? (
|
||||
<div data-slot="mock-responsive-sheet">
|
||||
<div data-slot="mock-title">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/components/medications/sections/notifications-section",
|
||||
() => ({
|
||||
NotificationsSection: () => <div data-slot="mock-notifications" />,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/components/medications/sections/settings-section", () => ({
|
||||
SettingsSection: ({
|
||||
onRequestPhaseSheet,
|
||||
}: {
|
||||
onRequestPhaseSheet?: () => void;
|
||||
}) => (
|
||||
<div
|
||||
data-slot="mock-settings"
|
||||
data-has-phase-swap={onRequestPhaseSheet ? "yes" : "no"}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/components/medications/sections/destructive-zone-section",
|
||||
() => ({
|
||||
DestructiveZoneSection: ({
|
||||
onAfterAction,
|
||||
}: {
|
||||
onAfterAction?: () => void;
|
||||
}) => (
|
||||
<button
|
||||
data-slot="mock-destructive"
|
||||
data-has-after-action={onAfterAction ? "yes" : "no"}
|
||||
onClick={onAfterAction}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
import { AdvancedSettingsSheet } from "@/components/medications/advanced-settings-sheet";
|
||||
|
||||
function baseProps(open: boolean, onRequestPhaseSheet?: () => void) {
|
||||
return {
|
||||
open,
|
||||
onOpenChange: () => {},
|
||||
medicationId: "med-1",
|
||||
medicationName: "Test Drug",
|
||||
treatmentClass: "GLP1",
|
||||
active: true,
|
||||
startsOn: "2026-01-01",
|
||||
endsOn: "2026-06-01",
|
||||
notificationsEnabled: true,
|
||||
reminderGraceMinutes: 30,
|
||||
intakeCount: 5,
|
||||
onRequestPhaseSheet,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AdvancedSettingsSheet (G-1 §5)", () => {
|
||||
it("renders the three sections in routine → rare → destructive order", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(true, () => {})} />,
|
||||
);
|
||||
const notif = html.indexOf("mock-notifications");
|
||||
const settings = html.indexOf("mock-settings");
|
||||
const destructive = html.indexOf("mock-destructive");
|
||||
expect(notif).toBeGreaterThan(-1);
|
||||
expect(settings).toBeGreaterThan(notif);
|
||||
expect(destructive).toBeGreaterThan(settings);
|
||||
});
|
||||
|
||||
it("threads onRequestPhaseSheet through to the settings section", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(true, () => {})} />,
|
||||
);
|
||||
expect(html).toContain('data-has-phase-swap="yes"');
|
||||
});
|
||||
|
||||
it("passes an onAfterAction to the destructive zone so it can close the sheet", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(true)} />,
|
||||
);
|
||||
expect(html).toContain('data-has-after-action="yes"');
|
||||
});
|
||||
|
||||
it("renders nothing when closed", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(false)} />,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("titles the sheet with the advanced-settings key", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(true)} />,
|
||||
);
|
||||
expect(html).toContain("medications.detail.advanced.title");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* v1.5.6 G-1 §9 Q1 — IntakeEditDialog seeds from the row's real
|
||||
* values.
|
||||
*
|
||||
* Before this pass the preview opened the dialog with a stub
|
||||
* `{ id, takenAt: null, skipped: false }`, so "Bearbeiten" always
|
||||
* showed an empty form. The fix threads the whole `IntakeEvent` up
|
||||
* through `onEditIntake(event)`; this test pins that the dialog's
|
||||
* datetime-local input + skipped switch seed from the event passed in.
|
||||
*
|
||||
* `<Dialog>` wraps a Radix portal that `renderToStaticMarkup` does not
|
||||
* materialise, so we mock the dialog primitives to passthroughs.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-slot="mock-dialog">{children}</div>
|
||||
),
|
||||
DialogContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogFooter: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogHeader: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog";
|
||||
|
||||
describe("IntakeEditDialog seed (G-1 §9 Q1)", () => {
|
||||
it("seeds the datetime-local input from the event's real takenAt", () => {
|
||||
// 2026-03-04T09:30 in local time — the dialog formats to
|
||||
// `YYYY-MM-DDTHH:mm` in the user's timezone.
|
||||
const taken = new Date(2026, 2, 4, 9, 30).toISOString();
|
||||
const html = renderToStaticMarkup(
|
||||
<IntakeEditDialog
|
||||
medicationId="med-1"
|
||||
event={{ id: "evt-1", takenAt: taken, skipped: false }}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('value="2026-03-04T09:30"');
|
||||
});
|
||||
|
||||
it("seeds the skipped switch from a skipped event (no stub default)", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<IntakeEditDialog
|
||||
medicationId="med-1"
|
||||
event={{ id: "evt-2", takenAt: null, skipped: true }}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
// Radix Switch reflects checked state via data-state.
|
||||
expect(html).toContain('data-state="checked"');
|
||||
});
|
||||
|
||||
it("renders nothing when no event is selected", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<IntakeEditDialog medicationId="med-1" event={null} onClose={() => {}} />,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -124,7 +124,7 @@ const mounjaro: Glp1Medication = {
|
||||
};
|
||||
|
||||
describe("medication card symmetry — Ramipril vs Mounjaro", () => {
|
||||
it("both cards render History + Pencil header buttons", () => {
|
||||
it("both cards render the detail-nav + Pencil header buttons", () => {
|
||||
const client = makeClient();
|
||||
seedCompliance(client, ramipril.id);
|
||||
seedCompliance(client, mounjaro.id);
|
||||
@@ -140,7 +140,10 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
|
||||
);
|
||||
|
||||
for (const html of [ramiprilHtml, mounjaroHtml]) {
|
||||
expect(html).toContain("lucide-history");
|
||||
// v1.5.6 F-1 M-1 — the detail-nav icon swapped from the
|
||||
// history glyph to a neutral chevron now that it routes to the
|
||||
// detail page rather than the history sub-route.
|
||||
expect(html).toContain("lucide-chevron-right");
|
||||
expect(html).toContain("lucide-pencil");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -26,6 +26,35 @@ vi.mock("@/lib/i18n/context", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// `<DropdownMenu>` wraps a Radix portal that `renderToStaticMarkup`
|
||||
// does not materialise while closed; mock the primitives to
|
||||
// passthroughs so the trigger + menu items are reachable in static
|
||||
// markup and the `onSelect` wiring is assertable (G-1 §4).
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-slot="mock-dropdown">{children}</div>
|
||||
),
|
||||
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-slot="mock-dropdown-trigger">{children}</div>
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-slot="mock-dropdown-content">{children}</div>
|
||||
),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
...rest
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onSelect?: () => void;
|
||||
[key: string]: unknown;
|
||||
}) => (
|
||||
<button onClick={onSelect} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import { MedicationDetailHeader } from "@/components/medications/medication-detail-header";
|
||||
|
||||
function render(props: {
|
||||
@@ -40,7 +69,8 @@ function render(props: {
|
||||
dose={props.dose ?? "5 mg"}
|
||||
active={props.active ?? true}
|
||||
endsOn={props.endsOn}
|
||||
onEdit={() => {}}
|
||||
onEditPlan={() => {}}
|
||||
onOpenAdvanced={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -79,6 +109,22 @@ describe("MedicationDetailHeader (D-3 §9.1)", () => {
|
||||
expect(headingIndex).toBeLessThan(buttonIndex);
|
||||
});
|
||||
|
||||
it("renders the two-option Bearbeiten picker (G-1 §4)", () => {
|
||||
const html = render({ active: true });
|
||||
expect(html).toContain("medications.detail.edit.planOption");
|
||||
expect(html).toContain("medications.detail.edit.advancedOption");
|
||||
expect(html).toContain('data-slot="medication-detail-edit-plan"');
|
||||
expect(html).toContain('data-slot="medication-detail-edit-advanced"');
|
||||
});
|
||||
|
||||
it("orders Plan bearbeiten above Erweiterte Einstellungen", () => {
|
||||
const html = render({ active: true });
|
||||
const plan = html.indexOf("medications.detail.edit.planOption");
|
||||
const advanced = html.indexOf("medications.detail.edit.advancedOption");
|
||||
expect(plan).toBeGreaterThan(-1);
|
||||
expect(advanced).toBeGreaterThan(plan);
|
||||
});
|
||||
|
||||
it("status pill text always renders (no colour-only state)", () => {
|
||||
// Regardless of theme support the label MUST be in the DOM so a
|
||||
// screen reader announces "Aktiv" even when the dot's hue is
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.5.6 G-1 §5 — Advanced settings sheet.
|
||||
*
|
||||
* Hosts the three settings sections the detail page no longer renders
|
||||
* inline: Notifications, Settings (API tokens / CSV stub / Phasen /
|
||||
* grace) and the destructive zone, stacked top → bottom in
|
||||
* routine → rare → destructive order inside a `<ResponsiveSheet>`.
|
||||
*
|
||||
* The Phasen button is sibling-swapped, not nested: when the user
|
||||
* opens it, `onRequestPhaseSheet` closes this sheet first and lets the
|
||||
* parent open the phase sheet so the modal stack never exceeds two
|
||||
* (G-1 §5 / D-3 §10 invariant 7). Each moved section saves itself, so
|
||||
* the footer slot stays empty. On a non-navigating destructive success
|
||||
* the sheet closes; the Tier 3b delete already navigates to
|
||||
* `/medications`.
|
||||
*/
|
||||
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { NotificationsSection } from "@/components/medications/sections/notifications-section";
|
||||
import { SettingsSection } from "@/components/medications/sections/settings-section";
|
||||
import { DestructiveZoneSection } from "@/components/medications/sections/destructive-zone-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export interface AdvancedSettingsSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
medicationId: string;
|
||||
medicationName: string;
|
||||
treatmentClass?: string;
|
||||
active: boolean;
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
notificationsEnabled: boolean;
|
||||
reminderGraceMinutes: number | null;
|
||||
intakeCount: number;
|
||||
/**
|
||||
* Sibling-swap the phase sheet: the parent closes this sheet and
|
||||
* opens `<PhaseConfigSheet>` so the two never stack (G-1 §5).
|
||||
*/
|
||||
onRequestPhaseSheet?: () => void;
|
||||
}
|
||||
|
||||
export function AdvancedSettingsSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
medicationId,
|
||||
medicationName,
|
||||
treatmentClass,
|
||||
active,
|
||||
startsOn,
|
||||
endsOn,
|
||||
notificationsEnabled,
|
||||
reminderGraceMinutes,
|
||||
intakeCount,
|
||||
onRequestPhaseSheet,
|
||||
}: AdvancedSettingsSheetProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("medications.detail.advanced.title")}
|
||||
className="sm:max-w-lg"
|
||||
bodyClassName="gap-6"
|
||||
>
|
||||
<div
|
||||
className="space-y-6"
|
||||
data-slot="advanced-settings-sheet-body"
|
||||
>
|
||||
{/* routine */}
|
||||
<NotificationsSection
|
||||
medicationId={medicationId}
|
||||
notificationsEnabled={notificationsEnabled}
|
||||
/>
|
||||
|
||||
{/* rare */}
|
||||
<SettingsSection
|
||||
medicationId={medicationId}
|
||||
medicationName={medicationName}
|
||||
treatmentClass={treatmentClass}
|
||||
startsOn={startsOn}
|
||||
endsOn={endsOn}
|
||||
reminderGraceMinutes={reminderGraceMinutes}
|
||||
onRequestPhaseSheet={onRequestPhaseSheet}
|
||||
/>
|
||||
|
||||
{/* destructive */}
|
||||
<DestructiveZoneSection
|
||||
medicationId={medicationId}
|
||||
medicationName={medicationName}
|
||||
active={active}
|
||||
intakeCount={intakeCount}
|
||||
onAfterAction={() => onOpenChange(false)}
|
||||
/>
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,8 @@
|
||||
* Renders the medication's cadence as a plain-language line (e.g.
|
||||
* "Alle 12 h nach letzter Einnahme", "Alle 2 Wochen, mittwochs",
|
||||
* "Monatlich am 15.") with an optional course-window sub-line, and an
|
||||
* edit pencil that opens the wizard with `landingIntent: "cadence"`.
|
||||
* optional edit pencil whose `onEdit` the caller wires (suppressed via
|
||||
* `hideEdit` on the read-only detail surface).
|
||||
* For one-shot medications the line collapses to
|
||||
* `Einmalig am DD.MM.` and the edit affordance is suppressed (the
|
||||
* header pencil owns the wizard route per D-3 §6 one-shot variant).
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronRight,
|
||||
CircleCheck,
|
||||
Flame,
|
||||
History,
|
||||
Loader2,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
@@ -337,7 +337,7 @@ export function Glp1MedicationCard({
|
||||
aria-label={t("medications.openDetailPage")}
|
||||
>
|
||||
<Link href={`/medications/${medication.id}`}>
|
||||
<History className="h-4 w-4" />
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
|
||||
type IntakeSource = "WEB" | "API" | "REMINDER" | "IMPORT";
|
||||
|
||||
interface IntakeEvent {
|
||||
export interface IntakeEvent {
|
||||
id: string;
|
||||
medicationId: string;
|
||||
scheduledFor: string;
|
||||
@@ -108,9 +108,12 @@ interface IntakeHistoryListV2Props {
|
||||
pageSize?: number;
|
||||
/**
|
||||
* v1.5.5 F-1 C-2 — per-row Bearbeiten callback. When provided the
|
||||
* row renders a kebab `<DropdownMenu>` with an Edit action.
|
||||
* row renders a kebab `<DropdownMenu>` with an Edit action. v1.5.6
|
||||
* G-1 §9 Q1: threads the whole `IntakeEvent` (not just the id) so
|
||||
* the edit dialog seeds from the row's real `takenAt` / `skipped`
|
||||
* instead of an empty stub.
|
||||
*/
|
||||
onEditIntake?: (eventId: string) => void;
|
||||
onEditIntake?: (event: IntakeEvent) => void;
|
||||
/**
|
||||
* v1.5.5 F-1 C-2 — per-row Löschen callback. When provided the row
|
||||
* renders a kebab `<DropdownMenu>` with a Delete action.
|
||||
@@ -380,7 +383,7 @@ export function IntakeHistoryListV2({
|
||||
<DropdownMenuContent align="end">
|
||||
{onEditIntake && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onEditIntake(event.id)}
|
||||
onSelect={() => onEditIntake(event)}
|
||||
data-slot="intake-history-row-edit"
|
||||
>
|
||||
{t("medications.detail.intake.rowActions.edit")}
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
Flame,
|
||||
Pencil,
|
||||
Loader2,
|
||||
History,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
@@ -275,7 +275,7 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
|
||||
aria-label={t("medications.openDetailPage")}
|
||||
>
|
||||
<Link href={`/medications/${medication.id}`}>
|
||||
<History className="h-4 w-4" />
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -18,10 +18,16 @@
|
||||
* destructive zone, not on the header (E-2 M-1).
|
||||
*/
|
||||
|
||||
import { Pencil } from "lucide-react";
|
||||
import { ChevronDown, Pencil } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export interface MedicationDetailHeaderProps {
|
||||
@@ -30,11 +36,15 @@ export interface MedicationDetailHeaderProps {
|
||||
active: boolean;
|
||||
endsOn?: string | null;
|
||||
/**
|
||||
* Fires when the user taps the Bearbeiten pencil. The detail page
|
||||
* routes this to the wizard with `landingIntent: "name"` so the
|
||||
* user lands on Step 1.
|
||||
* v1.5.6 G-1 §4 — "Plan bearbeiten" opens the wizard (mode="edit",
|
||||
* Step 1). Fired from the Bearbeiten dropdown's first item.
|
||||
*/
|
||||
onEdit: () => void;
|
||||
onEditPlan: () => void;
|
||||
/**
|
||||
* v1.5.6 G-1 §4 — "Erweiterte Einstellungen" opens the
|
||||
* `<AdvancedSettingsSheet>`. Fired from the dropdown's second item.
|
||||
*/
|
||||
onOpenAdvanced: () => void;
|
||||
}
|
||||
|
||||
type Status = "active" | "paused" | "ended";
|
||||
@@ -58,7 +68,8 @@ export function MedicationDetailHeader({
|
||||
dose,
|
||||
active,
|
||||
endsOn,
|
||||
onEdit,
|
||||
onEditPlan,
|
||||
onOpenAdvanced,
|
||||
}: MedicationDetailHeaderProps) {
|
||||
const { t } = useTranslations();
|
||||
const status = resolveStatus(active, endsOn);
|
||||
@@ -98,16 +109,34 @@ export function MedicationDetailHeader({
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="medication-detail-edit-button"
|
||||
>
|
||||
<Pencil aria-hidden="true" className="h-4 w-4" />
|
||||
<span>{t("common.edit")}</span>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="medication-detail-edit-button"
|
||||
>
|
||||
<Pencil aria-hidden="true" className="h-4 w-4" />
|
||||
<span>{t("common.edit")}</span>
|
||||
<ChevronDown aria-hidden="true" className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={onEditPlan}
|
||||
data-slot="medication-detail-edit-plan"
|
||||
>
|
||||
{t("medications.detail.edit.planOption")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={onOpenAdvanced}
|
||||
data-slot="medication-detail-edit-advanced"
|
||||
>
|
||||
{t("medications.detail.edit.advancedOption")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* v1.5.6 G-1 §5 — phase sibling-swap wiring on `<SettingsSection>`.
|
||||
*
|
||||
* When the hosting `<AdvancedSettingsSheet>` passes
|
||||
* `onRequestPhaseSheet`, the section must NOT mount its own
|
||||
* `<PhaseConfigSheet>` — the parent orchestrates the swap (close the
|
||||
* advanced sheet, then open the phase sheet) so the two never stack.
|
||||
* Without the callback the section keeps owning its own phase sheet,
|
||||
* preserving the standalone-surface fallback.
|
||||
*
|
||||
* `<PhaseConfigSheet>` is mocked to a marker so its presence /
|
||||
* absence is observable in static markup regardless of open-state.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
vi.mock("@/components/medications/sections/api-tokens-row", () => ({
|
||||
ApiTokensRow: () => <div data-slot="mock-api-tokens" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/medications/sections/phase-config-sheet", () => ({
|
||||
PhaseConfigSheet: () => <div data-slot="mock-phase-config-sheet" />,
|
||||
}));
|
||||
|
||||
import { SettingsSection } from "@/components/medications/sections/settings-section";
|
||||
|
||||
function makeClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, staleTime: Number.POSITIVE_INFINITY },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={makeClient()}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// GLP-1 + a set course window so the Phasen row + button mount.
|
||||
const glp1Props = {
|
||||
medicationId: "med-1",
|
||||
medicationName: "Test Drug",
|
||||
treatmentClass: "GLP1",
|
||||
startsOn: "2026-01-01",
|
||||
endsOn: "2026-06-01",
|
||||
reminderGraceMinutes: 30,
|
||||
};
|
||||
|
||||
describe("SettingsSection phase sibling-swap (G-1 §5)", () => {
|
||||
it("does NOT self-mount the phase sheet when onRequestPhaseSheet is provided", () => {
|
||||
const html = render(
|
||||
<SettingsSection {...glp1Props} onRequestPhaseSheet={() => {}} />,
|
||||
);
|
||||
expect(html).toContain("medication-detail-phase-management-button");
|
||||
expect(html).not.toContain("mock-phase-config-sheet");
|
||||
});
|
||||
|
||||
it("self-mounts the phase sheet on the standalone surface (no callback)", () => {
|
||||
const html = render(<SettingsSection {...glp1Props} />);
|
||||
expect(html).toContain("medication-detail-phase-management-button");
|
||||
expect(html).toContain("mock-phase-config-sheet");
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,13 @@ export interface DestructiveZoneSectionProps {
|
||||
medicationName: string;
|
||||
active: boolean;
|
||||
intakeCount: number;
|
||||
/**
|
||||
* v1.5.6 G-1 §5 — fires after a non-navigating destructive success
|
||||
* (Tier 1 pause/resume, Tier 2 end, Tier 3a purge) so a hosting
|
||||
* sheet can close itself. Tier 3b delete navigates instead and does
|
||||
* not call this. Absent on the standalone surface.
|
||||
*/
|
||||
onAfterAction?: () => void;
|
||||
}
|
||||
|
||||
const PAUSE_SWITCH_ID = "medication-detail-pause-switch";
|
||||
@@ -66,6 +73,7 @@ export function DestructiveZoneSection({
|
||||
medicationName,
|
||||
active,
|
||||
intakeCount,
|
||||
onAfterAction,
|
||||
}: DestructiveZoneSectionProps) {
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
@@ -107,6 +115,7 @@ export function DestructiveZoneSection({
|
||||
? t("medications.detail.zone.pause.pausedToast")
|
||||
: t("medications.detail.zone.pause.resumedToast"),
|
||||
);
|
||||
onAfterAction?.();
|
||||
} catch {
|
||||
setPaused(previous);
|
||||
toast.error(t("medications.detail.zone.pause.failed"));
|
||||
@@ -132,6 +141,7 @@ export function DestructiveZoneSection({
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
toast.success(t("medications.detail.zone.end.toast"));
|
||||
setEndDialogOpen(false);
|
||||
onAfterAction?.();
|
||||
} catch {
|
||||
toast.error(t("medications.detail.zone.end.failed"));
|
||||
} finally {
|
||||
@@ -154,6 +164,7 @@ export function DestructiveZoneSection({
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
toast.success(t("medications.detail.zone.purge.toast"));
|
||||
setPurgeDialogOpen(false);
|
||||
onAfterAction?.();
|
||||
} catch {
|
||||
toast.error(t("medications.detail.zone.purge.failed"));
|
||||
} finally {
|
||||
|
||||
@@ -40,7 +40,10 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog";
|
||||
import { IntakeHistoryListV2 } from "@/components/medications/intake-history-list-v2";
|
||||
import {
|
||||
IntakeHistoryListV2,
|
||||
type IntakeEvent,
|
||||
} from "@/components/medications/intake-history-list-v2";
|
||||
import { IntakeImportDialog } from "@/components/medications/intake-import-dialog";
|
||||
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -61,7 +64,7 @@ export function IntakeHistoryPreview({
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingEvent, setEditingEvent] = useState<IntakeEvent | null>(null);
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false);
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
@@ -206,7 +209,7 @@ export function IntakeHistoryPreview({
|
||||
<IntakeHistoryListV2
|
||||
medicationId={medicationId}
|
||||
pageSize={14}
|
||||
onEditIntake={setEditingId}
|
||||
onEditIntake={setEditingEvent}
|
||||
onDeleteIntake={setPendingDeleteId}
|
||||
selection={{
|
||||
mode: "multi",
|
||||
@@ -226,19 +229,25 @@ export function IntakeHistoryPreview({
|
||||
</div>
|
||||
</MedicationDetailSection>
|
||||
|
||||
<IntakeImportDialog
|
||||
medicationId={importOpen ? medicationId : null}
|
||||
onClose={() => onImportOpenChange(false)}
|
||||
/>
|
||||
{importOpen && (
|
||||
<IntakeImportDialog
|
||||
medicationId={medicationId}
|
||||
onClose={() => onImportOpenChange(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<IntakeEditDialog
|
||||
medicationId={medicationId}
|
||||
event={
|
||||
editingId
|
||||
? { id: editingId, takenAt: null, skipped: false }
|
||||
editingEvent
|
||||
? {
|
||||
id: editingEvent.id,
|
||||
takenAt: editingEvent.takenAt,
|
||||
skipped: editingEvent.skipped,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onClose={() => setEditingId(null)}
|
||||
onClose={() => setEditingEvent(null)}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
|
||||
@@ -40,6 +40,15 @@ export interface SettingsSectionProps {
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
reminderGraceMinutes?: number | null;
|
||||
/**
|
||||
* v1.5.6 G-1 §5 — sibling-swap the phase sheet. When provided, the
|
||||
* Phasen button hands control to the parent (which closes the
|
||||
* hosting sheet before opening `<PhaseConfigSheet>`) instead of the
|
||||
* self-managed `phaseSheetOpen` state, so the two sheets never
|
||||
* stack. Absent on the standalone surface, where the section owns
|
||||
* its own phase sheet.
|
||||
*/
|
||||
onRequestPhaseSheet?: () => void;
|
||||
}
|
||||
|
||||
export function SettingsSection({
|
||||
@@ -49,6 +58,7 @@ export function SettingsSection({
|
||||
startsOn,
|
||||
endsOn,
|
||||
reminderGraceMinutes,
|
||||
onRequestPhaseSheet,
|
||||
}: SettingsSectionProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -136,7 +146,11 @@ export function SettingsSection({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPhaseSheetOpen(true)}
|
||||
onClick={() =>
|
||||
onRequestPhaseSheet
|
||||
? onRequestPhaseSheet()
|
||||
: setPhaseSheetOpen(true)
|
||||
}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="medication-detail-phase-management-button"
|
||||
>
|
||||
@@ -196,7 +210,7 @@ export function SettingsSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showPhases && (
|
||||
{showPhases && !onRequestPhaseSheet && (
|
||||
<PhaseConfigSheet
|
||||
medicationId={medicationId}
|
||||
open={phaseSheetOpen}
|
||||
|
||||
@@ -87,14 +87,6 @@ export interface MedicationWizardDialogProps {
|
||||
mode: "create" | "edit";
|
||||
/** Required when `mode === "edit"`. */
|
||||
initial?: MedicationPayload;
|
||||
/**
|
||||
* v1.5.5 C-E2-2 — call-site intent that drives the landing step on
|
||||
* an edit-open. The detail page's cadence-summary row passes
|
||||
* `"cadence"` so the user lands on Step 5; the header pencil passes
|
||||
* `"name"` so the user lands on Step 1. Defaults to `undefined`
|
||||
* which keeps the legacy 1-or-8 heuristic.
|
||||
*/
|
||||
landingIntent?: "cadence" | "summary" | "name";
|
||||
/** Fires with the medication id on a successful create / save. */
|
||||
onSuccess?: (id: string) => void;
|
||||
}
|
||||
@@ -113,14 +105,13 @@ const STEP_ICONS = {
|
||||
type StepNumber = keyof typeof STEP_ICONS;
|
||||
|
||||
export function MedicationWizardDialog(props: MedicationWizardDialogProps) {
|
||||
// Key the inner state container on (open + mode + medication id +
|
||||
// landingIntent) so every open gets a fresh state tree AND every
|
||||
// change of intent re-runs the initial-step decision. React unmounts
|
||||
// the inner component when the key changes, which sidesteps the
|
||||
// "setState in useEffect" anti-pattern that resetting on open via an
|
||||
// effect would otherwise trigger. The outer shell stays mounted so
|
||||
// the dialog's close animation runs cleanly.
|
||||
const stateKey = `${props.open ? "open" : "closed"}:${props.mode}:${props.initial?.id ?? "new"}:${props.landingIntent ?? "default"}`;
|
||||
// Key the inner state container on (open + mode + medication id) so
|
||||
// every open gets a fresh state tree. React unmounts the inner
|
||||
// component when the key changes, which sidesteps the "setState in
|
||||
// useEffect" anti-pattern that resetting on open via an effect would
|
||||
// otherwise trigger. The outer shell stays mounted so the dialog's
|
||||
// close animation runs cleanly.
|
||||
const stateKey = `${props.open ? "open" : "closed"}:${props.mode}:${props.initial?.id ?? "new"}`;
|
||||
return <WizardDialogShell key={stateKey} {...props} />;
|
||||
}
|
||||
|
||||
@@ -129,7 +120,6 @@ function WizardDialogShell({
|
||||
onOpenChange,
|
||||
mode,
|
||||
initial,
|
||||
landingIntent,
|
||||
onSuccess,
|
||||
}: MedicationWizardDialogProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
@@ -144,7 +134,7 @@ function WizardDialogShell({
|
||||
const [step, setStep] = useState<StepNumber>(() => {
|
||||
if (mode === "edit" && initial) {
|
||||
const hydrated = hydrateWizardPayload(initial);
|
||||
return landingStepForEdit(hydrated, landingIntent) as StepNumber;
|
||||
return landingStepForEdit(hydrated) as StepNumber;
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
|
||||
@@ -722,63 +722,6 @@ describe("compose-mode — multi-schedule encoder + hydrator", () => {
|
||||
expect(landingStepForEdit(single)).toBe(1);
|
||||
});
|
||||
|
||||
// v1.5.5 C-E2-2 — `landingStepForEdit` gains an `intent` arg so the
|
||||
// detail-page cadence-summary row can drop the user directly on
|
||||
// Step 5 instead of bouncing them through Step 1 first. One-shot
|
||||
// payloads fall back to Step 8 because the cadence picker is not on
|
||||
// the one-shot path.
|
||||
it("lands on Step 5 for the cadence intent", () => {
|
||||
const payload = hydrateWizardPayload({
|
||||
id: "m_cadence",
|
||||
name: "Test",
|
||||
dose: "10",
|
||||
category: "OTHER",
|
||||
treatmentClass: "GENERIC",
|
||||
notificationsEnabled: true,
|
||||
startsOn: new Date(Date.UTC(2026, 4, 28)),
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
schedules: [
|
||||
{
|
||||
id: "sch_a",
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
timesOfDay: ["08:00"],
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(landingStepForEdit(payload, "cadence")).toBe(5);
|
||||
expect(landingStepForEdit(payload, "name")).toBe(1);
|
||||
expect(landingStepForEdit(payload, "summary")).toBe(8);
|
||||
});
|
||||
|
||||
it("falls back to Step 8 on cadence intent for a one-shot medication", () => {
|
||||
const payload = hydrateWizardPayload({
|
||||
id: "m_oneshot",
|
||||
name: "Test",
|
||||
dose: "10",
|
||||
category: "OTHER",
|
||||
treatmentClass: "GENERIC",
|
||||
notificationsEnabled: true,
|
||||
startsOn: new Date(Date.UTC(2026, 4, 28)),
|
||||
endsOn: null,
|
||||
oneShot: true,
|
||||
schedules: [
|
||||
{
|
||||
id: "sch_a",
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
timesOfDay: ["08:00"],
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(landingStepForEdit(payload, "cadence")).toBe(8);
|
||||
});
|
||||
|
||||
it("removeSchedule refuses when schedules.length === 1", () => {
|
||||
const payload = emptyWizardPayload();
|
||||
expect(payload.schedules).toHaveLength(1);
|
||||
|
||||
@@ -879,36 +879,12 @@ export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload
|
||||
}
|
||||
|
||||
/**
|
||||
* Decision the dialog uses to pick the landing step on edit-hydrate.
|
||||
*
|
||||
* Three call-site intents:
|
||||
*
|
||||
* - `"name"` (default header pencil): land on Step 1 so the user
|
||||
* edits the medication's name, dose, treatment-class as a single
|
||||
* top-down walk. Multi-schedule medications still jump to the
|
||||
* summary list because the muscle memory there is "see every
|
||||
* parallel schedule first".
|
||||
* - `"cadence"`: land directly on Step 5 (the cadence picker). The
|
||||
* v1.5.5 detail page's cadence-summary row uses this so a tap on
|
||||
* the row's edit pencil drops the user one screen away from the
|
||||
* cadence they wanted to change, not two screens away after the
|
||||
* intro + summary.
|
||||
* - undefined: legacy behaviour — Step 1 for single-schedule, Step 8
|
||||
* for multi-schedule.
|
||||
*
|
||||
* One-shot medications collapse Step 5 out of the path, so a
|
||||
* `"cadence"` intent on a one-shot payload falls back to Step 8
|
||||
* (the summary, where the user can flip the one-shot toggle).
|
||||
* Decision the dialog uses to pick the landing step on edit-hydrate:
|
||||
* Step 1 for a single-schedule medication (a straight top-down walk),
|
||||
* Step 8 (the summary list) for a multi-schedule one, where the muscle
|
||||
* memory is "see every parallel schedule first".
|
||||
*/
|
||||
export function landingStepForEdit(
|
||||
payload: WizardPayload,
|
||||
intent?: "cadence" | "summary" | "name",
|
||||
): number {
|
||||
if (intent === "cadence") {
|
||||
return payload.mode === "oneShot" ? 8 : 5;
|
||||
}
|
||||
if (intent === "summary") return 8;
|
||||
if (intent === "name") return 1;
|
||||
export function landingStepForEdit(payload: WizardPayload): number {
|
||||
return payload.schedules.length > 1 ? 8 : 1;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user