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

@@ -8,6 +8,7 @@ import { adminReminderCheckSchema } from "@/lib/validations/notifications";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day";
import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
@@ -182,7 +183,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
const isOverdue = dayMatch && minutesPastEnd > 0;
const hasEvent = eventCount > schedulesProcessed;
if (isOverdue && !hasEvent && med.notificationsEnabled) {
// v1.42.2 — a medication whose doses are recorded automatically
// is never reminded, here or in the worker.
if (isOverdue && !hasEvent && sendsMedicationReminders(med)) {
const doseInfo = schedule.dose ?? med.dose;
const timeWindow = `${schedule.windowStart}–${schedule.windowEnd}`;
@@ -92,6 +92,8 @@ interface SerializedDoseHistoryRow {
takenAt: string | null;
skipped: boolean;
autoMissed: boolean;
/** v1.42.2 — recorded automatically (`autoMarkTaken`). */
autoRecorded: boolean;
/** v1.16.4 — per-intake dose override; null = configured dose. */
doseTaken: string | null;
/** v1.32.8 (iOS #64) — how the dose was recorded; null on legacy rows. */
@@ -179,6 +181,8 @@ export const GET = apiHandler(
// v1.32.8 (iOS #64) — write provenance so the client can label how
// each dose was recorded (Web / API / Reminder / Import / AppleHealth).
source: true,
// v1.42.2 — the history labels an automatically recorded dose.
autoRecorded: true,
},
});
@@ -191,6 +195,7 @@ export const GET = apiHandler(
pinned: e.attributionSource === "USER_PIN",
doseTaken: e.doseTaken,
source: e.source,
autoRecorded: e.autoRecorded,
}));
const lastIntakeAt = lastNonSkippedTakenAt(mapped);
@@ -296,6 +301,7 @@ export const GET = apiHandler(
pinned: e.pinned,
doseTaken: e.doseTaken,
source: e.source,
autoRecorded: e.autoRecorded,
}));
const rows = reconstructDoseHistory(
@@ -327,6 +333,7 @@ export const GET = apiHandler(
autoMissed: row.intake.autoMissed ?? false,
doseTaken: row.intake.doseTaken ?? null,
source: row.intake.source ?? null,
autoRecorded: row.intake.autoRecorded ?? false,
}
: null,
}));
@@ -252,6 +252,10 @@ export const PUT = apiHandler(
// onto the slot the row already sits on, or an unpin whose band
// re-attribution lands on the same anchor).
...(attributionSource !== undefined && { attributionSource }),
// v1.42.2 — an edit of the outcome is the person's own record.
...((data.takenAt !== undefined || data.skipped !== undefined) && {
autoRecorded: false,
}),
// v1.7.0 sync — bump the reconciliation counter on every
// server-side mutation so the `/api/sync/changes` feed echoes a
// monotonic value to paired clients.
+12
View File
@@ -310,6 +310,8 @@ export const PUT = apiHandler(
asNeeded: true,
// v1.39.1 (#1033) — the tracking transition below needs the before.
trackIntake: true,
// v1.42.2 — `autoMarkTakenSince` is stamped only on an off→on flip.
autoMarkTaken: true,
// v1.37.0 — the pre-image the audit row carries (C4).
name: true,
dose: true,
@@ -407,6 +409,7 @@ export const PUT = apiHandler(
oneShot,
asNeeded,
trackIntake,
autoMarkTaken,
reminderGraceMinutes: topLevelGraceMinutes,
} = input;
@@ -962,6 +965,15 @@ export const PUT = apiHandler(
...(asNeeded !== undefined && { asNeeded }),
// v1.39.1 (#1033) — intake tracking, field-by-field.
...(trackIntake !== undefined && { trackIntake }),
// v1.42.2 — auto-mark-taken, field-by-field. Only an off→on flip
// stamps `autoMarkTakenSince`, so re-saving the form with the
// option already on never moves the floor; switching it off
// clears the stamp.
...(autoMarkTaken !== undefined &&
autoMarkTaken !== existing.autoMarkTaken && {
autoMarkTaken,
autoMarkTakenSince: autoMarkTaken ? new Date() : null,
}),
...(normalisedSchedules && {
schedules: {
create: normalisedSchedules.map((n) => n.createData),
+9 -1
View File
@@ -438,6 +438,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
data: {
takenAt: resolvedTakenAt,
skipped: false,
// v1.42.2 — the person confirmed it; no longer automatic.
autoRecorded: false,
syncVersion: { increment: 1 },
// v1.8.5 — persist the resolved site on the taken branch.
...(resolvedInjectionSite !== null && {
@@ -525,7 +527,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
updated = await prisma.medicationIntakeEvent.update({
where: { id: intakeId },
// v1.7.0 sync — bump the reconciliation counter on the skip toggle.
data: { takenAt: null, skipped: true, syncVersion: { increment: 1 } },
// v1.42.2 — a skip the person records clears the automatic marker.
data: {
takenAt: null,
skipped: true,
autoRecorded: false,
syncVersion: { increment: 1 },
},
});
// v1.16.10 — the row stopped being taken; refund whatever its
// consumption stamp recorded (no-op for a never-consumed row).
+7
View File
@@ -189,6 +189,7 @@ async function postMedication(request: NextRequest): Promise<Response> {
oneShot,
asNeeded,
trackIntake,
autoMarkTaken,
} = parsed.data;
// v1.40 (#1041) — a custom category must be one of this record's own.
@@ -378,6 +379,12 @@ async function postMedication(request: NextRequest): Promise<Response> {
// the schedule is stored as a record from the start; the column
// default (true) covers every caller that does not send it.
...(trackIntake !== undefined && { trackIntake }),
// v1.42.2 — auto-mark-taken, field-by-field. Created on, the
// auto-take job fills slots from this moment only.
...(autoMarkTaken !== undefined && {
autoMarkTaken,
autoMarkTakenSince: autoMarkTaken ? new Date() : null,
}),
schedules: {
create: scheduleInputs.map((s) => {
// Invariant 2 — default to FREQ=DAILY when nothing else is set.