From f9dab34fed5564de73e925a07fc9c26d03d95f8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 17 May 2026 14:42:25 +0200 Subject: [PATCH] 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. --- .../dashboard/medication-intake-quick-add.tsx | 434 ++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 src/components/dashboard/medication-intake-quick-add.tsx diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx new file mode 100644 index 000000000..57b80e87a --- /dev/null +++ b/src/components/dashboard/medication-intake-quick-add.tsx @@ -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 `` 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 + * `
` 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( + 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(null); + const [takenAt, setTakenAt] = useState(getDefaultIntakeAtValue); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 = ( +
+ {onCancel && ( + + )} + +
+ ); + + // 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 = ( +
+ {onCancel && ( + + )} +
+ ); + return ( + <> +
+
+ {footerSlot ? createPortal(emptyFooter, footerSlot) : emptyFooter} + + ); + } + + return ( + +
+ + +
+ +
+ + setDoseOverride(e.target.value)} + placeholder={ + selectedMedication?.dose ?? + t("dashboard.medicationIntakeQuickAdd.dosePlaceholder") + } + enterKeyHint="done" + autoComplete="off" + className="min-h-11" + /> +

+ {t("dashboard.medicationIntakeQuickAdd.doseHint")} +

+
+ +
+ + setTakenAt(e.target.value)} + required + aria-required="true" + aria-invalid={!!error || undefined} + aria-describedby={errorDescriptor} + /> +
+ + {error && ( + + )} + + {footerSlot ? createPortal(footerNode, footerSlot) : footerNode} + + ); +}