Give the intake toast one decision, and stop admitting two writes nothing calls

Three surfaces record a dose and all three had to reach the same two
conclusions inside somebody else's record: name the record, and drop an
Undo the server refuses. Two learned it and the dose ledger did not,
because the ternary was written out three times. It is written once now,
in the file the other two already share, with its own test.

POST /api/allergies and POST /api/family-history leave the frozen
delegable write set. The argument that admitted them still stands — an
allergy is the single most useful thing a caregiver can contribute — but
the only surface that posts to either lives in Settings, which a switch
closes, so no delegate could reach the form at any level. That is a
permission frozen ahead of the caller for it, and the list is built the
other way round on purpose.

Both delegable READ arms stay. A caregiver reading the allergy list is
what the feature is for; only the contribute step waits, and it comes
back in the same diff as the surface that offers it.
This commit is contained in:
Marc-André Bombeck committed 2026-08-03 18:47:42 +02:00
1 parent e26a8f3824
commit ebf032158e
9 files changed
+262 -65

No files matched your search

@@ -3,6 +3,7 @@ import type { QueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
intakeToastOptions,
runLogIntake,
runRecordIntake,
runUndoIntake,
@@ -475,3 +476,67 @@ describe("runUndoIntake — shared soft-delete", () => {
expect(toast.success).not.toHaveBeenCalled();
});
});
/**
* v1.36.x — the one decision every intake surface shares.
*
* Three surfaces record a dose and all three showed the same success toast:
* the cards, the log-intake dialog, and the dose-history ledger. Two of them
* learned to name the record and drop Undo inside somebody else's; the ledger
* built its own copy of the ternary and learned neither, so a delegate met a
* dead Undo on every dose they marked. This is the copy that is left.
*
* Whole objects rather than field probes: when one person's record is being
* separated from another's, a failure should print what would have been shown
* rather than `false !== true`.
*
* Mutation check, run: making `intakeToastOptions` ignore `recordName` → the
* two shared-record legs go red, printing the Undo action they returned.
*/
describe("intakeToastOptions — the shared toast decision", () => {
const onUndo = vi.fn();
it("carries an Undo in the caller's own record", () => {
expect(
intakeToastOptions({ recordName: null, eventId: "evt-1", t, onUndo }),
).toEqual({
action: {
label: "medications.intakeUndo",
onClick: expect.any(Function),
},
});
});
it("names the record and offers no Undo inside somebody else's", () => {
expect(
intakeToastOptions({
recordName: "Margarethe",
eventId: "evt-1",
t,
onUndo,
}),
).toEqual({ description: "recordSharing.toast.savedTo:Margarethe" });
});
it("still names the record when the write returned no event id", () => {
// The two arms are not alternatives: the receipt is owed either way, and
// an absent event id must not fall through to a bare "Saved".
expect(
intakeToastOptions({
recordName: "Margarethe",
eventId: undefined,
t,
onUndo,
}),
).toEqual({ description: "recordSharing.toast.savedTo:Margarethe" });
});
it("carries nothing when there is neither a record to name nor an undo", () => {
expect(
intakeToastOptions({ recordName: null, eventId: undefined, t, onUndo }),
).toBeUndefined();
expect(
intakeToastOptions({ recordName: null, eventId: "evt-1", t }),
).toBeUndefined();
});
});
@@ -94,7 +94,10 @@ import {
refetchInactiveDailyReads,
} from "@/lib/query-keys";
import type { DoseHistoryStatus } from "@/lib/medications/scheduling/dose-history";
import { runUndoIntake } from "@/components/medications/use-medication-intake";
import {
intakeToastOptions,
runUndoIntake,
} from "@/components/medications/use-medication-intake";
import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog";
import { LedgerAddDialog } from "@/components/medications/dose-history-add-dialog";
import {
@@ -291,26 +294,20 @@ export function DoseHistoryLedger({
: "medications.intakeToastTaken",
{ name: medicationName },
),
recordName
? {
description: t("recordSharing.toast.savedTo", {
name: recordName,
}),
}
: eventId
? {
action: {
label: t("medications.intakeUndo"),
onClick: () =>
void runUndoIntake({
medication: { id: medicationId, name: medicationName },
eventId,
t,
queryClient,
}),
},
}
: undefined,
// The shared decision, not a fourth copy of it: name the record it
// landed in, and withhold an Undo the server would refuse there.
intakeToastOptions({
recordName,
eventId,
t,
onUndo: (id) =>
void runUndoIntake({
medication: { id: medicationId, name: medicationName },
eventId: id,
t,
queryClient,
}),
}),
);
await invalidateKeys(queryClient, [
...medicationDependentKeys,
@@ -29,6 +29,54 @@ interface MedicationIntakeIdentity {
name: string;
}
/**
* What rides alongside a successful intake toast — and the one place that
* decides it.
*
* Three surfaces record a dose: the medication cards through
* {@link runRecordIntake}, the log-intake dialog through {@link runLogIntake},
* and the dose-history ledger, which builds its own POST because it also has
* an optimistic cache patch to make. All three showed the same success toast
* and all three had to reach the same two conclusions:
*
* - inside somebody else's record, name the record. "Saved" alone is the one
* confirmation a person acting for somebody else does not need.
* - and drop Undo there. A delegate may record a dose and may not remove
* one, so an Undo they can see is an Undo the server refuses.
*
* Two of the three learned that; the ledger did not, which is a delegate
* meeting a dead Undo on every dose they mark. Three copies of one ternary is
* how that happens, so there is one now.
*/
export function intakeToastOptions(input: {
/** The record the dose landed in, or null in the caller's own. */
recordName: string | null | undefined;
/** The created event, when the POST returned one. */
eventId: string | undefined;
t: Translator;
/** Reverse the write. Omitted where the caller offers no undo at all. */
onUndo?: (eventId: string) => void;
}):
| { description: string }
| { action: { label: string; onClick: () => void } }
| undefined {
const { recordName, eventId, t, onUndo } = input;
if (recordName) {
return {
description: t("recordSharing.toast.savedTo", { name: recordName }),
};
}
if (eventId && onUndo) {
return {
action: {
label: t("medications.intakeUndo"),
onClick: () => onUndo(eventId),
},
};
}
return undefined;
}
/**
* v1.12.2 — the take / skip + Undo intake orchestration shared by the
* generic {@link MedicationCard} and the {@link Glp1MedicationCard}.
@@ -146,20 +194,12 @@ export async function runRecordIntake(deps: {
: "medications.intakeToastTaken",
{ name: medication.name },
),
recordName
? {
description: t("recordSharing.toast.savedTo", {
name: recordName,
}),
}
: eventId
? {
action: {
label: t("medications.intakeUndo"),
onClick: () => void undoIntake(eventId),
},
}
: undefined,
intakeToastOptions({
recordName,
eventId,
t,
onUndo: (id) => void undoIntake(id),
}),
);
await invalidateMedicationReads(queryClient);
onRecorded?.(eventId, skipped);
@@ -248,17 +288,14 @@ 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 (recordName) {
toast.success(message, {
description: t("recordSharing.toast.savedTo", { name: recordName }),
});
} else if (eventId && undoIntake) {
toast.success(message, {
action: {
label: t("medications.intakeUndo"),
onClick: () => void undoIntake(eventId),
},
});
const options = intakeToastOptions({
recordName,
eventId,
t,
onUndo: undoIntake ? (id) => void undoIntake(id) : undefined,
});
if (options) {
toast.success(message, options);
} else {
toast.success(message);
}