feat(medications): record scheduled doses automatically

This commit is contained in:
Marc-André Bombeck committed 2026-10-11 13:46:16 +02:00
1 parent 5919a93c29
commit c94e109bf0
37 files changed
+1105 -15

No files matched your search

@@ -18,6 +18,8 @@ interface MedicationStateBadgesProps {
lastCourseEndedOn?: string | null;
/** v1.40 (#1024) — the running course's number, when there are several. */
courseNumber?: number | null;
/** v1.42.2 — doses are recorded automatically (`autoMarkTaken`). */
autoMarkTaken?: boolean;
}
/**
@@ -34,6 +36,7 @@ export function MedicationStateBadges({
courseEnded = false,
lastCourseEndedOn = null,
courseNumber = null,
autoMarkTaken = false,
}: MedicationStateBadgesProps) {
const { t, locale } = useTranslations();
const dateFormatPref = useDateFormatPreference();
@@ -75,7 +78,20 @@ export function MedicationStateBadges({
{t("medications.course.nth", { n: courseNumber })}
</Badge>
)}
{!notificationsEnabled && !recordOnly && !courseEnded && (
{/* v1.42.2 — a quiet meta line, not a status badge: the doses are
recorded for the person, and no reminder is sent for them. */}
{autoMarkTaken && active && !recordOnly && !courseEnded && (
<span
className="text-muted-foreground text-xs"
data-slot="medication-auto-mark-taken-hint"
>
{t("medications.autoMarkTaken.cardHint")}
</span>
)}
{!notificationsEnabled &&
!autoMarkTaken &&
!recordOnly &&
!courseEnded && (
<Badge variant="secondary" className="text-xs">
{t("medications.withoutNotification")}
</Badge>
@@ -93,6 +93,7 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { formatDose } from "@/lib/medications/format-dose";
import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload";
import type { ComplianceDisplay } from "@/lib/analytics/compliance";
import { AutoMarkTakenBody } from "@/components/medications/sections/auto-mark-taken-section";
import { IntakeTrackingBody } from "@/components/medications/sections/intake-tracking-section";
// v1.15.20 — Recharts stays out of the detail page's initial bundle:
@@ -174,6 +175,8 @@ export interface MedicationDetailSnapshot {
* `recordedSchedules`; the page shows (and edits) the stored rows.
*/
trackIntake?: boolean;
/** v1.42.2 — doses recorded automatically. */
autoMarkTaken?: boolean;
/** v1.39.4 (#1040) — where today sits in the course (server-resolved). */
courseStatus?: "UPCOMING" | "CURRENT" | "ENDED";
/** v1.40 (#1024) — the server-resolved courses. */
@@ -253,6 +256,7 @@ function snapshotToWizardPayload(
oneShot: med.oneShot ?? false,
asNeeded: med.asNeeded ?? false,
trackIntake: med.trackIntake !== false,
autoMarkTaken: med.autoMarkTaken === true,
schedules: med.schedules.map((s) => ({
id: s.id,
windowStart: s.windowStart,
@@ -731,6 +735,15 @@ export function MedicationDetailTabs({
dataSlot="medication-zeitplan-reminder"
>
<div className="space-y-4">
{/* v1.42.2 — leads the reminder card: with it on, the doses
are recorded and no reminder is sent. */}
{medication.asNeeded !== true && (
<AutoMarkTakenBody
key={medication.autoMarkTaken ? "on" : "off"}
medicationId={id}
autoMarkTaken={medication.autoMarkTaken === true}
/>
)}
<NotificationsBody
medicationId={id}
notificationsEnabled={medication.notificationsEnabled}
@@ -141,6 +141,8 @@ export interface Glp1Medication {
* compliance bars and the take / skip actions and shows a badge instead.
*/
trackIntake?: boolean;
/** v1.42.2 — doses recorded automatically. */
autoMarkTaken?: boolean;
/**
* v1.39.4 (#1040) — server-resolved: taken / skip are offered today only
* when true (active, tracked, and the course includes today).
@@ -464,6 +466,7 @@ export function Glp1MedicationCard({
active={medication.active}
pausedAt={medication.pausedAt}
recordOnly={medication.trackIntake === false}
autoMarkTaken={medication.autoMarkTaken === true}
courseEnded={medication.courseStatus === "ENDED"}
lastCourseEndedOn={medication.previousCourseEndedOn ?? null}
courseNumber={currentCourseNumber(medication)}
@@ -92,6 +92,8 @@ export interface IntakeEvent {
takenAt: string | null;
skipped: boolean;
source: IntakeSourceValue;
/** v1.42.2 — recorded by the server (`autoMarkTaken`), not the person. */
autoRecorded?: boolean;
createdAt: string;
}
@@ -430,10 +432,11 @@ export function IntakeHistoryListV2({
variant="outline"
className="text-muted-foreground text-xs font-normal"
>
{t("medications.intakeSourceVia", {
origin:
sourceLabels[event.source] ?? event.source,
})}
{event.autoRecorded
? t("medications.intakeRecordedAutomatically")
: t("medications.intakeSourceVia", {
origin: sourceLabels[event.source] ?? event.source,
})}
</Badge>
</TableCell>
{showRowActions && (
@@ -535,10 +538,11 @@ export function IntakeHistoryListV2({
variant="outline"
className="text-muted-foreground text-xs font-normal"
>
{t("medications.intakeSourceVia", {
origin:
sourceLabels[event.source] ?? event.source,
})}
{event.autoRecorded
? t("medications.intakeRecordedAutomatically")
: t("medications.intakeSourceVia", {
origin: sourceLabels[event.source] ?? event.source,
})}
</Badge>
</div>
</div>
@@ -126,6 +126,8 @@ interface Medication {
* compliance bars and the take / skip actions and shows a badge instead.
*/
trackIntake?: boolean;
/** v1.42.2 — doses recorded automatically. */
autoMarkTaken?: boolean;
/**
* v1.39.4 (#1040) — server-resolved: taken / skip are offered today only
* when true (active, tracked, and the course includes today).
@@ -407,6 +409,7 @@ export function MedicationCard({
active={medication.active}
pausedAt={medication.pausedAt}
recordOnly={medication.trackIntake === false}
autoMarkTaken={medication.autoMarkTaken === true}
courseEnded={medication.courseStatus === "ENDED"}
lastCourseEndedOn={medication.previousCourseEndedOn ?? null}
courseNumber={currentCourseNumber(medication)}
@@ -0,0 +1,94 @@
"use client";
/**
* v1.42.2 — the per-medication "record doses automatically" switch.
*
* The same switch row as the intake-tracking and reminder rows beside it:
* the whole row is the hit target, the helper line says what the option
* does. Flipping writes `PUT /api/medications/[id]` with `{ autoMarkTaken }`;
* the server stamps the moment it was switched on, and only slots from then
* on are ever recorded for the person.
*/
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
medicationDependentKeys,
refetchInactiveDailyReads,
} from "@/lib/query-keys";
import { apiPut } from "@/lib/api/api-fetch";
const SWITCH_ID = "medication-detail-auto-mark-taken-switch";
const ROW_TITLE_ID = "medication-detail-auto-mark-taken-label";
const HELPER_ID = "medication-detail-auto-mark-taken-helper";
export function AutoMarkTakenBody({
medicationId,
autoMarkTaken,
}: {
medicationId: string;
autoMarkTaken: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [localOn, setLocalOn] = useState(autoMarkTaken);
const [submitting, setSubmitting] = useState(false);
async function flip(next: boolean) {
if (submitting) return;
setSubmitting(true);
const previous = localOn;
setLocalOn(next);
try {
await apiPut(`/api/medications/${medicationId}`, {
autoMarkTaken: next,
});
await invalidateKeys(queryClient, medicationDependentKeys);
await refetchInactiveDailyReads(queryClient);
toast.success(
next
? t("medications.autoMarkTaken.enabledToast")
: t("medications.autoMarkTaken.disabledToast"),
);
} catch {
setLocalOn(previous);
toast.error(t("medications.autoMarkTaken.toggleFailed"));
} finally {
setSubmitting(false);
}
}
return (
<label
htmlFor={SWITCH_ID}
className="flex items-center justify-between gap-3"
data-slot="medication-auto-mark-taken-row"
>
<span className="space-y-1">
<span
id={ROW_TITLE_ID}
className="text-foreground block text-sm font-medium"
>
{t("medications.autoMarkTaken.label")}
</span>
<span id={HELPER_ID} className="text-muted-foreground block text-xs">
{t("medications.autoMarkTaken.helper")}
</span>
</span>
<Switch
id={SWITCH_ID}
checked={localOn}
disabled={submitting}
onCheckedChange={(checked) => void flip(checked)}
aria-labelledby={ROW_TITLE_ID}
aria-describedby={HELPER_ID}
data-slot="medication-auto-mark-taken-switch"
/>
</label>
);
}
@@ -197,6 +197,33 @@ export function Step8Summary({
</div>
)}
{/* v1.42.2 — auto-mark-taken. Only meaningful while doses are due:
hidden for as-needed and record-only medications. */}
{payload.mode !== "asNeeded" && payload.trackIntake && (
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<Label
htmlFor="wizard-auto-mark-taken"
className="text-sm font-medium"
>
{t("medications.autoMarkTaken.label")}
</Label>
<p className="text-muted-foreground text-xs">
{t("medications.autoMarkTaken.helper")}
</p>
</div>
<Switch
id="wizard-auto-mark-taken"
checked={payload.autoMarkTaken}
onCheckedChange={(checked) =>
applyPartial({ autoMarkTaken: checked })
}
data-slot="wizard-auto-mark-taken-toggle"
aria-label={t("medications.autoMarkTaken.label")}
/>
</div>
)}
{/* v1.16.11 — an as-needed medication never reminds (no slots),
so the reminders toggle is hidden alongside the schedules. */}
{payload.mode !== "asNeeded" && payload.trackIntake && (
@@ -346,6 +346,11 @@ export interface WizardPayload {
* schedule as a record: nothing due, no reminders, no adherence.
*/
trackIntake: boolean;
/**
* v1.42.2 — record each scheduled dose as taken automatically once its
* time has passed. No reminder is sent while it is on.
*/
autoMarkTaken: boolean;
/** Every parallel schedule under construction. Always >= 1 entry. */
schedules: ScheduleDraft[];
/** Which schedule Steps 5-7 currently edit. */
@@ -395,6 +400,7 @@ export function emptyWizardPayload(): WizardPayload {
endsOn: null,
notificationsEnabled: true,
trackIntake: true,
autoMarkTaken: false,
schedules: [draft],
activeScheduleIndex: 0,
};
@@ -695,6 +701,12 @@ export interface CreateMedicationBody {
* always tracks intake.
*/
trackIntake: boolean;
/**
* v1.42.2 — auto-mark-taken, sent on every create and edit (hydrated from
* the stored value). The server stamps its floor only on an off→on flip,
* so re-sending the stored value never moves it. Off for as-needed.
*/
autoMarkTaken: boolean;
schedules: Array<{
id?: string;
windowStart: string;
@@ -923,6 +935,8 @@ export function buildCreateBody(
// medication kept as a record. Turning a medication into an as-needed
// one means doses will be logged, so it tracks intake again.
trackIntake: isAsNeeded ? true : committed.trackIntake,
// An as-needed medication has no slot to record.
autoMarkTaken: isAsNeeded ? false : committed.autoMarkTaken,
schedules: draftsToEmit.map((draft) =>
encodeScheduleDraft(draft, isOneShot),
),
@@ -1149,6 +1163,8 @@ export interface MedicationPayload {
asNeeded?: boolean;
/** v1.39.1 (#1033) — intake tracking; absent = tracked. */
trackIntake?: boolean;
/** v1.42.2 — auto-mark-taken; absent = off. */
autoMarkTaken?: boolean;
schedules: Array<{
id?: string;
windowStart: string;
@@ -1301,6 +1317,7 @@ export function hydrateWizardPayload(
endsOn: initial.endsOn,
notificationsEnabled: initial.notificationsEnabled ?? true,
trackIntake: initial.trackIntake !== false,
autoMarkTaken: initial.autoMarkTaken === true,
schedules: drafts,
activeScheduleIndex: 0,
};