mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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
@@ -1323,6 +1323,14 @@
|
||||
"disabledToast": "Einnahmen werden nicht erfasst",
|
||||
"toggleFailed": "Einnahme-Erfassung konnte nicht geändert werden."
|
||||
},
|
||||
"autoMarkTaken": {
|
||||
"label": "Einnahmen automatisch erfassen",
|
||||
"helper": "HealthLog erfasst jede geplante Dosis zu ihrer Uhrzeit als eingenommen. Du kannst eine Dosis weiterhin selbst ändern oder überspringen. Erinnerungen entfallen.",
|
||||
"cardHint": "Wird automatisch erfasst",
|
||||
"enabledToast": "Einnahmen werden jetzt automatisch erfasst",
|
||||
"disabledToast": "Einnahmen werden nicht mehr automatisch erfasst",
|
||||
"toggleFailed": "Automatische Erfassung konnte nicht geändert werden."
|
||||
},
|
||||
"withoutNotification": "Ohne Benachrichtigung",
|
||||
"pausedSince": "Pausiert seit",
|
||||
"takeNow": "Jetzt einnehmen",
|
||||
@@ -1779,6 +1787,7 @@
|
||||
"sourceImport": "Import",
|
||||
"sourceAppleHealth": "Apple Health",
|
||||
"intakeSourceVia": "über {origin}",
|
||||
"intakeRecordedAutomatically": "Automatisch erfasst",
|
||||
"intakeHistoryEmptyTitle": "Noch keine Einnahmen erfasst",
|
||||
"intakeHistoryEmptyDescription": "Trage eine Einnahme auf der Tagesübersicht ein, um den Verlauf aufzubauen.",
|
||||
"intakeHistoryEmptyAction": "Zur Tagesübersicht",
|
||||
|
||||
@@ -1323,6 +1323,14 @@
|
||||
"disabledToast": "Intake tracking off",
|
||||
"toggleFailed": "Could not change intake tracking."
|
||||
},
|
||||
"autoMarkTaken": {
|
||||
"label": "Record doses automatically",
|
||||
"helper": "HealthLog records each scheduled dose as taken at its time. You can still change or skip a dose by hand. No reminders are sent.",
|
||||
"cardHint": "Recorded automatically",
|
||||
"enabledToast": "Doses are now recorded automatically",
|
||||
"disabledToast": "Doses are no longer recorded automatically",
|
||||
"toggleFailed": "Could not change automatic recording."
|
||||
},
|
||||
"withoutNotification": "Without notification",
|
||||
"pausedSince": "Paused since",
|
||||
"takeNow": "Take now",
|
||||
@@ -1779,6 +1787,7 @@
|
||||
"sourceImport": "Import",
|
||||
"sourceAppleHealth": "Apple Health",
|
||||
"intakeSourceVia": "via {origin}",
|
||||
"intakeRecordedAutomatically": "Recorded automatically",
|
||||
"intakeHistoryEmptyTitle": "No intakes recorded yet",
|
||||
"intakeHistoryEmptyDescription": "Log a dose on the daily intake page to start building the history.",
|
||||
"intakeHistoryEmptyAction": "Open daily intake",
|
||||
|
||||
@@ -1323,6 +1323,14 @@
|
||||
"disabledToast": "Registro de tomas desactivado",
|
||||
"toggleFailed": "No se pudo cambiar el registro de tomas."
|
||||
},
|
||||
"autoMarkTaken": {
|
||||
"label": "Registrar las tomas automáticamente",
|
||||
"helper": "HealthLog registra cada dosis programada como tomada a su hora. Puedes seguir cambiando u omitiendo una dosis a mano. No se envían recordatorios.",
|
||||
"cardHint": "Se registra automáticamente",
|
||||
"enabledToast": "Las tomas se registran ahora automáticamente",
|
||||
"disabledToast": "Las tomas ya no se registran automáticamente",
|
||||
"toggleFailed": "No se pudo cambiar el registro automático."
|
||||
},
|
||||
"withoutNotification": "Sin notificación",
|
||||
"pausedSince": "En pausa desde",
|
||||
"takeNow": "Tomar ahora",
|
||||
@@ -1779,6 +1787,7 @@
|
||||
"sourceImport": "Importación",
|
||||
"sourceAppleHealth": "Apple Health",
|
||||
"intakeSourceVia": "vía {origin}",
|
||||
"intakeRecordedAutomatically": "Registrada automáticamente",
|
||||
"intakeHistoryEmptyTitle": "Aún no hay tomas",
|
||||
"intakeHistoryEmptyDescription": "En cuanto registres una toma, aparecerá aquí con la hora y la fuente.",
|
||||
"intakeHistoryEmptyAction": "Registrar toma",
|
||||
|
||||
@@ -1323,6 +1323,14 @@
|
||||
"disabledToast": "Suivi des prises désactivé",
|
||||
"toggleFailed": "Impossible de modifier le suivi des prises."
|
||||
},
|
||||
"autoMarkTaken": {
|
||||
"label": "Enregistrer les prises automatiquement",
|
||||
"helper": "HealthLog enregistre chaque dose prévue comme prise à son heure. Vous pouvez toujours modifier ou sauter une dose à la main. Aucun rappel n'est envoyé.",
|
||||
"cardHint": "Enregistré automatiquement",
|
||||
"enabledToast": "Les prises sont désormais enregistrées automatiquement",
|
||||
"disabledToast": "Les prises ne sont plus enregistrées automatiquement",
|
||||
"toggleFailed": "Impossible de modifier l'enregistrement automatique."
|
||||
},
|
||||
"withoutNotification": "Sans notification",
|
||||
"pausedSince": "En pause depuis",
|
||||
"takeNow": "Prendre maintenant",
|
||||
@@ -1779,6 +1787,7 @@
|
||||
"sourceImport": "Import",
|
||||
"sourceAppleHealth": "Apple Health",
|
||||
"intakeSourceVia": "via {origin}",
|
||||
"intakeRecordedAutomatically": "Enregistrée automatiquement",
|
||||
"intakeHistoryEmptyTitle": "Aucune prise pour l’instant",
|
||||
"intakeHistoryEmptyDescription": "Dès que tu enregistreras une prise, elle apparaîtra ici avec l’heure et la source.",
|
||||
"intakeHistoryEmptyAction": "Enregistrer une prise",
|
||||
|
||||
@@ -1323,6 +1323,14 @@
|
||||
"disabledToast": "Registrazione assunzioni disattivata",
|
||||
"toggleFailed": "Impossibile modificare la registrazione delle assunzioni."
|
||||
},
|
||||
"autoMarkTaken": {
|
||||
"label": "Registra le assunzioni automaticamente",
|
||||
"helper": "HealthLog registra ogni dose programmata come assunta al suo orario. Puoi comunque modificare o saltare una dose a mano. Non vengono inviati promemoria.",
|
||||
"cardHint": "Registrato automaticamente",
|
||||
"enabledToast": "Le assunzioni ora vengono registrate automaticamente",
|
||||
"disabledToast": "Le assunzioni non vengono più registrate automaticamente",
|
||||
"toggleFailed": "Impossibile modificare la registrazione automatica."
|
||||
},
|
||||
"withoutNotification": "Senza notifica",
|
||||
"pausedSince": "In pausa dal",
|
||||
"takeNow": "Assumi ora",
|
||||
@@ -1779,6 +1787,7 @@
|
||||
"sourceImport": "Importazione",
|
||||
"sourceAppleHealth": "Apple Health",
|
||||
"intakeSourceVia": "tramite {origin}",
|
||||
"intakeRecordedAutomatically": "Registrata automaticamente",
|
||||
"intakeHistoryEmptyTitle": "Ancora nessuna assunzione",
|
||||
"intakeHistoryEmptyDescription": "Appena registrerai un’assunzione, comparirà qui con orario e sorgente.",
|
||||
"intakeHistoryEmptyAction": "Registra un’assunzione",
|
||||
|
||||
@@ -1323,6 +1323,14 @@
|
||||
"disabledToast": "복용 기록 꺼짐",
|
||||
"toggleFailed": "복용 기록 설정을 바꾸지 못했어요."
|
||||
},
|
||||
"autoMarkTaken": {
|
||||
"label": "복용 자동 기록",
|
||||
"helper": "HealthLog가 예정된 각 복용을 해당 시간에 복용한 것으로 기록합니다. 복용은 언제든 직접 변경하거나 건너뛸 수 있습니다. 알림은 보내지 않습니다.",
|
||||
"cardHint": "자동으로 기록됨",
|
||||
"enabledToast": "이제 복용이 자동으로 기록됩니다",
|
||||
"disabledToast": "더 이상 복용이 자동으로 기록되지 않습니다",
|
||||
"toggleFailed": "자동 기록을 변경하지 못했습니다."
|
||||
},
|
||||
"withoutNotification": "알림 없음",
|
||||
"pausedSince": "일시중지 시작",
|
||||
"takeNow": "지금 복용",
|
||||
@@ -1779,6 +1787,7 @@
|
||||
"sourceImport": "불러오기",
|
||||
"sourceAppleHealth": "Apple Health",
|
||||
"intakeSourceVia": "{origin} 경유",
|
||||
"intakeRecordedAutomatically": "자동으로 기록됨",
|
||||
"intakeHistoryEmptyTitle": "아직 복용 기록이 없어요",
|
||||
"intakeHistoryEmptyDescription": "일일 복용 페이지에서 용량을 기록하면 이력이 쌓여요.",
|
||||
"intakeHistoryEmptyAction": "일일 복용 열기",
|
||||
|
||||
@@ -1323,6 +1323,14 @@
|
||||
"disabledToast": "Rejestrowanie przyjęć wyłączone",
|
||||
"toggleFailed": "Nie udało się zmienić rejestrowania przyjęć."
|
||||
},
|
||||
"autoMarkTaken": {
|
||||
"label": "Zapisuj przyjęcia automatycznie",
|
||||
"helper": "HealthLog zapisuje każdą zaplanowaną dawkę jako przyjętą o jej porze. Nadal możesz ręcznie zmienić lub pominąć dawkę. Przypomnienia nie są wysyłane.",
|
||||
"cardHint": "Zapisywane automatycznie",
|
||||
"enabledToast": "Przyjęcia są teraz zapisywane automatycznie",
|
||||
"disabledToast": "Przyjęcia nie są już zapisywane automatycznie",
|
||||
"toggleFailed": "Nie udało się zmienić automatycznego zapisu."
|
||||
},
|
||||
"withoutNotification": "Bez powiadomienia",
|
||||
"pausedSince": "Wstrzymane od",
|
||||
"takeNow": "Przyjmij teraz",
|
||||
@@ -1779,6 +1787,7 @@
|
||||
"sourceImport": "Import",
|
||||
"sourceAppleHealth": "Apple Health",
|
||||
"intakeSourceVia": "przez {origin}",
|
||||
"intakeRecordedAutomatically": "Zapisano automatycznie",
|
||||
"intakeHistoryEmptyTitle": "Brak przyjęć",
|
||||
"intakeHistoryEmptyDescription": "Gdy tylko zarejestrujesz przyjęcie, pojawi się tu wraz z godziną i źródłem.",
|
||||
"intakeHistoryEmptyAction": "Zarejestruj przyjęcie",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -125,6 +125,8 @@ interface Medication {
|
||||
* and left out of the take-all and log-dose flows.
|
||||
*/
|
||||
trackIntake?: boolean;
|
||||
/** v1.42.2 — doses recorded automatically. */
|
||||
autoMarkTaken?: boolean;
|
||||
/** v1.39.4 (#1040) — server-resolved: taken / skip are offered today. */
|
||||
intakeActionable?: boolean;
|
||||
/** v1.39.4 (#1040) — where today sits in the course. */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -471,6 +471,10 @@ function serialiseIntakeEvent(e: BackupIntakeEvent, disasterRecovery: boolean) {
|
||||
takenAt: e.takenAt?.toISOString() ?? null,
|
||||
skipped: e.skipped,
|
||||
source: e.source,
|
||||
// v1.42.2 — both purposes: the history labels the dose "recorded
|
||||
// automatically", and a restore that dropped the marker would relabel
|
||||
// it as one the person confirmed.
|
||||
autoRecorded: e.autoRecorded,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1081,6 +1085,11 @@ export async function buildFullBackupPayload(
|
||||
// record only. Both purposes carry it: a restore that dropped it would
|
||||
// start reminding them about a medication they chose not to track.
|
||||
trackIntake: m.trackIntake,
|
||||
// v1.42.2 — the person's choice to have doses recorded for them, and
|
||||
// the floor before which none is. Both purposes: a restore without
|
||||
// them would start reminding again, or fill history it never owned.
|
||||
autoMarkTaken: m.autoMarkTaken,
|
||||
autoMarkTakenSince: m.autoMarkTakenSince?.toISOString() ?? null,
|
||||
// Both purposes: the category is something the person chose.
|
||||
category: categoryByMedication[m.id] ?? "OTHER",
|
||||
// Both purposes carry the creation instant: it is the floor of the
|
||||
|
||||
@@ -1232,6 +1232,15 @@ export async function restoreBackup(
|
||||
oneShot: m.oneShot ?? false,
|
||||
asNeeded: m.asNeeded ?? false,
|
||||
trackIntake: m.trackIntake ?? true,
|
||||
// v1.42.2 — an option that is on always carries its floor; a
|
||||
// file that has the flag without one starts it at the restore,
|
||||
// so the auto-take pass never fills history it did not own.
|
||||
autoMarkTaken: m.autoMarkTaken ?? false,
|
||||
autoMarkTakenSince: m.autoMarkTaken
|
||||
? m.autoMarkTakenSince
|
||||
? new Date(m.autoMarkTakenSince)
|
||||
: new Date()
|
||||
: null,
|
||||
deliveryForm: m.deliveryForm ?? "ORAL",
|
||||
trackInjectionSites: m.trackInjectionSites ?? false,
|
||||
allowedInjectionSites: m.allowedInjectionSites ?? [],
|
||||
@@ -1603,6 +1612,7 @@ export async function restoreBackup(
|
||||
takenAt: e.takenAt ? new Date(e.takenAt) : null,
|
||||
skipped: e.skipped ?? false,
|
||||
autoMissed: e.autoMissed ?? false,
|
||||
autoRecorded: e.autoRecorded ?? false,
|
||||
attributionSource: e.attributionSource ?? "AUTO",
|
||||
source: e.source ?? "WEB",
|
||||
idempotencyKey: e.idempotencyKey ?? null,
|
||||
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
LIVE_ERA_REVISION_ARGS,
|
||||
liveEraStart,
|
||||
} from "@/lib/medications/scheduling/live-era";
|
||||
import { INTAKE_AUTO_TAKE_LOOKBACK_MS } from "@/lib/jobs/intake-auto-take";
|
||||
|
||||
export const INTAKE_AUTO_SKIP_QUEUE = "intake-auto-skip";
|
||||
|
||||
@@ -189,6 +190,39 @@ export function medicationAutoMissDelayMs(
|
||||
return delayMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.42.2 — the instant from which the auto-take pass owns a medication's
|
||||
* slots, or null when it owns none. It does while the option is on and the
|
||||
* medication is active and unpaused, for slots from `autoMarkTakenSince` on
|
||||
* and inside its catch-up window: a slot older than that window can no
|
||||
* longer be recorded by it, so this pass resolves it as before rather than
|
||||
* leaving it open forever.
|
||||
*/
|
||||
export function autoTakeOwnedFrom(
|
||||
medication: {
|
||||
active: boolean;
|
||||
pausedAt: Date | null;
|
||||
autoMarkTaken: boolean;
|
||||
autoMarkTakenSince: Date | null;
|
||||
},
|
||||
nowMs: number,
|
||||
): Date | null {
|
||||
if (
|
||||
!medication.autoMarkTaken ||
|
||||
!medication.autoMarkTakenSince ||
|
||||
!medication.active ||
|
||||
medication.pausedAt !== null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return new Date(
|
||||
Math.max(
|
||||
medication.autoMarkTakenSince.getTime(),
|
||||
nowMs - INTAKE_AUTO_TAKE_LOOKBACK_MS,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one auto-skip pass. Returns the flipped row count + the base cutoff
|
||||
* the pass used. Pure function over the injected Prisma client so
|
||||
@@ -250,6 +284,10 @@ export async function runIntakeAutoSkipPass(
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
active: true,
|
||||
pausedAt: true,
|
||||
autoMarkTaken: true,
|
||||
autoMarkTakenSince: true,
|
||||
schedules: {
|
||||
select: { rrule: true, rollingIntervalDays: true, doseWindows: true },
|
||||
},
|
||||
@@ -261,7 +299,12 @@ export async function runIntakeAutoSkipPass(
|
||||
// each distinct cutoff instead of one query per medication.
|
||||
const medsByDelay = new Map<
|
||||
number,
|
||||
Array<{ id: string; createdAt: Date; eraStart: Date | null }>
|
||||
Array<{
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
eraStart: Date | null;
|
||||
autoTakeFrom: Date | null;
|
||||
}>
|
||||
>();
|
||||
for (const medication of medications) {
|
||||
const delayMs = medicationAutoMissDelayMs(medication.schedules);
|
||||
@@ -270,6 +313,7 @@ export async function runIntakeAutoSkipPass(
|
||||
id: medication.id,
|
||||
createdAt: medication.createdAt,
|
||||
eraStart: liveEraStart(medication.scheduleRevisions),
|
||||
autoTakeFrom: autoTakeOwnedFrom(medication, nowMs),
|
||||
});
|
||||
medsByDelay.set(delayMs, group);
|
||||
}
|
||||
@@ -300,6 +344,12 @@ export async function runIntakeAutoSkipPass(
|
||||
createdAt: { gte: m.eraStart },
|
||||
},
|
||||
}),
|
||||
// v1.42.2 — the slots the auto-take pass owns are its to record
|
||||
// as taken; flipping one to a miss first would make the two
|
||||
// passes write the same slot. See `autoTakeOwnedFrom`.
|
||||
...(m.autoTakeFrom && {
|
||||
AND: [{ scheduledFor: { lt: m.autoTakeFrom } }],
|
||||
}),
|
||||
})),
|
||||
},
|
||||
// `syncVersion` bumps so delta-sync clients pick up the terminal
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* v1.42.2 — records each scheduled dose of an `autoMarkTaken` medication as
|
||||
* taken once its time has passed.
|
||||
*
|
||||
* For a person who takes a fixed dose reliably (three supplements every
|
||||
* evening) and does not want to confirm it every day, while compliance, the
|
||||
* dose history and the Coach still see every dose. Each pass, per opted-in
|
||||
* medication:
|
||||
*
|
||||
* 1. Expands the slots between the floor and now through the canonical
|
||||
* recurrence engine (`occurrencesBetween`, era-aware through
|
||||
* `occurrencesAcrossEras`): daysOfWeek, intervalWeeks, RRULE, cyclic
|
||||
* on/off weeks, several times a day, start and end dates, one-shot, and
|
||||
* a rolling interval counted from the last taken dose. Nothing about the
|
||||
* cadence is re-derived here.
|
||||
* 2. Drops slots inside a pause era.
|
||||
* 3. Records each remaining slot as taken AT its scheduled instant, marked
|
||||
* `autoRecorded`, unless the person already acted on it.
|
||||
*
|
||||
* The floor is the latest of `autoMarkTakenSince` (the option is never
|
||||
* applied to history before it was switched on), the medication's creation,
|
||||
* and a 7-day catch-up window (a worker that was down for a weekend still
|
||||
* fills the gap, but a pass never walks unbounded history).
|
||||
*
|
||||
* What counts as "already acted on", and is therefore never touched:
|
||||
* - a live row on the slot that is taken, skipped, or auto-missed;
|
||||
* - a tombstone on the slot with no live row: the person removed the dose,
|
||||
* and putting it back would undo their correction;
|
||||
* - a live taken row whose `takenAt` lies in the slot's band (a dose the
|
||||
* person logged off the exact slot instant, early or late).
|
||||
* A pending placeholder on the slot (the today projector's or the reminder
|
||||
* worker's) is converted in place instead of gaining a sibling. The convert
|
||||
* is one conditional UPDATE that re-checks "still pending", so a take or skip
|
||||
* the person records at the same moment wins rather than being overwritten.
|
||||
*
|
||||
* Idempotent: a second pass finds every slot it recorded taken and moves on.
|
||||
*
|
||||
* A recorded dose then goes through the same follow-ups a manual take does:
|
||||
* the inventory consume hook (exactly-once on its stamp, units per dose
|
||||
* resolved per slot), the compliance rollup for the slot's day, the server
|
||||
* caches, the one-shot lifecycle, and the silent wake that lets the iOS app
|
||||
* reconcile its widgets.
|
||||
*
|
||||
* The hourly auto-miss pass (`intake-auto-skip.ts`) leaves the slots this job
|
||||
* owns alone, so the two never write the same slot.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
import { consumeForIntake } from "@/lib/medications/inventory/consumption";
|
||||
import { reconcileOneShotState } from "@/lib/medications/lifecycle";
|
||||
import {
|
||||
buildBandsForSchedulesWithEras,
|
||||
occurrencesAcrossEras,
|
||||
} from "@/lib/medications/scheduling/schedule-eras";
|
||||
import {
|
||||
occurrencesBetween,
|
||||
type Occurrence,
|
||||
} from "@/lib/medications/scheduling/recurrence";
|
||||
import {
|
||||
buildCanonicalSchedule,
|
||||
buildRecurrenceContext,
|
||||
} from "@/lib/medications/scheduling/worker-helpers";
|
||||
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
|
||||
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
import {
|
||||
dayKeyForScheduledFor,
|
||||
recomputeMedicationComplianceForEvent,
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
export const INTAKE_AUTO_TAKE_QUEUE = "intake-auto-take";
|
||||
|
||||
/**
|
||||
* Every 15 minutes, off the reminder tick (:00/:15/:30/:45) and the hourly
|
||||
* auto-miss pass (:05). A 19:00 dose reads taken by 19:10 at the latest.
|
||||
*/
|
||||
export const INTAKE_AUTO_TAKE_CRON = "10,25,40,55 * * * *";
|
||||
|
||||
const MINUTE_MS = 60 * 1000;
|
||||
const HOUR_MS = 60 * MINUTE_MS;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
|
||||
/** How far back one pass catches up on slots it has not filled yet. */
|
||||
export const INTAKE_AUTO_TAKE_LOOKBACK_MS = 7 * DAY_MS;
|
||||
|
||||
/**
|
||||
* A rolling cadence produces its next slot only once the previous one is
|
||||
* taken, so a pass that catches up re-expands after each recorded rolling
|
||||
* dose. Bounded so a malformed schedule can never spin.
|
||||
*/
|
||||
const MAX_EXPANSION_ROUNDS = 16;
|
||||
|
||||
export interface IntakeAutoTakePayload {
|
||||
triggeredAt?: string;
|
||||
}
|
||||
|
||||
export interface IntakeAutoTakeResult {
|
||||
/** Opted-in medications the pass looked at. */
|
||||
medicationCount: number;
|
||||
/** Slots recorded taken on this pass (inserted + converted). */
|
||||
recordedCount: number;
|
||||
/** Of those, pending placeholders converted in place. */
|
||||
convertedCount: number;
|
||||
/** Users whose caches were dropped because a dose was recorded. */
|
||||
userIds: string[];
|
||||
}
|
||||
|
||||
type AutoTakeClient = Pick<
|
||||
PrismaClient,
|
||||
| "medication"
|
||||
| "medicationIntakeEvent"
|
||||
| "medicationInventoryItem"
|
||||
| "$transaction"
|
||||
>;
|
||||
|
||||
const SCHEDULE_SELECT = {
|
||||
id: true,
|
||||
windowStart: true,
|
||||
windowEnd: true,
|
||||
daysOfWeek: true,
|
||||
timesOfDay: true,
|
||||
reminderGraceMinutes: true,
|
||||
rrule: true,
|
||||
rollingIntervalDays: true,
|
||||
scheduleType: true,
|
||||
cyclicOnWeeks: true,
|
||||
cyclicOffWeeks: true,
|
||||
doseWindows: true,
|
||||
} as const;
|
||||
|
||||
/** One slot row as the occupancy check reads it. */
|
||||
interface SlotRow {
|
||||
id: string;
|
||||
scheduledFor: Date;
|
||||
takenAt: Date | null;
|
||||
skipped: boolean;
|
||||
autoMissed: boolean;
|
||||
deletedAt: Date | null;
|
||||
}
|
||||
|
||||
export type SlotDecision =
|
||||
| { kind: "create" }
|
||||
| { kind: "convert"; rowId: string }
|
||||
| {
|
||||
kind: "leave";
|
||||
reason: "actioned" | "removed" | "taken_nearby";
|
||||
};
|
||||
|
||||
/**
|
||||
* Decide what the pass does with one slot, given every row of the
|
||||
* medication around it (live and tombstoned). Pure, so the precedence rules
|
||||
* are tested without a database.
|
||||
*
|
||||
* `band` is the slot's attribution band when the cadence has one (a take
|
||||
* anywhere inside it belongs to this slot); `null` for a rolling slot, whose
|
||||
* next occurrence is by definition later than every taken dose.
|
||||
*/
|
||||
export function decideSlot(
|
||||
slotAt: Date,
|
||||
rows: readonly SlotRow[],
|
||||
band: Pick<SlotBand, "earlyStart" | "overdueEnd"> | null,
|
||||
): SlotDecision {
|
||||
const t = slotAt.getTime();
|
||||
const onSlot = rows.filter((r) => r.scheduledFor.getTime() === t);
|
||||
const liveOnSlot = onSlot.filter((r) => r.deletedAt === null);
|
||||
|
||||
if (liveOnSlot.some((r) => r.takenAt !== null || r.skipped || r.autoMissed)) {
|
||||
return { kind: "leave", reason: "actioned" };
|
||||
}
|
||||
if (liveOnSlot.length === 0 && onSlot.length > 0) {
|
||||
return { kind: "leave", reason: "removed" };
|
||||
}
|
||||
if (band) {
|
||||
const from = band.earlyStart.getTime();
|
||||
const to = band.overdueEnd.getTime();
|
||||
const takenNearby = rows.some(
|
||||
(r) =>
|
||||
r.deletedAt === null &&
|
||||
!r.skipped &&
|
||||
r.takenAt !== null &&
|
||||
r.takenAt.getTime() >= from &&
|
||||
r.takenAt.getTime() <= to,
|
||||
);
|
||||
if (takenNearby) return { kind: "leave", reason: "taken_nearby" };
|
||||
}
|
||||
const pending = [...liveOnSlot].sort(
|
||||
(a, b) => a.id.localeCompare(b.id), // deterministic pick on duplicates
|
||||
)[0];
|
||||
return pending ? { kind: "convert", rowId: pending.id } : { kind: "create" };
|
||||
}
|
||||
|
||||
/** True when `at` falls inside a `[pausedAt, resumedAt ?? now)` pause era. */
|
||||
export function isInPauseEra(
|
||||
at: Date,
|
||||
eras: readonly { pausedAt: Date; resumedAt: Date | null }[],
|
||||
now: Date,
|
||||
): boolean {
|
||||
const t = at.getTime();
|
||||
return eras.some(
|
||||
(era) =>
|
||||
t >= era.pausedAt.getTime() &&
|
||||
t < (era.resumedAt ?? now).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
/** The slot floor for one medication: see the module comment. */
|
||||
export function autoTakeFloor(input: {
|
||||
autoMarkTakenSince: Date;
|
||||
createdAt: Date;
|
||||
now: Date;
|
||||
}): Date {
|
||||
return new Date(
|
||||
Math.max(
|
||||
input.autoMarkTakenSince.getTime(),
|
||||
input.createdAt.getTime(),
|
||||
input.now.getTime() - INTAKE_AUTO_TAKE_LOOKBACK_MS,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
interface AutoTakeMedication {
|
||||
id: string;
|
||||
userId: string;
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
oneShot: boolean;
|
||||
createdAt: Date;
|
||||
autoMarkTakenSince: Date | null;
|
||||
schedules: Parameters<typeof buildCanonicalSchedule>[0][];
|
||||
scheduleRevisions: {
|
||||
id: string;
|
||||
validFrom: Date;
|
||||
validUntil: Date;
|
||||
payload: unknown;
|
||||
supersededByRevisionId: string | null;
|
||||
}[];
|
||||
pauseEras: { pausedAt: Date; resumedAt: Date | null }[];
|
||||
user: { timezone: string | null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The slots of one medication that are due by `now` and not before `floor`,
|
||||
* in chronological order. Pure; `lastIntakeAt` anchors a rolling cadence.
|
||||
*/
|
||||
export function dueAutoTakeSlots(input: {
|
||||
medication: AutoTakeMedication;
|
||||
userTz: string;
|
||||
floor: Date;
|
||||
now: Date;
|
||||
lastIntakeAt: Date | null;
|
||||
}): Occurrence[] {
|
||||
const { medication, userTz, floor, now } = input;
|
||||
const ctx = buildRecurrenceContext({
|
||||
medication,
|
||||
userTz,
|
||||
lastIntakeAt: input.lastIntakeAt,
|
||||
});
|
||||
const live = medication.schedules.map(buildCanonicalSchedule);
|
||||
const slots = occurrencesAcrossEras(
|
||||
{ from: floor, to: now },
|
||||
medication.scheduleRevisions,
|
||||
live,
|
||||
(schedule, from, to) => occurrencesBetween(schedule, from, to, ctx),
|
||||
{ oneShot: medication.oneShot },
|
||||
);
|
||||
const seen = new Set<number>();
|
||||
return slots.filter((slot) => {
|
||||
const t = slot.at.getTime();
|
||||
if (t < floor.getTime() || t > now.getTime()) return false;
|
||||
if (isInPauseEra(slot.at, medication.pauseEras, now)) return false;
|
||||
// Two schedules on one medication naming the same instant are one slot
|
||||
// (the intake row is keyed on the instant, not the schedule).
|
||||
if (seen.has(t)) return false;
|
||||
seen.add(t);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The attribution bands around the floor..now window, so a dose the person
|
||||
* logged off the exact slot instant still claims its slot. Keyed by anchor.
|
||||
*/
|
||||
function bandsByAnchor(input: {
|
||||
medication: AutoTakeMedication;
|
||||
userTz: string;
|
||||
floor: Date;
|
||||
now: Date;
|
||||
lastIntakeAt: Date | null;
|
||||
}): Map<number, SlotBand> {
|
||||
const { medication, userTz, floor, now } = input;
|
||||
const ctx = buildRecurrenceContext({
|
||||
medication,
|
||||
userTz,
|
||||
lastIntakeAt: input.lastIntakeAt,
|
||||
});
|
||||
const groups = buildBandsForSchedulesWithEras({
|
||||
medication,
|
||||
schedules: medication.schedules.map(buildCanonicalSchedule),
|
||||
revisions: medication.scheduleRevisions,
|
||||
ctx,
|
||||
userTz,
|
||||
// A day of padding each side so the first and last slots' neighbours
|
||||
// exist and their bands are capped the way the ledger caps them.
|
||||
range: {
|
||||
from: new Date(floor.getTime() - DAY_MS),
|
||||
to: new Date(now.getTime() + DAY_MS),
|
||||
},
|
||||
now,
|
||||
});
|
||||
const map = new Map<number, SlotBand>();
|
||||
for (const group of groups) {
|
||||
for (const band of group.bands) map.set(band.at.getTime(), band);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one pass. Pure over the injected client so a unit test drives it with
|
||||
* a fake and the integration test with a real Postgres.
|
||||
*/
|
||||
export async function runIntakeAutoTakePass(
|
||||
prisma: AutoTakeClient,
|
||||
options: { nowMs?: number } = {},
|
||||
): Promise<IntakeAutoTakeResult> {
|
||||
const now = new Date(options.nowMs ?? Date.now());
|
||||
|
||||
const medications = (await prisma.medication.findMany({
|
||||
where: {
|
||||
autoMarkTaken: true,
|
||||
autoMarkTakenSince: { not: null, lte: now },
|
||||
active: true,
|
||||
pausedAt: null,
|
||||
asNeeded: false,
|
||||
// A record-only medication expects no dose, so none is recorded.
|
||||
...TRACKED_INTAKE_WHERE,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
startsOn: true,
|
||||
endsOn: true,
|
||||
oneShot: true,
|
||||
createdAt: true,
|
||||
autoMarkTakenSince: true,
|
||||
schedules: { select: SCHEDULE_SELECT },
|
||||
scheduleRevisions: {
|
||||
orderBy: { validFrom: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
validFrom: true,
|
||||
validUntil: true,
|
||||
payload: true,
|
||||
supersededByRevisionId: true,
|
||||
},
|
||||
},
|
||||
pauseEras: { select: { pausedAt: true, resumedAt: true } },
|
||||
user: { select: { timezone: true } },
|
||||
},
|
||||
})) as AutoTakeMedication[];
|
||||
|
||||
let recordedCount = 0;
|
||||
let convertedCount = 0;
|
||||
const touchedUsers = new Set<string>();
|
||||
|
||||
for (const medication of medications) {
|
||||
if (!medication.autoMarkTakenSince || medication.schedules.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const userTz = medication.user.timezone || DEFAULT_TIMEZONE;
|
||||
const floor = autoTakeFloor({
|
||||
autoMarkTakenSince: medication.autoMarkTakenSince,
|
||||
createdAt: medication.createdAt,
|
||||
now,
|
||||
});
|
||||
const rolling = medication.schedules.some(
|
||||
(s) => s.rollingIntervalDays !== null,
|
||||
);
|
||||
|
||||
const outcome = await autoTakeMedication(prisma, {
|
||||
medication,
|
||||
userTz,
|
||||
floor,
|
||||
now,
|
||||
rolling,
|
||||
});
|
||||
recordedCount += outcome.recorded.length;
|
||||
convertedCount += outcome.converted;
|
||||
if (outcome.recorded.length === 0) continue;
|
||||
|
||||
touchedUsers.add(medication.userId);
|
||||
// The same follow-ups the intake routes run after a manual take.
|
||||
const days = new Set<string>();
|
||||
for (const slot of outcome.recorded) {
|
||||
await consumeForIntake({
|
||||
client: prisma,
|
||||
userId: medication.userId,
|
||||
medicationId: medication.id,
|
||||
eventId: slot.eventId,
|
||||
intakeAt: slot.at,
|
||||
});
|
||||
const dayKey = dayKeyForScheduledFor(slot.at, userTz);
|
||||
if (days.has(dayKey)) continue;
|
||||
days.add(dayKey);
|
||||
await recomputeMedicationComplianceForEvent({
|
||||
userId: medication.userId,
|
||||
medicationId: medication.id,
|
||||
scheduledFor: slot.at,
|
||||
tz: userTz,
|
||||
});
|
||||
}
|
||||
if (medication.oneShot) {
|
||||
await reconcileOneShotState(prisma, medication.id, medication.userId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const userId of touchedUsers) {
|
||||
invalidateUserMedications(userId, { evict: true });
|
||||
// Wakes the person's iOS devices so a widget or Live Activity showing
|
||||
// the dose as open reconciles. Coalesced per user, best-effort.
|
||||
queueMedicationIntakeSync({ userId, originDeviceToken: null });
|
||||
}
|
||||
|
||||
return {
|
||||
medicationCount: medications.length,
|
||||
recordedCount,
|
||||
convertedCount,
|
||||
userIds: [...touchedUsers],
|
||||
};
|
||||
}
|
||||
|
||||
async function autoTakeMedication(
|
||||
prisma: AutoTakeClient,
|
||||
input: {
|
||||
medication: AutoTakeMedication;
|
||||
userTz: string;
|
||||
floor: Date;
|
||||
now: Date;
|
||||
rolling: boolean;
|
||||
},
|
||||
): Promise<{ recorded: { eventId: string; at: Date }[]; converted: number }> {
|
||||
const { medication, userTz, floor, now, rolling } = input;
|
||||
const recorded: { eventId: string; at: Date }[] = [];
|
||||
let converted = 0;
|
||||
const handled = new Set<number>();
|
||||
|
||||
// Every row of the medication around the window, tombstones included: a
|
||||
// tombstone on a slot is the person's removal and must be seen.
|
||||
const rows: SlotRow[] = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: medication.userId,
|
||||
medicationId: medication.id,
|
||||
OR: [
|
||||
{
|
||||
scheduledFor: {
|
||||
gte: new Date(floor.getTime() - DAY_MS),
|
||||
lte: new Date(now.getTime() + DAY_MS),
|
||||
},
|
||||
},
|
||||
{
|
||||
takenAt: {
|
||||
gte: new Date(floor.getTime() - DAY_MS),
|
||||
lte: new Date(now.getTime() + DAY_MS),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
scheduledFor: true,
|
||||
takenAt: true,
|
||||
skipped: true,
|
||||
autoMissed: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
let lastIntakeAt: Date | null = null;
|
||||
if (rolling) {
|
||||
const last = await prisma.medicationIntakeEvent.findFirst({
|
||||
where: {
|
||||
userId: medication.userId,
|
||||
medicationId: medication.id,
|
||||
deletedAt: null,
|
||||
takenAt: { not: null },
|
||||
},
|
||||
orderBy: { takenAt: "desc" },
|
||||
select: { takenAt: true },
|
||||
});
|
||||
lastIntakeAt = last?.takenAt ?? null;
|
||||
}
|
||||
|
||||
for (let round = 0; round < MAX_EXPANSION_ROUNDS; round++) {
|
||||
const slots = dueAutoTakeSlots({
|
||||
medication,
|
||||
userTz,
|
||||
floor,
|
||||
now,
|
||||
lastIntakeAt,
|
||||
}).filter((slot) => !handled.has(slot.at.getTime()));
|
||||
if (slots.length === 0) break;
|
||||
const bands = bandsByAnchor({
|
||||
medication,
|
||||
userTz,
|
||||
floor,
|
||||
now,
|
||||
lastIntakeAt,
|
||||
});
|
||||
|
||||
let recordedRolling = false;
|
||||
for (const slot of slots) {
|
||||
handled.add(slot.at.getTime());
|
||||
const isRollingSlot =
|
||||
medication.schedules.find((s) => s.id === slot.scheduleId)
|
||||
?.rollingIntervalDays != null;
|
||||
const decision = decideSlot(
|
||||
slot.at,
|
||||
rows,
|
||||
isRollingSlot ? null : (bands.get(slot.at.getTime()) ?? null),
|
||||
);
|
||||
const written = await applyDecision(prisma, {
|
||||
decision,
|
||||
medication,
|
||||
at: slot.at,
|
||||
});
|
||||
if (!written) continue;
|
||||
recorded.push({ eventId: written.id, at: slot.at });
|
||||
if (decision.kind === "convert") converted++;
|
||||
// Keep the in-memory view current so a later slot's nearby-take check
|
||||
// and a rolling re-expansion see this dose.
|
||||
rows.push({
|
||||
id: written.id,
|
||||
scheduledFor: slot.at,
|
||||
takenAt: slot.at,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
deletedAt: null,
|
||||
});
|
||||
if (isRollingSlot) {
|
||||
recordedRolling = true;
|
||||
if (!lastIntakeAt || slot.at.getTime() > lastIntakeAt.getTime()) {
|
||||
lastIntakeAt = slot.at;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only a rolling cadence yields new slots after a recorded dose.
|
||||
if (!recordedRolling) break;
|
||||
}
|
||||
|
||||
return { recorded, converted };
|
||||
}
|
||||
|
||||
async function applyDecision(
|
||||
prisma: AutoTakeClient,
|
||||
input: {
|
||||
decision: SlotDecision;
|
||||
medication: AutoTakeMedication;
|
||||
at: Date;
|
||||
},
|
||||
): Promise<{ id: string } | null> {
|
||||
const { decision, medication, at } = input;
|
||||
if (decision.kind === "leave") return null;
|
||||
|
||||
if (decision.kind === "convert") {
|
||||
// Conditional: still pending at the moment of the write. A take or skip
|
||||
// the person records concurrently makes this match nothing.
|
||||
const { count } = await prisma.medicationIntakeEvent.updateMany({
|
||||
where: {
|
||||
id: decision.rowId,
|
||||
deletedAt: null,
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
},
|
||||
data: {
|
||||
takenAt: at,
|
||||
autoRecorded: true,
|
||||
syncVersion: { increment: 1 },
|
||||
},
|
||||
});
|
||||
return count === 1 ? { id: decision.rowId } : null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: medication.userId,
|
||||
medicationId: medication.id,
|
||||
scheduledFor: at,
|
||||
takenAt: at,
|
||||
skipped: false,
|
||||
// The server wrote it, as for the projector's and the reminder
|
||||
// worker's slot rows; `autoRecorded` says which server path.
|
||||
source: "REMINDER",
|
||||
autoRecorded: true,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
} catch (err) {
|
||||
// A placeholder minted for the same slot between the read and this
|
||||
// insert. The next pass converts it.
|
||||
if (isP2002(err)) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -332,6 +332,9 @@ export const QUEUE_RUNTIME: Readonly<Record<string, QueueRuntime>> = {
|
||||
},
|
||||
"medication-inventory-expire": short("One updateMany."),
|
||||
"intake-auto-skip": short("One updateMany."),
|
||||
"intake-auto-take": short(
|
||||
"At most seven days of slots per opted-in medication, a few rows each.",
|
||||
),
|
||||
"apple-health-import-v2": {
|
||||
runtime: "long",
|
||||
expireInSeconds: APPLE_HEALTH_IMPORT_SEND_OPTIONS.expireInSeconds,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Medication maintenance: inventory expiry and intake auto-skip handlers.
|
||||
* Medication maintenance: inventory expiry, intake auto-skip and auto-take
|
||||
* handlers.
|
||||
*
|
||||
* Extracted from reminder-worker.ts, which owns the queue names, cron
|
||||
* schedules, and boss.work registrations.
|
||||
@@ -10,6 +11,10 @@ import {
|
||||
type IntakeAutoSkipPayload,
|
||||
runIntakeAutoSkipPass,
|
||||
} from "@/lib/jobs/intake-auto-skip";
|
||||
import {
|
||||
type IntakeAutoTakePayload,
|
||||
runIntakeAutoTakePass,
|
||||
} from "@/lib/jobs/intake-auto-take";
|
||||
import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { expireStaleInUseItems } from "@/lib/medications/inventory/service";
|
||||
@@ -75,3 +80,36 @@ export async function handleIntakeAutoSkip(
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.42.2 — records the scheduled doses of every medication with
|
||||
* auto-mark-taken on once their time has passed. The pass lives in
|
||||
* `@/lib/jobs/intake-auto-take`; this wrapper threads the worker plumbing.
|
||||
*/
|
||||
export async function handleIntakeAutoTake(
|
||||
jobs: Job<IntakeAutoTakePayload>[],
|
||||
): Promise<JobOutcome> {
|
||||
void jobs;
|
||||
return withBackgroundEvent("job.intake_auto_take", async (evt) => {
|
||||
const prisma = getWorkerPrisma();
|
||||
try {
|
||||
const result = await runIntakeAutoTakePass(prisma, {
|
||||
nowMs: Date.now(),
|
||||
});
|
||||
evt.addMeta("intake_auto_take_medications", result.medicationCount);
|
||||
evt.addMeta("intake_auto_take_recorded", result.recordedCount);
|
||||
evt.addMeta("intake_auto_take_converted", result.convertedCount);
|
||||
return jobDone({
|
||||
intake_auto_take_medications: result.medicationCount,
|
||||
intake_auto_take_recorded: result.recordedCount,
|
||||
});
|
||||
} catch (err) {
|
||||
evt.addWarning(
|
||||
`intake-auto-take failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return jobFailed("intake auto-take pass failed", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
import { getUserTodayBounds as getUserTodayBoundsUtil } from "@/lib/tz/local-day";
|
||||
import { getWorkerPrisma, parseTimeToMinutes } from "./shared";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken";
|
||||
import {
|
||||
LIVE_ERA_REVISION_ARGS,
|
||||
liveEraStart as liveEraStartOf,
|
||||
@@ -662,7 +663,7 @@ export async function handleReminderCheck(
|
||||
}),
|
||||
});
|
||||
|
||||
if (med.notificationsEnabled) {
|
||||
if (sendsMedicationReminders(med)) {
|
||||
const claimed = await claimNotificationEvent(prisma, {
|
||||
recordUserId: med.user.id,
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
@@ -726,7 +727,7 @@ export async function handleReminderCheck(
|
||||
// banner, every other channel the user configured still delivers
|
||||
// — so the gate lives in the dispatcher's APNs branch, which is
|
||||
// the only place that knows whether an APNs send was on the table.
|
||||
if (med.notificationsEnabled) {
|
||||
if (sendsMedicationReminders(med)) {
|
||||
const recipientLocale = await resolveJobLocale(med.user.locale);
|
||||
const { title, message } = getPhaseMessage(
|
||||
currentPhase,
|
||||
|
||||
@@ -50,6 +50,11 @@ import {
|
||||
INTAKE_AUTO_SKIP_CRON,
|
||||
type IntakeAutoSkipPayload,
|
||||
} from "@/lib/jobs/intake-auto-skip";
|
||||
import {
|
||||
INTAKE_AUTO_TAKE_QUEUE,
|
||||
INTAKE_AUTO_TAKE_CRON,
|
||||
type IntakeAutoTakePayload,
|
||||
} from "@/lib/jobs/intake-auto-take";
|
||||
import {
|
||||
APPLE_HEALTH_IMPORT_V2_QUEUE,
|
||||
APPLE_HEALTH_IMPORT_LEGACY_QUEUE,
|
||||
@@ -263,6 +268,7 @@ import {
|
||||
import {
|
||||
handleMedicationInventoryExpire,
|
||||
handleIntakeAutoSkip,
|
||||
handleIntakeAutoTake,
|
||||
} from "./medication-maintenance";
|
||||
import {
|
||||
DOCUMENT_PURGE_QUEUE,
|
||||
@@ -463,6 +469,10 @@ const allQueues = [
|
||||
// this entry the schedule silently no-ops and pending rows older than
|
||||
// 24 h pile up unflipped.
|
||||
INTAKE_AUTO_SKIP_QUEUE,
|
||||
// v1.42.2 — records the doses of a medication with auto-mark-taken on.
|
||||
// Without this entry the schedule silently no-ops and those doses stay
|
||||
// open.
|
||||
INTAKE_AUTO_TAKE_QUEUE,
|
||||
APPLE_HEALTH_IMPORT_V2_QUEUE,
|
||||
APPLE_HEALTH_IMPORT_LEGACY_QUEUE,
|
||||
// v1.32.1 (issue #588) — periodic sweep for ImportJob rows orphaned by a
|
||||
@@ -671,6 +681,10 @@ const schedules: ScheduleEntry[] = [
|
||||
// reminder-check (:00) so the hourly ticks don't pile up on the same
|
||||
// boss poll.
|
||||
[INTAKE_AUTO_SKIP_QUEUE, INTAKE_AUTO_SKIP_CRON, cronIsTheRetry],
|
||||
// v1.42.2 — every 15 minutes, off the reminder tick and the auto-miss
|
||||
// pass, so a dose of an auto-mark-taken medication reads taken within
|
||||
// minutes of its time.
|
||||
[INTAKE_AUTO_TAKE_QUEUE, INTAKE_AUTO_TAKE_CRON, cronIsTheRetry],
|
||||
// v0.5.4 ios-coord — daily mood-reminder dispatch-ledger retention sweep.
|
||||
[MOOD_REMINDER_CLEANUP_QUEUE, MOOD_REMINDER_CLEANUP_CRON, cronIsTheRetry],
|
||||
// v1.15.19 — daily 03:28 Europe/Berlin duplicate dose-slot dedup
|
||||
@@ -1138,6 +1152,14 @@ export async function registerMaintenanceQueues(
|
||||
{ localConcurrency: 1 },
|
||||
handleIntakeAutoSkip,
|
||||
);
|
||||
// v1.42.2 — auto-mark-taken pass. Single-flight: two passes racing over
|
||||
// the same slots would only collide on the conditional convert.
|
||||
await createAndWork<IntakeAutoTakePayload>(
|
||||
boss,
|
||||
INTAKE_AUTO_TAKE_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleIntakeAutoTake,
|
||||
);
|
||||
// v1.4.34 — Apple Health export.zip ingest worker. localConcurrency
|
||||
// caps at 1 because the parse loop is CPU-bound and a concurrent
|
||||
// second 1 GB import would race the first for RSS.
|
||||
|
||||
@@ -112,6 +112,7 @@ const subsystemSurface = {
|
||||
"pr-detection": { audience: "system" },
|
||||
"medication-inventory-expire": { audience: "system" },
|
||||
"intake-auto-skip": { audience: "system" },
|
||||
"intake-auto-take": { audience: "system" },
|
||||
"apple-health-import-v2": { audience: "account" },
|
||||
"apple-health-import": { audience: "account" },
|
||||
"apple-health-import-reconcile": { audience: "account" },
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* v1.42.2 — "record doses automatically" (`Medication.autoMarkTaken`).
|
||||
*
|
||||
* A medication with the option on has every scheduled dose recorded as taken
|
||||
* by the `intake-auto-take` job once its time has passed. There is nothing to
|
||||
* remind about, so every reminder path asks {@link sendsMedicationReminders}
|
||||
* instead of reading `notificationsEnabled` alone. The person's own reminder
|
||||
* preference stays stored untouched: switching the option off restores
|
||||
* whatever they had chosen.
|
||||
*/
|
||||
|
||||
/**
|
||||
* True when a dose reminder may be sent for this medication: reminders are
|
||||
* switched on and its doses are not recorded automatically.
|
||||
*/
|
||||
export function sendsMedicationReminders(medication: {
|
||||
notificationsEnabled: boolean;
|
||||
autoMarkTaken: boolean;
|
||||
}): boolean {
|
||||
return medication.notificationsEnabled && !medication.autoMarkTaken;
|
||||
}
|
||||
@@ -78,6 +78,11 @@ export interface HistoryIntake {
|
||||
* fixtures omit it.
|
||||
*/
|
||||
source?: "WEB" | "API" | "REMINDER" | "IMPORT" | "APPLE_HEALTH";
|
||||
/**
|
||||
* v1.42.2 — recorded by the auto-take pass (`autoMarkTaken`), not the
|
||||
* person. Pure pass-through for the read ledger: it counts as taken.
|
||||
*/
|
||||
autoRecorded?: boolean;
|
||||
}
|
||||
|
||||
export type DoseHistoryStatus =
|
||||
|
||||
@@ -431,6 +431,9 @@ async function applyToExisting(
|
||||
// must reset or the compliance engine keeps counting the now-taken
|
||||
// dose as a miss.
|
||||
...(takenAt !== null && { autoMissed: false }),
|
||||
// v1.42.2 — a take or skip the person records is theirs, not the
|
||||
// auto-take pass's: the "recorded automatically" label goes.
|
||||
...((isExplicitTaken || isExplicitSkip) && { autoRecorded: false }),
|
||||
syncVersion: { increment: 1 },
|
||||
idempotencyKey: idempotencyKey ?? existing.idempotencyKey ?? null,
|
||||
// v1.8.5 — write the site only when this write carries a resolved
|
||||
|
||||
@@ -318,6 +318,17 @@ export const medicationResource = z
|
||||
.describe(
|
||||
"v1.39.1 — intake tracking. True (the default) for every medication that existed before the field. When false the medication is kept as a record: nothing is ever due (`nextDueAt` stays null), nothing reminds on any channel, and it is excluded from every adherence figure. Its stored schedule then travels in `recordedSchedules` while `schedules` is served empty, so a client that arms reminders from `schedules` stays silent without an update.",
|
||||
),
|
||||
autoMarkTaken: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"v1.42.2 — doses are recorded automatically. False (the default) for every medication that existed before the field. When true the server records each scheduled dose as taken (an intake with `autoRecorded: true`, `takenAt` = the slot time) shortly after its time passes, from `autoMarkTakenSince` on. A slot the person took, skipped or removed is never touched, and they can still change or skip any dose. No medication reminder is sent for it on any channel while it is on; a client that arms local reminders should not arm them for it either. `notificationsEnabled` keeps the person's own setting and applies again once this is switched off.",
|
||||
),
|
||||
autoMarkTakenSince: z.iso
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.describe(
|
||||
"v1.42.2 — when `autoMarkTaken` was last switched on. No slot anchored before it is ever recorded automatically. NULL while the option is off. Read-only — set by the server on the switch.",
|
||||
),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
updatedAt: z.iso.datetime({ offset: true }),
|
||||
schedules: z
|
||||
@@ -562,6 +573,11 @@ export const medicationIntakeEventResource = z
|
||||
.describe(
|
||||
"True when the nightly cron closed the slot as missed (no user action). Auto-missed rows count against adherence but never consume inventory.",
|
||||
),
|
||||
autoRecorded: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"v1.42.2 — true when the server recorded this dose because the medication has `autoMarkTaken` on, not the person. It counts as taken everywhere (adherence, history, Coach) and consumed inventory like a manual take. Any take or skip the person records on the row clears it. Clients label such a dose \"recorded automatically\".",
|
||||
),
|
||||
attributionSource: z
|
||||
.enum(["AUTO", "USER_PIN"])
|
||||
.describe(
|
||||
@@ -1110,6 +1126,11 @@ const doseHistoryRow = z
|
||||
takenAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
skipped: z.boolean(),
|
||||
autoMissed: z.boolean(),
|
||||
autoRecorded: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"v1.42.2 — the dose was recorded automatically (`autoMarkTaken`), not by the person.",
|
||||
),
|
||||
doseTaken: z
|
||||
.string()
|
||||
.nullable()
|
||||
|
||||
@@ -393,6 +393,10 @@ const medicationSchema = z
|
||||
// v1.39.1 (#1033) — absent in a backup written before the field existed;
|
||||
// the restore defaults it to tracking on, the behaviour those had.
|
||||
trackIntake: z.boolean().optional(),
|
||||
// v1.42.2 — absent in a backup written before the option existed; the
|
||||
// restore defaults it off, the behaviour those had.
|
||||
autoMarkTaken: z.boolean().optional(),
|
||||
autoMarkTakenSince: isoDateTime.nullable().optional(),
|
||||
// v1.39.4 — the clinical category. Absent in older files. A string, not
|
||||
// the enum: a file from a newer server may name a category this one does
|
||||
// not know, and the restore reads that as OTHER rather than refusing the
|
||||
@@ -448,6 +452,8 @@ const intakeEventSchema = z
|
||||
takenAt: isoDateTime.nullable().optional(),
|
||||
skipped: z.boolean().optional(),
|
||||
autoMissed: z.boolean().optional(),
|
||||
// v1.42.2 — absent in older files: every dose there was confirmed.
|
||||
autoRecorded: z.boolean().optional(),
|
||||
attributionSource: z.enum(IntakeAttributionSource).optional(),
|
||||
source: z.enum(IntakeSource).optional(),
|
||||
idempotencyKey: z.string().nullable().optional(),
|
||||
|
||||
@@ -83,6 +83,16 @@ const courseWindowFields = {
|
||||
.describe(
|
||||
"v1.39.1 — intake tracking. Default true. When false the medication keeps its dose, dates and schedules as information, but nothing is ever due: no reminder on any channel, no projected slot, no entry on the today list or the doses card, and no adherence figure anywhere. Existing intake history stays stored. Switching it back on resumes from that moment; the time it was off never counts as missed. Omitted on update leaves the current value. On a medication whose tracking is off, an update that does not name `trackIntake` leaves its stored schedule alone: `schedules`, `asNeeded` and `oneShot` in such a request are ignored (a client that does not know the field cannot have seen the stored schedule, which is served in `recordedSchedules`). Name `trackIntake` to change the schedule of such a medication.",
|
||||
),
|
||||
/**
|
||||
* v1.42.2 — record each scheduled dose as taken automatically once its
|
||||
* time has passed. Omitted leaves it as is.
|
||||
*/
|
||||
autoMarkTaken: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.42.2 — record each scheduled dose as taken automatically. Default false. When true the server records a taken intake (`autoRecorded: true`) for every scheduled slot once its time has passed, from the moment the option was switched on (`autoMarkTakenSince`); slots before that are never filled. A slot the person already took, skipped or removed is left alone, and the person can still change or skip any dose by hand. While on, no medication reminder is sent for this medication on any channel; a client that arms local reminders should not arm them either. Has no effect on an as-needed medication or one whose intake tracking is off. Omitted on update leaves the current value.",
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user