Keep dose marking under a switch, and nothing else on medications

Marking a dose taken or skipped is the verb the delegation was written for:
somebody looking after a parent confirms the morning tablet. Adding a
medication and noting a side effect come with it. The card menu keeps only
what survives and disappears entirely when nothing does.

Four detail tabs leave the registry inside somebody else's record, because
their whole content is owner work: the schedule editor and its plan history,
the supply ledger, the external-token row, and the advanced surface holding
the wizard, the import and the destructive zone. A stale deep link into one
lands on the landing tab, the way an inapplicable tab already does.

The take-all-due sweep goes too. It rides the bulk intake route, which the
delegation does not name, and a control that half-works is worse than one
that is not offered.
This commit is contained in:
Marc-André Bombeck committed 2026-08-03 12:11:52 +02:00
1 parent 7817632385
commit ab53d42874
14 files changed
+394 -256

No files matched your search

+51 -39
View File
@@ -1,5 +1,6 @@
"use client";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useCallback, useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
@@ -159,6 +160,11 @@ function MedicationCardSkeleton() {
export default function MedicationsPageClient() {
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
const { t } = useTranslations();
// v1.36.x — adding a medication and marking a dose are both admitted
// delegated writes, so a WRITE delegate keeps the Add menu whole. The
// take-all-due sweep is not (it rides the bulk intake route), and neither is
// the customize page, which sharing does not cover at all.
const { canAdd, canManage } = useRecordCapabilities();
// v1.18.1 (D3) — medications is an opt-out module. When the account has it
// turned off the whole page disappears (the nav entry is hidden by the same
// gate) and the list query never fires (the API would 403 anyway).
@@ -417,7 +423,7 @@ export default function MedicationsPageClient() {
card's own one-tap job). Calm outline variant so the primary
Add button keeps the visual lead; same responsive 44-px
mobile tap floor as its neighbours. */}
{dueMeds.length >= 2 && (
{canManage && dueMeds.length >= 2 && (
<Button
variant="outline"
onClick={() => setTakeAllOpen(true)}
@@ -433,47 +439,51 @@ export default function MedicationsPageClient() {
Same glyph, slot (left of the add button) and responsive
44-px mobile tap floor as the dashboard and insights
headers. */}
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/layout/medications"
aria-label={t("medications.customize")}
title={t("medications.customize")}
{canManage && (
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<Link
href="/settings/layout/medications"
aria-label={t("medications.customize")}
title={t("medications.customize")}
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
)}
{/* v1.14.0 — the "Add" button is now a choice: log an intake
(incl. a backdated one) against an existing medication, or
create a new medication. v1.12.2 — match the dashboard Add
button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so
both primary "add" entry points clear the WCAG 2.5.5 44px mobile
minimum identically. */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="min-h-11 sm:min-h-9">
<Plus className="h-4 w-4" />
{t("medications.addMedication")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => setLogIntakeOpen(true)}
disabled={activeMeds.length === 0}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
{t("medications.addChoice.logIntake")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={openCreate}>
<Pill className="mr-2 h-4 w-4" />
{t("medications.addChoice.newMedication")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{canAdd && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="min-h-11 sm:min-h-9">
<Plus className="h-4 w-4" />
{t("medications.addMedication")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => setLogIntakeOpen(true)}
disabled={activeMeds.length === 0}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
{t("medications.addChoice.logIntake")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={openCreate}>
<Pill className="mr-2 h-4 w-4" />
{t("medications.addChoice.newMedication")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
@@ -511,10 +521,12 @@ export default function MedicationsPageClient() {
title={t("medications.emptyTitle")}
description={t("medications.emptyDescription")}
action={
<Button size="sm" onClick={openCreate}>
<Plus className="h-4 w-4" />
{t("medications.firstMedication")}
</Button>
canAdd ? (
<Button size="sm" onClick={openCreate}>
<Plus className="h-4 w-4" />
{t("medications.firstMedication")}
</Button>
) : undefined
}
/>
) : tableView ? (
+64 -52
View File
@@ -25,6 +25,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { getHourForTimeZone } from "@/components/dashboard/range-display";
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
@@ -37,6 +38,11 @@ export function DashboardHeader({
const { user } = useAuth();
const mounted = useMounted();
const nutrientsEnabled = useModuleEnabled("nutrients");
// v1.36.x — the dashboard quick-add offers four kinds; a delegation admits
// two of them (a reading, a dose). Mood and water stay with the account
// whose record it is, and the customize shortcut points at a settings page
// sharing does not cover at all.
const { canAdd, canManage } = useRecordCapabilities();
// The pre-hero greeting derivation, kept hydration-safe: `user` comes
// from the auth query, which can resolve before this boundary
@@ -87,24 +93,27 @@ export function DashboardHeader({
a `min-h-11 min-w-11` mobile floor so it meets the 44 px
touch-target contract the add button also honours, shrinking
back to the 40 px icon footprint on sm+. */}
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
data-testid="dashboard-customize-shortcut"
>
<Link
href="/settings/layout/dashboard"
aria-label={t("dashboard.customizeDashboard")}
title={t("dashboard.customizeDashboard")}
{canManage && (
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
data-testid="dashboard-customize-shortcut"
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* v1.4.33 maintainer-item-7 — restore proportional sizing
<Link
href="/settings/layout/dashboard"
aria-label={t("dashboard.customizeDashboard")}
title={t("dashboard.customizeDashboard")}
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
)}
{canAdd && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* v1.4.33 maintainer-item-7 — restore proportional sizing
across viewports. The v1.4.27 fix pinned a `size="sm"
min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target
contract on mobile, but `size="sm"` is h-8 (32 px) and
@@ -116,53 +125,56 @@ export function DashboardHeader({
44 px tall under finger pressure and shrinks back to
the desktop-friendly 36 px on `sm:` upwards. The icon
+ label keep the same visual contract. */}
<Button
size="default"
className="min-h-11 sm:min-h-9"
data-tour-id="dashboard-quick-add"
<Button
size="default"
className="min-h-11 sm:min-h-9"
data-tour-id="dashboard-quick-add"
>
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-w-[calc(100vw-2rem)]"
>
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-w-[calc(100vw-2rem)]"
>
{/* Menu items must each carry a self-contained verb-phrase
{/* Menu items must each carry a self-contained verb-phrase
("Log measurement", "Log mood") — the trigger above already
says "Add", and the icon is `aria-hidden`, so the visible
text is the only thing distinguishing the rows. v1.4.15
phase-A3 fix #1 hardened this with a unit guard at
`src/app/__tests__/quick-add-labels.test.ts` — both labels
must differ from each other AND from `common.add`. */}
<DropdownMenuItem onClick={() => onQuickEntry("measurement")}>
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMeasurement")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMood")}
</DropdownMenuItem>
{/* v1.4.37 W7b — third quick-add row: medication intake.
<DropdownMenuItem onClick={() => onQuickEntry("measurement")}>
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMeasurement")}
</DropdownMenuItem>
{canManage && (
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMood")}
</DropdownMenuItem>
)}
{/* v1.4.37 W7b — third quick-add row: medication intake.
Same Sheet-on-mobile / Dialog-on-desktop primitive as
the other two; the menu label is a self-contained
verb-phrase so it doesn't collide with the trigger or
the sibling rows (cf. quick-add-labels.test.ts). */}
<DropdownMenuItem
onClick={() => onQuickEntry("medicationIntake")}
>
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMedicationIntake")}
</DropdownMenuItem>
{nutrientsEnabled && (
<DropdownMenuItem onClick={() => onQuickEntry("water")}>
<GlassWater className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddWater")}
<DropdownMenuItem
onClick={() => onQuickEntry("medicationIntake")}
>
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMedicationIntake")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{nutrientsEnabled && canManage && (
<DropdownMenuItem onClick={() => onQuickEntry("water")}>
<GlassWater className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddWater")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</>
}
/>
@@ -40,10 +40,16 @@ function makeClient(): QueryClient {
}
function render(node: React.ReactNode, client?: QueryClient) {
const tree = client ? (
<QueryClientProvider client={client}>{node}</QueryClientProvider>
) : (
node
// v1.36.x — every card part now asks what the record on screen allows
// before it renders a control, and that answer rides the account query.
// The parts that carry no query of their own were rendered here without a
// provider; they need one now. Fixtures are the caller's own record, which
// is every capability — the delegation cases live in
// `src/components/__tests__/delegated-write-affordances.test.tsx`.
const tree = (
<QueryClientProvider client={client ?? makeClient()}>
{node}
</QueryClientProvider>
);
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{tree}</I18nProvider>,
@@ -2,6 +2,7 @@ import { Check, Loader2, SkipForward } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
interface MedicationIntakeActionsProps {
/** "take" | "skip" while the matching request is in flight, else null. */
@@ -27,6 +28,11 @@ export function MedicationIntakeActions({
onRecordIntake,
}: MedicationIntakeActionsProps) {
const { t } = useTranslations();
// v1.36.x — marking a dose taken or skipped is the verb the delegation was
// written for. A read-only delegate sees the card and its schedule and no
// action row; the card is otherwise unchanged.
const { canAdd } = useRecordCapabilities();
if (!canAdd) return null;
return (
<div className="flex gap-2">
@@ -31,6 +31,7 @@
* administration.
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import {
useCallback,
useEffect,
@@ -171,6 +172,19 @@ const TAB_SLUGS = [
] as const;
type TabSlug = (typeof TAB_SLUGS)[number];
/**
* Tabs whose entire content is owner-only work: the schedule editor and its
* plan history, the supply ledger, the external-token row, and the advanced
* surface (wizard, import, pause, end, purge, delete). A delegation admits
* none of it at any level.
*/
const OWNER_ONLY_TABS: ReadonlySet<TabSlug> = new Set([
"zeitplan",
"bestand",
"api",
"erweitert",
]);
/**
* Legacy slugs that point at a dissolved tab. The Erinnerung tab folded
* into Zeitplan in v1.15.20; old deep-links keep landing on the surface
@@ -222,6 +236,7 @@ export function MedicationDetailTabs({
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const { canManage } = useRecordCapabilities();
const router = useRouter();
const searchParams = useSearchParams();
@@ -273,15 +288,23 @@ export function MedicationDetailTabs({
// reminds, so the Zeitplan tab (times editor + reminder card +
// plan-history) does not apply and is dropped from the registry; a
// stale `?tab=zeitplan` deep-link falls back to the landing tab.
// v1.36.x — four tabs are nothing BUT owner work: the schedule editor, the
// supply ledger, the external-token row and the advanced surface that holds
// the wizard, the import and the destructive zone. None of it is delegated,
// so inside somebody else's record they leave the registry entirely rather
// than opening onto controls the server refuses — which also means a stale
// `?tab=erweitert` deep link lands on the landing tab, the same way an
// inapplicable tab already does.
const availableTabs = useMemo<TabSlug[]>(
() =>
TAB_SLUGS.filter(
(slug) =>
(slug !== "injektion" || isInjectable) &&
(slug !== "zeitplan" || !asNeeded) &&
(slug !== "wirkung" || hasEfficacyTarget),
(slug !== "wirkung" || hasEfficacyTarget) &&
(canManage || !OWNER_ONLY_TABS.has(slug)),
),
[isInjectable, asNeeded, hasEfficacyTarget],
[isInjectable, asNeeded, hasEfficacyTarget, canManage],
);
const requestedRaw = searchParams?.get("tab");
+125 -109
View File
@@ -39,6 +39,7 @@
* the ledger uses the semantic feedback vocabulary on the row, not the card.
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useCallback, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -162,6 +163,10 @@ export function DoseHistoryLedger({
const fmt = useFormatters();
const dateFormatPref = useDateFormatPreference();
const { user } = useAuth();
// v1.36.x — a delegate may record a dose, which is what "add" and the
// inline "mark taken" do. Editing, deleting, pinning and un-skipping an
// existing event are the owner's, so the row menu is absent for them.
const { canAdd } = useRecordCapabilities();
const queryClient = useQueryClient();
const timeZone = user?.timezone || "Europe/Berlin";
@@ -413,16 +418,18 @@ export function DoseHistoryLedger({
{t("medications.detail.verlauf.summaryEmpty")}
</p>
)}
<Button
variant="outline"
size="sm"
onClick={() => setAddOpen(true)}
className="min-h-11 shrink-0 sm:min-h-9"
data-slot="ledger-add"
>
<Plus aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.verlauf.add")}
</Button>
{canAdd && (
<Button
variant="outline"
size="sm"
onClick={() => setAddOpen(true)}
className="min-h-11 shrink-0 sm:min-h-9"
data-slot="ledger-add"
>
<Plus aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.verlauf.add")}
</Button>
)}
</div>
{isLoading && (
@@ -571,6 +578,7 @@ function LedgerRowItem({
onUnpin: (eventId: string) => void;
}) {
const { t } = useTranslations();
const { canAdd, canManage } = useRecordCapabilities();
const fmt = useFormatters();
const actionable = isSlotActionable(row);
@@ -676,7 +684,7 @@ function LedgerRowItem({
</div>
<div className="flex shrink-0 items-center gap-1">
{showTakenButton && (
{showTakenButton && canAdd && (
<Button
variant="outline"
size="sm"
@@ -697,118 +705,126 @@ function LedgerRowItem({
{t("medications.detail.verlauf.markTaken")}
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
aria-label={t("medications.detail.verlauf.rowActionsLabel", {
time: timeLabel,
})}
data-slot="ledger-row-menu"
>
<MoreVertical aria-hidden="true" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" data-slot="ledger-row-menu-content">
{actionable && !showTakenButton && (
<DropdownMenuItem
onClick={() => onMark(row, "taken")}
data-slot="ledger-menu-taken"
>
<Check aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.verlauf.markTaken")}
</DropdownMenuItem>
)}
{actionable && (
<DropdownMenuItem
onClick={() => onMark(row, "skipped")}
data-slot="ledger-mark-skipped"
>
{skipBusy ? (
<Loader2
aria-hidden="true"
className="mr-2 size-4 animate-spin motion-reduce:animate-none"
/>
) : (
<SkipForward aria-hidden="true" className="mr-2 size-4" />
)}
{t("medications.detail.verlauf.markSkipped")}
</DropdownMenuItem>
)}
{canPin && (
<DropdownMenuItem
onClick={() => onPin(intake!.id as string, dueContext!.at)}
data-slot="ledger-pin"
>
<Link2 aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.verlauf.pin.action", {
time: fmt.time(new Date(dueContext!.at)),
{canManage && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
aria-label={t("medications.detail.verlauf.rowActionsLabel", {
time: timeLabel,
})}
</DropdownMenuItem>
)}
{canUnpin && (
<DropdownMenuItem
onClick={() => onUnpin(intake!.id as string)}
data-slot="ledger-unpin"
data-slot="ledger-row-menu"
>
<Unlink aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.verlauf.pin.release")}
</DropdownMenuItem>
)}
{intake?.id && (
<>
<MoreVertical aria-hidden="true" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
data-slot="ledger-row-menu-content"
>
{actionable && !showTakenButton && (
<DropdownMenuItem
onClick={() =>
onEdit({
id: intake.id as string,
takenAt: intake.takenAt,
skipped: intake.skipped,
scheduledFor: intake.scheduledFor,
})
}
data-slot="ledger-edit"
onClick={() => onMark(row, "taken")}
data-slot="ledger-menu-taken"
>
<Pencil aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.intake.rowActions.edit")}
<Check aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.verlauf.markTaken")}
</DropdownMenuItem>
{(row.status === "skipped" ||
row.status === "taken_on_time" ||
row.status === "taken_late") && (
)}
{actionable && (
<DropdownMenuItem
onClick={() => onMark(row, "skipped")}
data-slot="ledger-mark-skipped"
>
{skipBusy ? (
<Loader2
aria-hidden="true"
className="mr-2 size-4 animate-spin motion-reduce:animate-none"
/>
) : (
<SkipForward aria-hidden="true" className="mr-2 size-4" />
)}
{t("medications.detail.verlauf.markSkipped")}
</DropdownMenuItem>
)}
{canPin && (
<DropdownMenuItem
onClick={() => onPin(intake!.id as string, dueContext!.at)}
data-slot="ledger-pin"
>
<Link2 aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.verlauf.pin.action", {
time: fmt.time(new Date(dueContext!.at)),
})}
</DropdownMenuItem>
)}
{canUnpin && (
<DropdownMenuItem
onClick={() => onUnpin(intake!.id as string)}
data-slot="ledger-unpin"
>
<Unlink aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.verlauf.pin.release")}
</DropdownMenuItem>
)}
{intake?.id && (
<>
<DropdownMenuItem
onClick={() =>
onToggleSkipped({
onEdit({
id: intake.id as string,
takenAt: intake.takenAt,
skipped: intake.skipped,
scheduledFor: intake.scheduledFor,
})
}
data-slot="ledger-toggle-skipped"
data-slot="ledger-edit"
>
{intake.skipped ? (
<Check aria-hidden="true" className="mr-2 size-4" />
) : (
<SkipForward aria-hidden="true" className="mr-2 size-4" />
)}
{intake.skipped
? t("medications.detail.verlauf.markAsTaken")
: t("medications.detail.verlauf.markAsSkipped")}
<Pencil aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.intake.rowActions.edit")}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(intake.id as string)}
className="text-destructive focus:text-destructive"
data-slot="ledger-delete"
>
<Trash2 aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.intake.rowActions.delete")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{(row.status === "skipped" ||
row.status === "taken_on_time" ||
row.status === "taken_late") && (
<DropdownMenuItem
onClick={() =>
onToggleSkipped({
id: intake.id as string,
skipped: intake.skipped,
scheduledFor: intake.scheduledFor,
})
}
data-slot="ledger-toggle-skipped"
>
{intake.skipped ? (
<Check aria-hidden="true" className="mr-2 size-4" />
) : (
<SkipForward
aria-hidden="true"
className="mr-2 size-4"
/>
)}
{intake.skipped
? t("medications.detail.verlauf.markAsTaken")
: t("medications.detail.verlauf.markAsSkipped")}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(intake.id as string)}
className="text-destructive focus:text-destructive"
data-slot="ledger-delete"
>
<Trash2 aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.intake.rowActions.delete")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</li>
);
@@ -17,6 +17,7 @@
* never float to the top of the table (O-1).
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Trash2 } from "lucide-react";
@@ -61,6 +62,8 @@ export function IntakeHistoryEditable({
defaultSortBy,
}: IntakeHistoryEditableProps) {
const { t } = useTranslations();
// v1.36.x — bulk-deleting recorded doses is the owner's alone.
const { canManage } = useRecordCapabilities();
const queryClient = useQueryClient();
const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -132,7 +135,7 @@ export function IntakeHistoryEditable({
return (
<>
<div className="space-y-3" data-slot="intake-history-editable-body">
{selected.size > 0 && (
{canManage && selected.size > 0 && (
<div
className="border-border bg-muted/40 flex flex-wrap items-center justify-between gap-2 rounded-md border p-2"
role="region"
@@ -1,5 +1,6 @@
"use client";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useQuery } from "@tanstack/react-query";
import { useState, useMemo } from "react";
import Link from "next/link";
@@ -586,6 +587,9 @@ function IntakeRowActions({
onDeleteIntake?: (eventId: string) => void;
t: ReturnType<typeof useTranslations>["t"];
}) {
// v1.36.x — editing or deleting a recorded dose is the owner's.
const { canManage } = useRecordCapabilities();
if (!canManage) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -19,6 +19,7 @@
* the form state + the medication/slot pickers.
*/
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
import { useId, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
@@ -116,6 +117,7 @@ export function LogIntakeDialog({
medications,
}: LogIntakeDialogProps) {
const { t } = useTranslations();
const recordName = useActiveRecordName();
const queryClient = useQueryClient();
const { user } = useAuth();
const userTz = user?.timezone || "Europe/Berlin";
@@ -210,6 +212,7 @@ export function LogIntakeDialog({
...(doseDeviates && { doseTaken: trimmedDose }),
t,
queryClient,
recordName,
// v1.30.1 M7 — a backdated/manual intake now gets the same
// Undo affordance the card's take/skip path already has.
undoIntake: (eventId) =>
@@ -32,6 +32,7 @@
* navigation.
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { History, MoreVertical, Pencil, Stethoscope } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -57,6 +58,13 @@ export function MedicationCardMenu({
onLogSideEffect,
}: MedicationCardMenuProps) {
const { t } = useTranslations();
// v1.36.x — noting a side effect is an admitted delegated write; editing the
// medication and opening its history editor are not. When nothing in the
// menu survives, the trigger goes with it rather than opening onto an empty
// sheet. The card itself is untouched: no wash, no badge, no colour change.
const { canAdd, canManage } = useRecordCapabilities();
const showSideEffect = onLogSideEffect != null && canAdd;
if (!canManage && !showSideEffect) return null;
return (
<DropdownMenu>
@@ -79,17 +87,21 @@ export function MedicationCardMenu({
className="w-56"
data-slot="medication-card-menu"
>
<DropdownMenuItem onClick={onEdit}>
<Pencil className="mr-2 h-4 w-4" />
{t("common.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={onOpenHistory}>
<History className="mr-2 h-4 w-4" />
{t("medications.detail.header.historyLabel")}
</DropdownMenuItem>
{onLogSideEffect && (
{canManage && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onEdit}>
<Pencil className="mr-2 h-4 w-4" />
{t("common.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={onOpenHistory}>
<History className="mr-2 h-4 w-4" />
{t("medications.detail.header.historyLabel")}
</DropdownMenuItem>
</>
)}
{showSideEffect && onLogSideEffect && (
<>
{canManage && <DropdownMenuSeparator />}
<DropdownMenuItem onClick={onLogSideEffect}>
<Stethoscope className="mr-2 h-4 w-4" />
{t("medications.glp1LogSideEffect")}
@@ -1,5 +1,6 @@
"use client";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useEffect, useReducer, useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
@@ -402,6 +403,9 @@ function MedicationTableRowItem({
// failure toast + Undo. No card-specific follow-up here; the
// injection-site prompt stays a card affordance.
const { intakeLoading, recordIntake } = useMedicationIntake({ medication });
// v1.36.x — marking a dose is admitted; a read-only delegate sees the row
// without its action pair.
const { canAdd } = useRecordCapabilities();
// SAME batched compliance source as the cards.
const { data: compliance } = useMedicationComplianceSummary(medication.id);
@@ -675,7 +679,7 @@ function MedicationTableRowItem({
</TableCell>
<TableCell className="text-sm">{stockCell}</TableCell>
<TableCell>
{medication.active ? (
{medication.active && canAdd ? (
<div className="flex gap-1.5">
<Button
size="icon"
@@ -14,6 +14,7 @@
* redundant link is removed (R-medui §4.3).
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -34,6 +35,8 @@ export function IntakeHistoryPreview({
onImportOpenChange,
}: IntakeHistoryPreviewProps) {
const { t } = useTranslations();
// v1.36.x — importing dose history is not a delegated verb.
const { canManage } = useRecordCapabilities();
return (
<>
@@ -42,16 +45,18 @@ export function IntakeHistoryPreview({
title={t("medications.detail.intake.title")}
dataSlot="medication-detail-intake-history-section"
headerExtras={
<Button
variant="outline"
size="sm"
onClick={() => onImportOpenChange(true)}
className="min-h-11 sm:min-h-9"
data-slot="intake-history-import"
>
<Upload aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.intake.importButton")}
</Button>
canManage ? (
<Button
variant="outline"
size="sm"
onClick={() => onImportOpenChange(true)}
className="min-h-11 sm:min-h-9"
data-slot="intake-history-import"
>
<Upload aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.intake.importButton")}
</Button>
) : null
}
>
<IntakeHistoryEditable medicationId={medicationId} pageSize={14} />
@@ -1,5 +1,6 @@
"use client";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useId, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -111,6 +112,7 @@ function entryI18nKey(entry: MedicationSideEffectEntry): string {
export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
const { t } = useTranslations();
const { canAdd, canManage } = useRecordCapabilities();
const fmt = useFormatters();
const queryClient = useQueryClient();
@@ -245,17 +247,21 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
({items.length})
</span>
)}
<Button
size="sm"
variant="outline"
onClick={() => {
resetForm();
setOpen(true);
}}
>
<Plus className="h-3.5 w-3.5" />
{t("medications.sideEffects.addCta")}
</Button>
{/* v1.36.x — noting a side effect is an admitted delegated write.
Removing one that is already noted is not. */}
{canAdd && (
<Button
size="sm"
variant="outline"
onClick={() => {
resetForm();
setOpen(true);
}}
>
<Plus className="h-3.5 w-3.5" />
{t("medications.sideEffects.addCta")}
</Button>
)}
</>
);
@@ -340,16 +346,18 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
</p>
)}
</div>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive size-11 shrink-0"
aria-label={t("medications.sideEffects.deleteCta")}
onClick={() => setPendingDeleteId(row.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
{canManage && (
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive size-11 shrink-0"
aria-label={t("medications.sideEffects.deleteCta")}
onClick={() => setPendingDeleteId(row.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</li>
))}
</ul>
@@ -1,5 +1,6 @@
"use client";
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
import { useState } from "react";
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -94,6 +95,13 @@ export async function runRecordIntake(deps: {
setIntakeLoading: (value: string | null) => void;
undoIntake: (eventId: string) => void | Promise<void>;
onRecorded?: (eventId: string | undefined, skipped: boolean) => void;
/**
* v1.36.x — the record this dose was written into, when it is not the
* caller's own. Two consequences: the toast names it, and the Undo action
* goes. A delegate may record a dose and may not remove one, so an Undo
* they can see is an Undo the server refuses.
*/
recordName?: string | null;
}): Promise<void> {
const {
medication,
@@ -104,6 +112,7 @@ export async function runRecordIntake(deps: {
setIntakeLoading,
undoIntake,
onRecorded,
recordName,
} = deps;
setIntakeLoading(skipped ? "skip" : "take");
@@ -137,14 +146,20 @@ export async function runRecordIntake(deps: {
: "medications.intakeToastTaken",
{ name: medication.name },
),
eventId
recordName
? {
action: {
label: t("medications.intakeUndo"),
onClick: () => void undoIntake(eventId),
},
description: t("recordSharing.toast.savedTo", {
name: recordName,
}),
}
: undefined,
: eventId
? {
action: {
label: t("medications.intakeUndo"),
onClick: () => void undoIntake(eventId),
},
}
: undefined,
);
await invalidateMedicationReads(queryClient);
onRecorded?.(eventId, skipped);
@@ -195,6 +210,8 @@ export async function runLogIntake(deps: {
* carries no action when it's absent.
*/
undoIntake?: (eventId: string) => void | Promise<void>;
/** See {@link runRecordIntake}: names the record, and drops Undo. */
recordName?: string | null;
}): Promise<boolean> {
const {
medication,
@@ -205,6 +222,7 @@ export async function runLogIntake(deps: {
t,
queryClient,
undoIntake,
recordName,
} = deps;
try {
const body: Record<string, unknown> = { skipped };
@@ -230,7 +248,11 @@ export async function runLogIntake(deps: {
// a real Undo action to attach; keeps the no-undo call signature
// identical to the pre-fix behaviour (existing unit tests assert the
// single-argument call).
if (eventId && undoIntake) {
if (recordName) {
toast.success(message, {
description: t("recordSharing.toast.savedTo", { name: recordName }),
});
} else if (eventId && undoIntake) {
toast.success(message, {
action: {
label: t("medications.intakeUndo"),
@@ -273,6 +295,7 @@ export function useMedicationIntake({
}: UseMedicationIntakeParams): UseMedicationIntakeResult {
const queryClient = useQueryClient();
const { t } = useTranslations();
const recordName = useActiveRecordName();
const [intakeLoading, setIntakeLoading] = useState<string | null>(null);
const undoIntake = (eventId: string) =>
@@ -288,6 +311,7 @@ export function useMedicationIntake({
setIntakeLoading,
undoIntake,
onRecorded,
recordName,
});
return { intakeLoading, recordIntake, undoIntake };