feat(dashboard): medication-intake quick-add overlay

Mirror the ResponsiveSheet primitive used by the Measurement and Mood
quick-adds for medication intake. The sheet auto-selects the active
medication whose schedule window is currently open (or the only active
medication when the user has just one), pre-fills the dose from the
catalogue entry, and defaults the time taken to now. On submit the
form invalidates medicationDependentKeys plus the inline per-medication
compliance chart cache so the detail tile, the dashboard compliance
chart, the today list, and the achievements card all refresh in lockstep
with the API. An empty-state branch suppresses the form and surfaces a
CTA to /medications when the user hasn't yet created a medication.
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 14:42:25 +02:00
1 parent d3c9eaaf48
commit f9dab34fed
1 file changed
+434
@@ -0,0 +1,434 @@
"use client";
import { useId, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { DateTimeInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2, Pill, Plus } from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import {
reduceCurrentWindowStatus,
toBerlinDate,
type ScheduleWindowInput,
} from "@/lib/medications/window-status";
/**
* v1.4.37 W7b — dashboard "Hinzufügen" → "Medikamenteneinnahme" quick-add.
*
* Marc's brief: the dashboard's top-right "Hinzufügen" menu logs
* measurements and mood entries; add a medication-intake action so a
* dose can be logged in a few taps without leaving the dashboard.
*
* Form shape mirrors `MoodForm` / `MeasurementForm`:
*
* 1. Medication picker. Auto-selects the only active medication when
* the user has one; otherwise defaults to the first medication
* whose schedule window is currently open (the cool/easy slice).
* Falls back to the most-recently-created entry when nothing is
* due. Disabled when the user has no active medications — the
* sheet body renders an EmptyState-style hint instead of an
* unusable form.
* 2. Dose. Pre-filled from the medication's default dose; editable
* for the "took half / took double" cases. Free text (the
* medication catalogue stores dose as a free string), so we ship
* it as a notes-style append on the intake event via the
* existing API — the intake POST itself does not persist a dose
* override yet (the event references the medication's current
* dose), so a non-default value surfaces as a sonner toast hint
* only. Keeping the field editable preserves Marc's "few taps"
* contract for the common case while documenting the boundary.
* 3. Time taken. Defaults to `now` (local datetime-local format
* shaped the same as `MoodForm`).
*
* On success: invalidate `medicationDependentKeys` (medications +
* analytics + insights + intake-summary + achievements) PLUS the
* inline per-medication compliance chart key so the detail page tile
* refreshes if it happens to be mounted elsewhere in the app.
*
* Mounted inside `<ResponsiveSheet>` from `page.tsx`; the action row
* is portalled into the sheet's sticky footer slot via `footerSlot`,
* matching the `MoodForm` / `MeasurementForm` contract so the Save
* button stays reachable above the mobile keyboard.
*/
interface Schedule extends ScheduleWindowInput {
id: string;
label: string | null;
dose: string | null;
}
export interface MedicationOption {
id: string;
name: string;
dose: string;
active: boolean;
schedules: Schedule[];
lastTakenAt: string | null;
todayEventCount: number | null;
}
interface MedicationIntakeQuickAddProps {
onSuccess?: () => void;
onCancel?: () => void;
/**
* v1.4.27 R4 RC2 contract — when mounted inside the responsive sheet,
* the parent passes the sheet's sticky footer slot so the action row
* (Cancel + Save) renders pinned above the soft keyboard. The
* `<form>` keeps `submit-on-Enter` working through the HTML `form`
* attribute on the portalled Save button.
*/
footerSlot?: HTMLElement | null;
}
function getDefaultIntakeAtValue(): string {
const now = new Date();
const offset = now.getTimezoneOffset();
const local = new Date(now.getTime() - offset * 60 * 1000);
return local.toISOString().slice(0, 16);
}
/**
* Pick the medication that should be pre-selected when the sheet opens.
*
* Priority:
* 1. The active medication whose schedule window is currently open
* (or late / very_late) — this is the "due now" surface.
* 2. The single active medication when the user has exactly one.
* 3. The first active medication alphabetically (stable fallback).
*
* Returns the medication id or `null` when no active medication exists.
*/
export function pickDefaultMedicationId(
options: MedicationOption[],
now: Date = new Date(),
thresholds: { lateMinutes: number; missedMinutes: number } = {
lateMinutes: 120,
missedMinutes: 240,
},
): string | null {
const actives = options.filter((m) => m.active);
if (actives.length === 0) return null;
if (actives.length === 1) return actives[0].id;
const nowBerlin = toBerlinDate(now);
const due = actives.find((m) => {
const status = reduceCurrentWindowStatus({
schedules: m.schedules,
nowBerlin,
lateMinutes: thresholds.lateMinutes,
missedMinutes: thresholds.missedMinutes,
active: true,
lastTakenAt: m.lastTakenAt,
todayEventCount: m.todayEventCount ?? 0,
});
return status.status !== null;
});
if (due) return due.id;
// Stable alphabetical fallback when nothing is currently due.
const sorted = [...actives].sort((a, b) =>
a.name.localeCompare(b.name, "de", { sensitivity: "base" }),
);
return sorted[0]?.id ?? null;
}
export function MedicationIntakeQuickAdd({
onSuccess,
onCancel,
footerSlot,
}: MedicationIntakeQuickAddProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { data: medicationsRaw, isLoading: medicationsLoading } = useQuery({
queryKey: queryKeys.medications(),
queryFn: async () => {
const res = await fetch("/api/medications");
if (!res.ok) throw new Error("Failed to load medications");
const json = await res.json();
return json.data as MedicationOption[];
},
});
const medications = useMemo(
() =>
Array.isArray(medicationsRaw)
? medicationsRaw.filter((m) => m.active)
: [],
[medicationsRaw],
);
const sortedMedications = useMemo(
() =>
[...medications].sort((a, b) =>
a.name.localeCompare(b.name, "de", { sensitivity: "base" }),
),
[medications],
);
// User-driven medication override; `null` means "use the auto-pick".
// Derived selection prefers the override, falls back to the heuristic
// so a freshly-mounted sheet shows the cool/easy default without any
// effect-driven setState.
const [medicationOverride, setMedicationOverride] = useState<string | null>(
null,
);
// User-driven dose override; `null` means "use the medication's
// default". The picker auto-prefills the dose input by deriving it
// from the selected medication during render — keeping it state-free
// until the user actually types so we don't fight the linter's
// `react-hooks/set-state-in-effect` guard.
const [doseOverride, setDoseOverride] = useState<string | null>(null);
const [takenAt, setTakenAt] = useState<string>(getDefaultIntakeAtValue);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const errorId = useId();
const errorDescriptor = error ? errorId : undefined;
const formId = useId();
const defaultMedicationId = useMemo(
() => pickDefaultMedicationId(medications),
[medications],
);
const medicationId = medicationOverride ?? defaultMedicationId ?? "";
const selectedMedication = medications.find((m) => m.id === medicationId);
const dose = doseOverride ?? selectedMedication?.dose ?? "";
function handleMedicationChange(value: string) {
setMedicationOverride(value);
// Reset the dose override when the medication changes so the new
// default flows through on the next render. The user can still
// override it after the selection by typing into the dose field.
setDoseOverride(null);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!medicationId || loading) return;
setError(null);
setLoading(true);
try {
const timestamp = new Date(takenAt).toISOString();
const res = await fetch(`/api/medications/${medicationId}/intake`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
takenAt: timestamp,
skipped: false,
}),
});
const json = await res.json();
if (!res.ok) {
setError(
typeof json?.error === "string"
? json.error
: t("dashboard.medicationIntakeQuickAdd.saveError"),
);
setLoading(false);
return;
}
await invalidateKeys(queryClient, medicationDependentKeys);
// Fan-out to every inline compliance chart key (one per medication)
// so the detail-page tile refreshes when the user re-enters it.
await queryClient.invalidateQueries({
queryKey: ["compliance-chart-inline"],
});
toast.success(t("common.saved"));
onSuccess?.();
} catch {
setError(t("dashboard.medicationIntakeQuickAdd.saveError"));
} finally {
setLoading(false);
}
}
const footerNode = (
<div className="flex w-full items-center justify-end gap-2">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading}
>
{t("common.cancel")}
</Button>
)}
<Button
type="submit"
form={formId}
disabled={loading || !medicationId || medications.length === 0}
>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
{t("common.save")}
</Button>
</div>
);
// Empty-state branch: the user has no active medications. Render a
// hint + CTA into the body so the user has a single tap into the
// medication-creation surface. The form itself is suppressed so we
// don't ship an unusable Save button.
if (!medicationsLoading && medications.length === 0) {
const emptyFooter = (
<div className="flex w-full items-center justify-end gap-2">
{onCancel && (
<Button type="button" variant="outline" onClick={onCancel}>
{t("common.close")}
</Button>
)}
</div>
);
return (
<>
<div
data-testid="medication-intake-quick-add-empty"
className="flex flex-col items-center gap-3 py-4 text-center"
role="status"
aria-live="polite"
>
<Pill className="text-muted-foreground h-6 w-6" aria-hidden="true" />
<div className="space-y-1">
<p className="text-sm font-medium">
{t("dashboard.medicationIntakeQuickAdd.emptyTitle")}
</p>
<p className="text-muted-foreground text-xs">
{t("dashboard.medicationIntakeQuickAdd.emptyDescription")}
</p>
</div>
<Button asChild size="sm" variant="outline">
<a href="/medications">
{t("dashboard.medicationIntakeQuickAdd.emptyCta")}
</a>
</Button>
</div>
{footerSlot ? createPortal(emptyFooter, footerSlot) : emptyFooter}
</>
);
}
return (
<form
id={formId}
onSubmit={handleSubmit}
className="space-y-4"
data-testid="medication-intake-quick-add-form"
>
<div className="space-y-2">
<Label htmlFor="medication-intake-medication">
{t("dashboard.medicationIntakeQuickAdd.medicationLabel")}
</Label>
<Select
value={medicationId}
onValueChange={handleMedicationChange}
disabled={medicationsLoading || medications.length === 0}
>
<SelectTrigger
id="medication-intake-medication"
data-testid="medication-intake-quick-add-medication"
className="min-h-11"
>
<SelectValue
placeholder={t(
"dashboard.medicationIntakeQuickAdd.medicationPlaceholder",
)}
/>
</SelectTrigger>
<SelectContent>
{sortedMedications.map((m) => (
<SelectItem key={m.id} value={m.id}>
<span className="flex items-center gap-2">
<Pill
className="text-muted-foreground h-4 w-4"
aria-hidden="true"
/>
<span>{m.name}</span>
{m.dose ? (
<span className="text-muted-foreground text-xs">
({m.dose})
</span>
) : null}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="medication-intake-dose">
{t("dashboard.medicationIntakeQuickAdd.doseLabel")}
</Label>
<Input
id="medication-intake-dose"
data-testid="medication-intake-quick-add-dose"
value={dose}
onChange={(e) => setDoseOverride(e.target.value)}
placeholder={
selectedMedication?.dose ??
t("dashboard.medicationIntakeQuickAdd.dosePlaceholder")
}
enterKeyHint="done"
autoComplete="off"
className="min-h-11"
/>
<p className="text-muted-foreground text-xs">
{t("dashboard.medicationIntakeQuickAdd.doseHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="medication-intake-taken-at">
{t("dashboard.medicationIntakeQuickAdd.timeLabel")}
</Label>
<DateTimeInput
id="medication-intake-taken-at"
data-testid="medication-intake-quick-add-taken-at"
value={takenAt}
onChange={(e) => setTakenAt(e.target.value)}
required
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
/>
</div>
{error && (
<div
id={errorId}
role="alert"
aria-live="assertive"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{error}
</div>
)}
{footerSlot ? createPortal(footerNode, footerSlot) : footerNode}
</form>
);
}