mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(medications): record scheduled doses automatically
This commit is contained in:
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,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user