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:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-29 22:07:10 +02:00
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;
}