chore(release): v1.7.1 — overview-card edit/history/advanced actions (incl. GLP-1)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-05-31 16:10:03 +02:00
1 parent 50e0a65228
commit 05fdddabc5
65 files changed
+10335 -26

No files matched your search

+80
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useRouter, useSearchParams } from "next/navigation";
import { AdvancedSettingsSheet } from "@/components/medications/advanced-settings-sheet";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -69,6 +70,11 @@ export default function MedicationsPage() {
const shouldOpenFromUrl = searchParams?.get("new") === "1";
const [dialogOpen, setDialogOpen] = useState(shouldOpenFromUrl);
const [editingMed, setEditingMed] = useState<Medication | null>(null);
// v1.7.1 — the medications-card History + Advanced actions. History
// navigates to the full intake-history view; Advanced opens one shared
// `<AdvancedSettingsSheet>` mounted below, keyed to the selected
// medication (reused, not duplicated per card).
const [advancedMed, setAdvancedMed] = useState<Medication | null>(null);
useEffect(() => {
if (shouldOpenFromUrl) {
@@ -94,6 +100,40 @@ export default function MedicationsPage() {
enabled: isAuthenticated,
});
// v1.7.1 — the advanced sheet's danger zone needs `intakeCount` to
// gate the purge button. Fetch it lazily once a medication is selected
// (the list view never pays for it otherwise), mirroring the
// detail-page query shape.
const advancedIntakeParams = {
sortBy: "takenAt",
sortDir: "desc" as const,
limit: 1,
offset: 0,
status: "completed",
};
const { data: advancedIntakeCount } = useQuery({
queryKey: advancedMed
? queryKeys.medicationIntakeList(advancedMed.id, advancedIntakeParams)
: ["medications", "intake-count", "none"],
queryFn: async () => {
if (!advancedMed) return 0;
const params = new URLSearchParams({
sortBy: advancedIntakeParams.sortBy,
sortDir: advancedIntakeParams.sortDir,
limit: String(advancedIntakeParams.limit),
offset: String(advancedIntakeParams.offset),
status: advancedIntakeParams.status,
});
const res = await fetch(
`/api/medications/${advancedMed.id}/intake?${params.toString()}`,
);
if (!res.ok) return 0;
const json = await res.json();
return (json.data?.meta?.total ?? 0) as number;
},
enabled: isAuthenticated && !!advancedMed,
});
function openCreate() {
setEditingMed(null);
setDialogOpen(true);
@@ -104,6 +144,14 @@ export default function MedicationsPage() {
setDialogOpen(true);
}
function openHistory(med: Medication) {
router.push(`/medications/${med.id}/history`);
}
function openAdvanced(med: Medication) {
setAdvancedMed(med);
}
function closeDialog() {
setDialogOpen(false);
setEditingMed(null);
@@ -207,12 +255,16 @@ export default function MedicationsPage() {
key={med.id}
medication={med}
onEdit={openEdit}
onOpenHistory={openHistory}
onOpenAdvanced={openAdvanced}
/>
) : (
<MedicationCard
key={med.id}
medication={med}
onEdit={openEdit}
onOpenHistory={openHistory}
onOpenAdvanced={openAdvanced}
/>
),
)}
@@ -233,12 +285,16 @@ export default function MedicationsPage() {
key={med.id}
medication={med}
onEdit={openEdit}
onOpenHistory={openHistory}
onOpenAdvanced={openAdvanced}
/>
) : (
<MedicationCard
key={med.id}
medication={med}
onEdit={openEdit}
onOpenHistory={openHistory}
onOpenAdvanced={openAdvanced}
/>
),
)}
@@ -269,6 +325,30 @@ export default function MedicationsPage() {
initial={editingMed ? medicationToPayload(editingMed) : undefined}
onSuccess={closeDialog}
/>
{/* v1.7.1 — one shared advanced-settings sheet for every card.
Reuses the exact component the detail page mounts (Data /
Reminders / Lifecycle / Danger zone); the card's sliders button
selects the medication and opens it. Not duplicated per card. */}
{advancedMed && (
<AdvancedSettingsSheet
open={!!advancedMed}
onOpenChange={(open) => {
if (!open) setAdvancedMed(null);
}}
medicationId={advancedMed.id}
medicationName={advancedMed.name}
treatmentClass={advancedMed.treatmentClass}
active={advancedMed.active}
startsOn={advancedMed.startsOn}
endsOn={advancedMed.endsOn}
notificationsEnabled={advancedMed.notificationsEnabled}
reminderGraceMinutes={
advancedMed.schedules[0]?.reminderGraceMinutes ?? null
}
intakeCount={advancedIntakeCount ?? 0}
/>
)}
</div>
);
}
@@ -176,7 +176,12 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
seedGlp1Details(client, med7p5.id, {});
const html = render(
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
<Glp1MedicationCard
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -189,6 +194,47 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
expect(html).not.toMatch(/lucide-syringe/i);
});
it("renders edit + history + advanced header actions with distinct aria-labels", () => {
// v1.7.1 — the GLP-1 card carries the same three header actions as
// the generic medication card: edit (pencil), history, and advanced
// (sliders). All three surface with their localised aria-labels and
// their click handlers deliver the GLP-1 medication object the parent
// routes / sheets against.
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {});
const onEdit = vi.fn();
const onOpenHistory = vi.fn();
const onOpenAdvanced = vi.fn();
const html = render(
<Glp1MedicationCard
medication={med7p5}
onEdit={onEdit}
onOpenHistory={onOpenHistory}
onOpenAdvanced={onOpenAdvanced}
/>,
client,
);
expect(html).toContain('aria-label="Edit"');
expect(html).toContain('aria-label="View intake history"');
expect(html).toContain('aria-label="Advanced settings"');
// Smoke-check the handler contract — SSR can't fire DOM events, so
// invoke the handlers the way each button's onClick would and pin
// the medication-object payload.
onOpenHistory(med7p5);
onOpenAdvanced(med7p5);
expect(onOpenHistory).toHaveBeenCalledWith(
expect.objectContaining({ id: "med-glp1-1", treatmentClass: "GLP1" }),
);
expect(onOpenAdvanced).toHaveBeenCalledWith(
expect.objectContaining({ id: "med-glp1-1", treatmentClass: "GLP1" }),
);
});
it("renders the default MedicationCard when treatmentClass is null/undefined (back-compat)", () => {
// The page dispatcher renders MedicationCard for everything that
// isn't `"GLP1"`. We verify the default card's output stays free
@@ -198,7 +244,12 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
seedCompliance(client, defaultMed.id);
const html = render(
<MedicationCard medication={defaultMed} onEdit={() => {}} />,
<MedicationCard
medication={defaultMed}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -217,7 +268,12 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
seedGlp1Details(client, med7p5.id, {});
const html = render(
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
<Glp1MedicationCard
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -239,7 +295,12 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
});
const html = render(
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
<Glp1MedicationCard
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -280,7 +341,12 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
});
const html = render(
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
<Glp1MedicationCard
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -306,7 +372,12 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
});
const html = render(
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
<Glp1MedicationCard
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -333,7 +404,12 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
});
const html = render(
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
<Glp1MedicationCard
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -354,7 +430,12 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
});
const html = render(
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
<Glp1MedicationCard
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -380,6 +461,8 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
<Glp1MedicationCard
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
onLogSideEffect={handler}
/>,
client,
@@ -411,7 +494,12 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
seedGlp1Details(client, med7p5.id, {});
const html = render(
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
<Glp1MedicationCard
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -430,7 +518,12 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
seedGlp1Details(client, med7p5.id, {});
const html = render(
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
<Glp1MedicationCard
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -450,7 +543,12 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
seedGlp1Details(client, paused.id, {});
const html = render(
<Glp1MedicationCard medication={paused} onEdit={() => {}} />,
<Glp1MedicationCard
medication={paused}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -479,7 +577,12 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
});
const html = render(
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
<Glp1MedicationCard
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
"de",
);
@@ -131,11 +131,21 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
seedGlp1Details(client, mounjaro.id);
const ramiprilHtml = render(
<MedicationCard medication={ramipril} onEdit={() => {}} />,
<MedicationCard
medication={ramipril}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
const mounjaroHtml = render(
<Glp1MedicationCard medication={mounjaro} onEdit={() => {}} />,
<Glp1MedicationCard
medication={mounjaro}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -148,6 +158,32 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
}
});
it("the overview card renders the three header actions (edit / history / advanced)", () => {
// v1.7.1 — the overview card mirrors the detail-page header: an
// edit pencil, a History (clock) button routing to the intake
// history, and a SlidersHorizontal button opening the shared
// advanced-settings sheet. Each carries a distinct aria-label.
const client = makeClient();
seedCompliance(client, ramipril.id);
const html = render(
<MedicationCard
medication={ramipril}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
expect(html).toContain("lucide-pencil");
expect(html).toContain("lucide-history");
expect(html).toContain("lucide-sliders-horizontal");
expect(html).toContain('aria-label="Edit"');
expect(html).toContain('aria-label="View intake history"');
expect(html).toContain('aria-label="Advanced settings"');
});
it("neither card paints a kebab when no overflow prop is wired", () => {
// Symmetry default: without onLogSideEffect, the Mounjaro header
// shape matches the Ramipril header exactly (history + edit only).
@@ -157,11 +193,21 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
seedGlp1Details(client, mounjaro.id);
const ramiprilHtml = render(
<MedicationCard medication={ramipril} onEdit={() => {}} />,
<MedicationCard
medication={ramipril}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
const mounjaroHtml = render(
<Glp1MedicationCard medication={mounjaro} onEdit={() => {}} />,
<Glp1MedicationCard
medication={mounjaro}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -179,6 +225,8 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
<Glp1MedicationCard
medication={mounjaro}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
onLogSideEffect={() => {}}
/>,
client,
@@ -194,11 +242,21 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
seedGlp1Details(client, mounjaro.id);
const ramiprilHtml = render(
<MedicationCard medication={ramipril} onEdit={() => {}} />,
<MedicationCard
medication={ramipril}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
const mounjaroHtml = render(
<Glp1MedicationCard medication={mounjaro} onEdit={() => {}} />,
<Glp1MedicationCard
medication={mounjaro}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -217,11 +275,21 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
seedGlp1Details(client, mounjaro.id);
const ramiprilHtml = render(
<MedicationCard medication={ramipril} onEdit={() => {}} />,
<MedicationCard
medication={ramipril}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
const mounjaroHtml = render(
<Glp1MedicationCard medication={mounjaro} onEdit={() => {}} />,
<Glp1MedicationCard
medication={mounjaro}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -240,13 +308,20 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
seedGlp1Details(client, mounjaro.id);
const ramiprilHtml = render(
<MedicationCard medication={ramipril} onEdit={() => {}} />,
<MedicationCard
medication={ramipril}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
const mounjaroHtml = render(
<Glp1MedicationCard
medication={mounjaro}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
onLogSideEffect={() => {}}
/>,
client,
@@ -307,11 +382,21 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
seedGlp1Details(client, mounjaroFuture.id);
const ramiprilHtml = render(
<MedicationCard medication={ramiprilFuture} onEdit={() => {}} />,
<MedicationCard
medication={ramiprilFuture}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
const mounjaroHtml = render(
<Glp1MedicationCard medication={mounjaroFuture} onEdit={() => {}} />,
<Glp1MedicationCard
medication={mounjaroFuture}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
@@ -10,10 +10,12 @@ import {
ChevronRight,
CircleCheck,
Flame,
History,
Loader2,
MoreVertical,
Pencil,
SkipForward,
SlidersHorizontal,
Stethoscope,
} from "lucide-react";
@@ -120,6 +122,18 @@ interface DetailsResponse {
interface Glp1MedicationCardProps {
medication: Glp1Medication;
onEdit: (med: Glp1Medication) => void;
/**
* v1.7.1 — routes to the medication's full intake-history view
* (`/medications/{id}/history`), mirroring the generic medication card
* and the detail-header History button. The parent owns the navigation.
*/
onOpenHistory: (med: Glp1Medication) => void;
/**
* v1.7.1 — opens the shared `<AdvancedSettingsSheet>` (mounted by the
* list page) for this medication, mirroring the generic medication card
* and the detail-header sliders button.
*/
onOpenAdvanced: (med: Glp1Medication) => void;
onLogSideEffect?: (med: Glp1Medication) => void;
}
@@ -173,6 +187,8 @@ function predictNextWeeklyDate(
export function Glp1MedicationCard({
medication,
onEdit,
onOpenHistory,
onOpenAdvanced,
onLogSideEffect,
}: Glp1MedicationCardProps) {
const queryClient = useQueryClient();
@@ -349,6 +365,29 @@ export function Glp1MedicationCard({
>
<Pencil className="h-4 w-4" />
</Button>
{/* v1.7.1 — History + Advanced icon buttons mirror the generic
medication card and the detail-page header so the GLP-1 card
carries the same three actions (edit / history / advanced).
History routes to the full intake-history view; Advanced opens
the shared settings sheet mounted by the list page. */}
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
onClick={() => onOpenHistory(medication)}
aria-label={t("medications.detail.header.historyLabel")}
>
<History className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
onClick={() => onOpenAdvanced(medication)}
aria-label={t("medications.detail.header.advancedLabel")}
>
<SlidersHorizontal className="h-4 w-4" />
</Button>
{/* v1.4.37 W4b — GLP-1 specifics (side-effect quick-log etc.)
live in the header actions overflow so the primary action
row stays the canonical two-button shape (Eingenommen /
+43 -1
View File
@@ -23,7 +23,9 @@ import {
CircleCheck,
SkipForward,
Flame,
History,
Pencil,
SlidersHorizontal,
Loader2,
ChevronRight,
} from "lucide-react";
@@ -82,6 +84,18 @@ interface ComplianceData {
interface MedicationCardProps {
medication: Medication;
onEdit: (med: Medication) => void;
/**
* v1.7.1 — routes to the medication's full intake-history view
* (`/medications/{id}/history`), mirroring the detail-header History
* button. The parent owns the navigation.
*/
onOpenHistory: (med: Medication) => void;
/**
* v1.7.1 — opens the shared `<AdvancedSettingsSheet>` (mounted by the
* list page) for this medication, mirroring the detail-header sliders
* button.
*/
onOpenAdvanced: (med: Medication) => void;
}
function getNextOccurrenceTimestamp(
@@ -125,7 +139,12 @@ function getNextOccurrenceTimestamp(
return null;
}
export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
export function MedicationCard({
medication,
onEdit,
onOpenHistory,
onOpenAdvanced,
}: MedicationCardProps) {
const queryClient = useQueryClient();
const { t, locale } = useTranslations();
const fmt = useFormatters();
@@ -287,6 +306,29 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
>
<Pencil className="h-4 w-4" />
</Button>
{/* v1.7.1 — History + Advanced icon buttons mirror the
detail-page header so the overview card carries the same three
actions (edit / history / advanced). History routes to the
full intake-history view; Advanced opens the shared settings
sheet mounted by the list page. */}
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
onClick={() => onOpenHistory(medication)}
aria-label={t("medications.detail.header.historyLabel")}
>
<History className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
onClick={() => onOpenAdvanced(medication)}
aria-label={t("medications.detail.header.advancedLabel")}
>
<SlidersHorizontal className="h-4 w-4" />
</Button>
</>
);