fix(medications): surface failed dose logging and add an undo affordance

A failed take/skip POST cleared the spinner without any feedback, so the
user believed the dose was logged when it was not. Show an error toast on
a non-ok response or a thrown request, and never show the success
confirmation in that case.

On a successful take/skip, attach an Undo action to the toast that
soft-deletes the just-recorded intake via the per-event delete route, so
a misclick no longer needs a trip through the intake history to correct.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 15:36:12 +02:00
1 parent 0cb8070a22
commit 8434af40cb
7 files changed
+88 -21

No files matched your search

+4
View File
@@ -622,6 +622,10 @@
"skipped": "Übersprungen",
"intakeToastTaken": "{name} als genommen erfasst",
"intakeToastSkipped": "{name} übersprungen",
"intakeToastFailed": "{name} konnte nicht erfasst werden – bitte erneut versuchen",
"intakeUndo": "Rückgängig",
"intakeUndone": "Eintrag zurückgenommen",
"intakeUndoFailed": "Rückgängig fehlgeschlagen – im Verlauf korrigieren",
"weekdaySunday": "Sonntag",
"weekdayMonday": "Montag",
"weekdayTuesday": "Dienstag",
+4
View File
@@ -622,6 +622,10 @@
"skipped": "Skipped",
"intakeToastTaken": "{name} recorded as taken",
"intakeToastSkipped": "{name} skipped",
"intakeToastFailed": "Could not record {name} — try again",
"intakeUndo": "Undo",
"intakeUndone": "Entry reverted",
"intakeUndoFailed": "Could not undo — open the history to fix it",
"weekdaySunday": "Sunday",
"weekdayMonday": "Monday",
"weekdayTuesday": "Tuesday",
+4
View File
@@ -622,6 +622,10 @@
"skipped": "Omitida",
"intakeToastTaken": "{name} registrada como tomada",
"intakeToastSkipped": "{name} omitida",
"intakeToastFailed": "No se pudo registrar {name}; inténtalo de nuevo",
"intakeUndo": "Deshacer",
"intakeUndone": "Entrada revertida",
"intakeUndoFailed": "No se pudo deshacer; corrígelo en el historial",
"weekdaySunday": "Domingo",
"weekdayMonday": "Lunes",
"weekdayTuesday": "Martes",
+4
View File
@@ -622,6 +622,10 @@
"skipped": "Sauté",
"intakeToastTaken": "{name} enregistré comme pris",
"intakeToastSkipped": "{name} sauté",
"intakeToastFailed": "Impossible d'enregistrer {name} — réessayez",
"intakeUndo": "Annuler",
"intakeUndone": "Entrée annulée",
"intakeUndoFailed": "Annulation impossible — corrigez dans l'historique",
"weekdaySunday": "Dimanche",
"weekdayMonday": "Lundi",
"weekdayTuesday": "Mardi",
+4
View File
@@ -622,6 +622,10 @@
"skipped": "Saltata",
"intakeToastTaken": "{name} registrata come assunta",
"intakeToastSkipped": "{name} saltata",
"intakeToastFailed": "Impossibile registrare {name}; riprova",
"intakeUndo": "Annulla",
"intakeUndone": "Voce annullata",
"intakeUndoFailed": "Impossibile annullare; correggi nella cronologia",
"weekdaySunday": "Domenica",
"weekdayMonday": "Lunedì",
"weekdayTuesday": "Martedì",
+4
View File
@@ -622,6 +622,10 @@
"skipped": "Pominięte",
"intakeToastTaken": "{name} oznaczone jako przyjęte",
"intakeToastSkipped": "{name} pominięte",
"intakeToastFailed": "Nie udało się zapisać {name} — spróbuj ponownie",
"intakeUndo": "Cofnij",
"intakeUndone": "Wpis cofnięty",
"intakeUndoFailed": "Nie udało się cofnąć — popraw w historii",
"weekdaySunday": "Niedziela",
"weekdayMonday": "Poniedziałek",
"weekdayTuesday": "Wtorek",
+64 -21
View File
@@ -168,6 +168,26 @@ export function MedicationCard({
return () => clearInterval(interval);
}, []);
// v1.11.3 C2 — reverse the just-recorded intake via the soft-delete
// route. Surfaced from the success toast's Undo action so a misclicked
// take / skip no longer needs a history dive to correct.
async function undoIntake(eventId: string) {
try {
const res = await fetch(
`/api/medications/${medication.id}/intake/${eventId}`,
{ method: "DELETE" },
);
if (!res.ok) {
toast.error(t("medications.intakeUndoFailed"));
return;
}
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(t("medications.intakeUndone"));
} catch {
toast.error(t("medications.intakeUndoFailed"));
}
}
async function recordIntake(skipped: boolean) {
const key = skipped ? "skip" : "take";
setIntakeLoading(key);
@@ -177,29 +197,52 @@ export function MedicationCard({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ skipped }),
});
if (res.ok) {
toast.success(
t(
skipped
? "medications.intakeToastSkipped"
: "medications.intakeToastTaken",
{ name: medication.name },
),
// v1.11.3 C1 — a failed POST used to clear the spinner silently, so
// the user believed the dose was logged when it was not. Surface the
// failure and never show the success confirmation in that case.
if (!res.ok) {
toast.error(
t("medications.intakeToastFailed", { name: medication.name }),
);
await invalidateKeys(queryClient, medicationDependentKeys);
// v1.8.5 — after a TAKEN dose on a tracking-enabled injection,
// prompt (skippably) for the site. The dialog PATCHes it onto
// the just-created event via the status-toggle route.
if (!skipped && tracksInjection) {
try {
const json = await res.json();
const eventId = json?.data?.id as string | undefined;
if (eventId) setSiteIntakeId(eventId);
} catch {
/* dose recorded; the site prompt is best-effort */
}
}
return;
}
// The POST returns the created event (`apiSuccess(event, 201)`); its
// id drives both the Undo affordance and the optional injection-site
// prompt below.
let eventId: string | undefined;
try {
const json = await res.json();
eventId = json?.data?.id as string | undefined;
} catch {
/* dose recorded; the body is best-effort for the id */
}
toast.success(
t(
skipped
? "medications.intakeToastSkipped"
: "medications.intakeToastTaken",
{ name: medication.name },
),
eventId
? {
action: {
label: t("medications.intakeUndo"),
onClick: () => void undoIntake(eventId),
},
}
: undefined,
);
await invalidateKeys(queryClient, medicationDependentKeys);
// v1.8.5 — after a TAKEN dose on a tracking-enabled injection,
// prompt (skippably) for the site. The dialog PATCHes it onto
// the just-created event via the status-toggle route.
if (!skipped && tracksInjection && eventId) {
setSiteIntakeId(eventId);
}
} catch {
toast.error(
t("medications.intakeToastFailed", { name: medication.name }),
);
} finally {
setIntakeLoading(null);
}