mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat(medications): let a course end at an instant
A course's end date is a calendar day and counts in full: the doses of that day stay due, reminded and expected until local midnight. That is right for an end date set in the editor and wrong for "End course", whose dialog promises that nothing more is due. A course now carries `endedAt` (migration 0482, additive, nullable), the instant it was ended on its `endsOn` day, projected onto the medication next to `startsOn` / `endsOn` by the one course writer. From that instant: - the schedule engine emits no slot, so a dose later that day is not due, not reminded, not projected onto the today list and never expected by compliance, while a dose earlier that day stays inside the course; - the course reads ENDED (`courseStatusAt` / `courseStatusOn`), so the dose actions, `remindersActive` and the reminder gate stop at once, follow-ups of an earlier dose included; - every engine context builder, compliance context, dose history, Coach snapshot, doctor report and the adherence storyline read it, through one select and one picker for the medication window. Any write that moves a course's `endsOn` clears the instant, so an end date chosen in the editor keeps meaning the whole day. Backups carry it in both purposes and a restore projects it again. `courseStatusAt` moves next to the other course rules. Refs #1280
This commit is contained in:
1 parent
a773d839d4
commit
23a67f61e6
68 files changed
+813
-180
No files matched your search
+52
-6
@@ -42034,6 +42034,11 @@ components:
|
||||
format: date
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
|
||||
- type: "null"
|
||||
endedAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
minLength: 1
|
||||
- type: "null"
|
||||
note:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -53787,9 +53792,10 @@ components:
|
||||
type: boolean
|
||||
description: "v1.42.2 — whether the server sends dose reminders for this medication: true only when
|
||||
`notificationsEnabled` is on, the medication is `active`, its course has not ended (`endsOn` is today or
|
||||
later on the person's clock, or unset), its intake is tracked (`trackIntake`), it is not `asNeeded`, and
|
||||
`autoMarkTaken` is off. The same decision gates every server-side reminder send. A client that arms local
|
||||
reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not stored."
|
||||
later on the person's clock, or unset, and no `endedAt` has passed), its intake is tracked (`trackIntake`),
|
||||
it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side reminder send. A
|
||||
client that arms local reminders arms them from this, not from `notificationsEnabled`. Read-only — computed,
|
||||
not stored."
|
||||
nextDueAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -53813,6 +53819,15 @@ components:
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
description: v1.5 course end (ISO date). NULL means chronic. Equals `startsOn` when `oneShot` is true.
|
||||
endedAt:
|
||||
description: "#1280 — the instant the latest course was ended with \"End course\" (the course's own `endedAt`). From it
|
||||
on no dose is due or reminded and `courseStatus` is ENDED; NULL when the course runs to the end of `endsOn`.
|
||||
Read-only — written by the course writer only."
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
oneShot:
|
||||
type: boolean
|
||||
description: v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` /
|
||||
@@ -53910,7 +53925,8 @@ components:
|
||||
- ENDED
|
||||
description: "v1.39.4 — where today (on the user's clock) sits in the course: UPCOMING before `startsOn`, ENDED after
|
||||
`endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all
|
||||
day. Read-only — computed, not stored."
|
||||
day, unless it was ended with \"End course\": then it is ENDED from that instant (`endedAt`). Read-only —
|
||||
computed, not stored."
|
||||
intakeActionable:
|
||||
type: boolean
|
||||
description: "v1.39.4 — whether the medication offers taken / skip today: true only when it is `active`, its intake is
|
||||
@@ -54037,13 +54053,23 @@ components:
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
|
||||
- type: "null"
|
||||
description: Last day, inclusive. NULL = open (only the latest course).
|
||||
endedAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
description: '#1280 — the instant "End course" ended this course, on its `endsOn` day. No dose slot at or after it is
|
||||
due, reminded or expected, and the course is ENDED from it on. NULL when the whole `endsOn` day counts (an
|
||||
end date set in the editor). Any edit that moves `endsOn` clears it.'
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- UPCOMING
|
||||
- CURRENT
|
||||
- ENDED
|
||||
description: Where today, on the person's clock, sits in this course.
|
||||
description: Where today, on the person's clock, sits in this course. A course with `endedAt` is ENDED on its `endsOn`
|
||||
day already.
|
||||
takenDoses:
|
||||
type: integer
|
||||
minimum: 0
|
||||
@@ -54058,6 +54084,7 @@ components:
|
||||
- id
|
||||
- startsOn
|
||||
- endsOn
|
||||
- endedAt
|
||||
- status
|
||||
- takenDoses
|
||||
- note
|
||||
@@ -54244,6 +54271,15 @@ components:
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
description: v1.5 course end (ISO date). NULL means chronic. Equals `startsOn` when `oneShot` is true.
|
||||
endedAt:
|
||||
description: "#1280 — the instant the latest course was ended with \"End course\" (the course's own `endedAt`). From it
|
||||
on no dose is due or reminded and `courseStatus` is ENDED; NULL when the course runs to the end of `endsOn`.
|
||||
Read-only — written by the course writer only."
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
oneShot:
|
||||
type: boolean
|
||||
description: v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` /
|
||||
@@ -54336,7 +54372,8 @@ components:
|
||||
- ENDED
|
||||
description: "v1.39.4 — where today (on the user's clock) sits in the course: UPCOMING before `startsOn`, ENDED after
|
||||
`endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all
|
||||
day. Read-only — computed, not stored."
|
||||
day, unless it was ended with \"End course\": then it is ENDED from that instant (`endedAt`). Read-only —
|
||||
computed, not stored."
|
||||
intakeActionable:
|
||||
type: boolean
|
||||
description: "v1.39.4 — whether the medication offers taken / skip today: true only when it is `active`, its intake is
|
||||
@@ -93793,6 +93830,15 @@ components:
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
description: v1.5 course end (ISO date). NULL means chronic. Equals `startsOn` when `oneShot` is true.
|
||||
endedAt:
|
||||
description: "#1280 — the instant the latest course was ended with \"End course\" (the course's own `endedAt`). From it
|
||||
on no dose is due or reminded and `courseStatus` is ENDED; NULL when the course runs to the end of `endsOn`.
|
||||
Read-only — written by the course writer only."
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
oneShot:
|
||||
type: boolean
|
||||
description: v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` /
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- "End course" ends a course at the moment it is pressed (#1280).
|
||||
--
|
||||
-- A course's `ends_on` is a calendar day and counts in full: the doses of
|
||||
-- that day stay due, reminded and expected until local midnight. That is the
|
||||
-- right reading for an end date written in the course editor ("the last day
|
||||
-- of the pack"), and the wrong one for "End course", whose dialog promises
|
||||
-- that nothing more is due. `ended_at` records the instant the course was
|
||||
-- ended that way: it lies on the `ends_on` day, a dose slot at or after it
|
||||
-- is not part of the course, and the course reads as ended from then on.
|
||||
-- NULL keeps the full-day reading every existing course has.
|
||||
--
|
||||
-- `medications.ended_at` is the projection of the latest course's value,
|
||||
-- written next to `starts_on` / `ends_on` by the one course writer, so the
|
||||
-- engines that read the medication's window read it too.
|
||||
--
|
||||
-- Nullable, no default, no backfill: no existing course was ended this way.
|
||||
-- Reversibility: dropping both columns restores the full-day reading.
|
||||
--
|
||||
-- @retry-on-lock-timeout
|
||||
|
||||
DO $migration$
|
||||
BEGIN
|
||||
PERFORM set_config('lock_timeout', '5s', true);
|
||||
ALTER TABLE "medication_courses"
|
||||
ADD COLUMN IF NOT EXISTS "ended_at" TIMESTAMPTZ(6);
|
||||
ALTER TABLE "medications"
|
||||
ADD COLUMN IF NOT EXISTS "ended_at" TIMESTAMPTZ(6);
|
||||
END
|
||||
$migration$;
|
||||
@@ -2903,6 +2903,12 @@ model Medication {
|
||||
/// minting slots past this date. NULL = no end date (chronic).
|
||||
/// When `oneShot` is true, `endsOn` always equals `startsOn`.
|
||||
endsOn DateTime? @map("ends_on") @db.Date
|
||||
/// #1280 — the instant the latest course was ended with "End course", the
|
||||
/// projection of `MedicationCourse.endedAt` written next to `startsOn` /
|
||||
/// `endsOn` by `src/lib/medications/courses.ts` only. On the `endsOn` day;
|
||||
/// no dose slot at or after it is due, reminded or expected, and the course
|
||||
/// reads as ended from it on. NULL = the course runs to the end of `endsOn`.
|
||||
endedAt DateTime? @map("ended_at") @db.Timestamptz(6)
|
||||
/// v1.5 — single-administration medication (flu shot, vaccine,
|
||||
/// post-op single dose, etc.). When true the medication has at
|
||||
/// most one MedicationIntakeEvent and `active` flips to false once
|
||||
@@ -3157,6 +3163,13 @@ model MedicationCourse {
|
||||
startsOn DateTime @map("starts_on") @db.Date
|
||||
/// Last day, inclusive. NULL = open (only the latest course may be open).
|
||||
endsOn DateTime? @map("ends_on") @db.Date
|
||||
/// #1280 — set by "End course": the instant the course ended, on the
|
||||
/// `endsOn` day. A dose slot at or after it is not part of the course (not
|
||||
/// due, not reminded, not expected by compliance), so the course ends when
|
||||
/// the person ends it rather than at local midnight. NULL = the whole
|
||||
/// `endsOn` day counts, the reading of an end date set in the editor. Any
|
||||
/// later write that moves `endsOn` clears it.
|
||||
endedAt DateTime? @map("ended_at") @db.Timestamptz(6)
|
||||
/// Why this course, in the person's words. AES-256-GCM at rest.
|
||||
noteEncrypted Bytes? @map("note_encrypted")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
* which it is. The scan asserts a floor on what it found, so a matcher that
|
||||
* stopped matching fails instead of agreeing with an empty tree.
|
||||
*
|
||||
* #1280 — the same list carries the instant "End course" ended a course:
|
||||
* every medication builder threads `endedAt` into the context, or the
|
||||
* engine would keep a dose due after the course was ended. A builder passes
|
||||
* when its block names `endedAt` or spreads a window that carries it
|
||||
* (`...window` from `mintWindow`, `...medicationWindow(row)`).
|
||||
*
|
||||
* Limit, stated: a literal passed straight to the engine without the
|
||||
* `RecurrenceContext` annotation is invisible here. Every medication path
|
||||
* today goes through one of the annotated builders below.
|
||||
@@ -69,19 +75,22 @@ function blockAt(text: string, open: number): string {
|
||||
interface Builder {
|
||||
file: string;
|
||||
declaresCourseStart: boolean;
|
||||
threadsEndedAt: boolean;
|
||||
}
|
||||
|
||||
const THREADS_ENDED_AT = /\bendedAt\b|\.\.\.\s*(window\b|medicationWindow\()/;
|
||||
|
||||
function builders(): Builder[] {
|
||||
const out: Builder[] = [];
|
||||
for (const file of sourceFiles(SRC)) {
|
||||
const text = readSourceText(file);
|
||||
for (const m of text.matchAll(DECLARATION)) {
|
||||
const open = m.index! + m[0].length - 1;
|
||||
const block = blockAt(text, open);
|
||||
out.push({
|
||||
file: relative(ROOT, file),
|
||||
declaresCourseStart: /rollingAnchor\s*:\s*"courseStart"/.test(
|
||||
blockAt(text, open),
|
||||
),
|
||||
declaresCourseStart: /rollingAnchor\s*:\s*"courseStart"/.test(block),
|
||||
threadsEndedAt: THREADS_ENDED_AT.test(block),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -114,6 +123,18 @@ describe("the rolling course anchor is opt-in", () => {
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("has every medication builder thread the instant a course was ended", () => {
|
||||
const medication = builders().filter((b) =>
|
||||
MEDICATION_BUILDERS.includes(b.file),
|
||||
);
|
||||
expect(medication.length).toBeGreaterThanOrEqual(3);
|
||||
expect(medication.filter((b) => !b.threadsEndedAt)).toEqual([]);
|
||||
// The matcher, held against what it must and must not accept.
|
||||
expect(THREADS_ENDED_AT.test("endedAt: row.endedAt,")).toBe(true);
|
||||
expect(THREADS_ENDED_AT.test("...medicationWindow(row),")).toBe(true);
|
||||
expect(THREADS_ENDED_AT.test("endsOn: row.endsOn,")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps every other builder on the default (the last intake anchors)", () => {
|
||||
const declaring = builders().filter(
|
||||
(b) => NON_MEDICATION_BUILDERS.includes(b.file) && b.declaresCourseStart,
|
||||
|
||||
@@ -155,6 +155,7 @@ describe.each(EDGES)("day paths with the host in $zone", (edge) => {
|
||||
startsOn: null,
|
||||
// `endsOn` is a @db.Date column: the calendar date as UTC midnight.
|
||||
endsOn: dayKeyAsUtcMidnight(edge.localDay),
|
||||
endedAt: null,
|
||||
},
|
||||
edge.now,
|
||||
edge.zone,
|
||||
|
||||
@@ -234,7 +234,7 @@ export const GET = apiHandler(
|
||||
createdAt: medication.createdAt,
|
||||
};
|
||||
const ctx = buildRecurrenceContext({
|
||||
medication: bandMedication,
|
||||
medication: { ...bandMedication, endedAt: medication.endedAt },
|
||||
userTz,
|
||||
lastIntakeAt,
|
||||
});
|
||||
|
||||
@@ -317,6 +317,7 @@ describe("buildLedgerCompliance — doctor-report adherence via the dose ledger"
|
||||
trackIntake: true,
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-04-01T00:00:00.000Z"),
|
||||
schedules: [dailySchedule()],
|
||||
@@ -354,6 +355,7 @@ describe("buildLedgerCompliance — doctor-report adherence via the dose ledger"
|
||||
trackIntake: true,
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-04-01T00:00:00.000Z"),
|
||||
schedules: [dailySchedule()],
|
||||
@@ -429,6 +431,7 @@ describe("buildLedgerCompliance — doctor-report adherence via the dose ledger"
|
||||
trackIntake: true,
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-04-01T00:00:00.000Z"),
|
||||
schedules: [dailySchedule()],
|
||||
@@ -460,6 +463,7 @@ describe("buildLedgerCompliance — doctor-report adherence via the dose ledger"
|
||||
trackIntake: false,
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-04-01T00:00:00.000Z"),
|
||||
// The schedule stays on record; it must not produce a rate.
|
||||
|
||||
@@ -706,6 +706,7 @@ describe("buildCoachSnapshot", () => {
|
||||
name: "Testdrug",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt,
|
||||
schedules: [schedule],
|
||||
|
||||
@@ -37,6 +37,7 @@ for (const tz of ["Asia/Kolkata", "America/New_York"]) {
|
||||
{
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt: d1(16, 5),
|
||||
schedules: [
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
import { loadFreeWeekdaySpansForPrompt } from "@/lib/profile/life-circumstances";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { MEDICATION_WINDOW_SELECT } from "@/lib/medications/course-window";
|
||||
import { extractFeatures } from "@/lib/insights/features";
|
||||
import type { CoachDataCluster } from "@/lib/validations/coach-prefs";
|
||||
import type { UnitPreferences } from "@/lib/measurements/display-transform";
|
||||
@@ -511,8 +512,7 @@ async function buildCoachSnapshotImpl(
|
||||
where: { userId, asNeeded: false, ...TRACKED_INTAKE_WHERE },
|
||||
select: {
|
||||
id: true,
|
||||
startsOn: true,
|
||||
endsOn: true,
|
||||
...MEDICATION_WINDOW_SELECT,
|
||||
oneShot: true,
|
||||
createdAt: true,
|
||||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||||
|
||||
@@ -58,6 +58,7 @@ const MEDICATION = {
|
||||
// The row projects the latest course.
|
||||
startsOn: COURSES[1].startsOn,
|
||||
endsOn: COURSES[1].endsOn,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-03-15T10:00:00.000Z"),
|
||||
};
|
||||
@@ -124,6 +125,7 @@ describe("the streak across two courses (v1.40, #1024)", () => {
|
||||
const A = {
|
||||
startsOn: dateOfDayKey("2026-06-01"),
|
||||
endsOn: dateOfDayKey("2026-06-10"),
|
||||
endedAt: null,
|
||||
};
|
||||
const B = { startsOn: dateOfDayKey("2026-06-13"), endsOn: null };
|
||||
const NOW_B = new Date("2026-06-27T18:00:00.000Z");
|
||||
@@ -141,6 +143,7 @@ describe("the streak across two courses (v1.40, #1024)", () => {
|
||||
{
|
||||
startsOn: B.startsOn,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-05-20T10:00:00.000Z"),
|
||||
courses: [A, B],
|
||||
|
||||
@@ -61,6 +61,7 @@ const medication = {
|
||||
active: true,
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
schedules: [twiceDaily],
|
||||
|
||||
@@ -136,7 +136,13 @@ describe("buildMedicationComplianceBundle — parity with the per-window composi
|
||||
const schedules = [dailySchedule()];
|
||||
const events = dailyFixtureEvents();
|
||||
const ctx = buildComplianceMedicationContext(
|
||||
{ startsOn: null, endsOn: null, oneShot: false, createdAt },
|
||||
{
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt,
|
||||
},
|
||||
lastNonSkippedTakenAt(events),
|
||||
TZ,
|
||||
);
|
||||
@@ -191,6 +197,7 @@ describe("buildMedicationComplianceBundle — parity with the per-window composi
|
||||
{
|
||||
startsOn: new Date(NOW.getTime() - 380 * DAY_MS),
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt,
|
||||
},
|
||||
@@ -223,7 +230,13 @@ describe("buildMedicationComplianceBundle — parity with the per-window composi
|
||||
it("empty schedule list: zeros short-circuit matches calculateCompliance", () => {
|
||||
const createdAt = new Date(NOW.getTime() - 10 * DAY_MS);
|
||||
const ctx = buildComplianceMedicationContext(
|
||||
{ startsOn: null, endsOn: null, oneShot: false, createdAt },
|
||||
{
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt,
|
||||
},
|
||||
null,
|
||||
TZ,
|
||||
);
|
||||
@@ -250,7 +263,13 @@ describe("dailyComplianceRatesFromLedger — cadence-aware per-day series", () =
|
||||
const createdAt = new Date(NOW.getTime() - 60 * DAY_MS);
|
||||
const schedules = [weeklyMondaySchedule()];
|
||||
const ctx = buildComplianceMedicationContext(
|
||||
{ startsOn: null, endsOn: null, oneShot: false, createdAt },
|
||||
{
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt,
|
||||
},
|
||||
null,
|
||||
TZ,
|
||||
);
|
||||
@@ -300,7 +319,13 @@ describe("dailyComplianceRatesFromLedger — cadence-aware per-day series", () =
|
||||
const createdAt = new Date(NOW.getTime() - 60 * DAY_MS);
|
||||
const schedules = [weeklyMondaySchedule()];
|
||||
const ctx = buildComplianceMedicationContext(
|
||||
{ startsOn: null, endsOn: null, oneShot: false, createdAt },
|
||||
{
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt,
|
||||
},
|
||||
null,
|
||||
TZ,
|
||||
);
|
||||
@@ -383,7 +408,13 @@ describe("tallyLedgerRows — windowed tallies over one shared ledger", () => {
|
||||
const schedules = [dailySchedule()];
|
||||
const events = dailyFixtureEvents();
|
||||
const ctx = buildComplianceMedicationContext(
|
||||
{ startsOn: null, endsOn: null, oneShot: false, createdAt },
|
||||
{
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt,
|
||||
},
|
||||
lastNonSkippedTakenAt(events),
|
||||
TZ,
|
||||
);
|
||||
@@ -407,7 +438,13 @@ describe("tallyLedgerRows — windowed tallies over one shared ledger", () => {
|
||||
const schedules = [dailySchedule()];
|
||||
const events = dailyFixtureEvents();
|
||||
const ctx = buildComplianceMedicationContext(
|
||||
{ startsOn: null, endsOn: null, oneShot: false, createdAt },
|
||||
{
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt,
|
||||
},
|
||||
lastNonSkippedTakenAt(events),
|
||||
TZ,
|
||||
);
|
||||
|
||||
@@ -78,6 +78,7 @@ describe("H-MED1 — paused days drop out of the compliance denominator", () =>
|
||||
const medBase = {
|
||||
startsOn: createdAt,
|
||||
endsOn,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt,
|
||||
};
|
||||
@@ -209,6 +210,7 @@ describe("M-MED3 — the compliance rate honours archived schedule eras", () =>
|
||||
const medBase = {
|
||||
startsOn: createdAt,
|
||||
endsOn,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt,
|
||||
};
|
||||
|
||||
@@ -55,6 +55,7 @@ function context(tz: string) {
|
||||
{
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2024-06-01T00:00:00Z"),
|
||||
},
|
||||
|
||||
@@ -417,6 +417,7 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => {
|
||||
return {
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2024-12-01T00:00:00Z"),
|
||||
lastIntakeAt: null,
|
||||
@@ -1002,6 +1003,7 @@ describe("expectedSlotCountForDay + lastNonSkippedTakenAt", () => {
|
||||
{
|
||||
startsOn: overrides.startsOn ?? null,
|
||||
endsOn: overrides.endsOn ?? null,
|
||||
endedAt: null,
|
||||
oneShot: overrides.oneShot ?? false,
|
||||
createdAt: overrides.createdAt ?? new Date("2024-12-01T00:00:00Z"),
|
||||
},
|
||||
@@ -1121,6 +1123,7 @@ describe("expectedSlotCountForDay + lastNonSkippedTakenAt", () => {
|
||||
{
|
||||
startsOn: medCtx.startsOn,
|
||||
endsOn: medCtx.endsOn,
|
||||
endedAt: null,
|
||||
oneShot: medCtx.oneShot,
|
||||
createdAt: medCtx.createdAt,
|
||||
lastIntakeAt: medCtx.lastIntakeAt,
|
||||
@@ -1466,6 +1469,7 @@ describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => {
|
||||
{
|
||||
startsOn: overrides.startsOn ?? null,
|
||||
endsOn: overrides.endsOn ?? null,
|
||||
endedAt: null,
|
||||
oneShot: overrides.oneShot ?? false,
|
||||
// Created well before the windows so no day is excluded.
|
||||
createdAt: overrides.createdAt ?? new Date("2025-01-01T00:00:00Z"),
|
||||
@@ -2102,6 +2106,7 @@ describe("expectedSlotsBetween", () => {
|
||||
{
|
||||
startsOn: overrides.startsOn ?? null,
|
||||
endsOn: overrides.endsOn ?? null,
|
||||
endedAt: null,
|
||||
oneShot: overrides.oneShot ?? false,
|
||||
createdAt: overrides.createdAt ?? new Date("2025-01-01T00:00:00Z"),
|
||||
},
|
||||
@@ -2317,6 +2322,7 @@ describe("calculateCompliance — autoMissed forgotten doses (BUG #2 / #3)", ()
|
||||
{
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2025-01-08T00:00:00Z"),
|
||||
},
|
||||
@@ -2370,6 +2376,7 @@ describe("calculateCompliance — irregular twice-daily taker (BUG 3 verify)", (
|
||||
{
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
// Created well before the window so age never clamps the denominator.
|
||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||
@@ -2717,6 +2724,7 @@ describe("tallyComplianceFromLedger — the unified % keystone", () => {
|
||||
return {
|
||||
startsOn: new Date("2026-06-01T00:00:00Z"),
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
lastIntakeAt: null,
|
||||
@@ -3048,6 +3056,7 @@ describe("tallyComplianceFromLedger — the unified % keystone", () => {
|
||||
id: "x",
|
||||
startsOn: ctxFor().startsOn,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt: ctxFor().createdAt,
|
||||
},
|
||||
|
||||
@@ -65,6 +65,7 @@ export function toRecurrenceCtx(
|
||||
id: idTag,
|
||||
startsOn: window.startsOn,
|
||||
endsOn: window.endsOn,
|
||||
endedAt: ctx.endedAt ?? null,
|
||||
oneShot: ctx.oneShot,
|
||||
createdAt: ctx.createdAt,
|
||||
},
|
||||
|
||||
@@ -144,7 +144,7 @@ export const SCHEDULE_COMPLIANCE_SELECT = {
|
||||
* sibling of {@link SCHEDULE_COMPLIANCE_SELECT}.
|
||||
*/
|
||||
export const COURSES_COMPLIANCE_SELECT = {
|
||||
select: { startsOn: true, endsOn: true },
|
||||
select: { startsOn: true, endsOn: true, endedAt: true },
|
||||
orderBy: { startsOn: "asc" },
|
||||
} as const;
|
||||
|
||||
@@ -158,6 +158,13 @@ export const COURSES_COMPLIANCE_SELECT = {
|
||||
export interface ComplianceMedicationContext {
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
/**
|
||||
* #1280 — the instant "End course" ended the latest course: no slot at or
|
||||
* after it is expected. Earlier courses carry their own in `courses`.
|
||||
* Every production context comes from `buildComplianceMedicationContext`,
|
||||
* whose input requires it.
|
||||
*/
|
||||
endedAt?: Date | null;
|
||||
oneShot: boolean;
|
||||
createdAt: Date;
|
||||
lastIntakeAt: Date | null;
|
||||
@@ -222,7 +229,7 @@ export function slotInsideCourses(
|
||||
): boolean {
|
||||
const courses = ctx.courses;
|
||||
if (!courses || courses.length < 2) return true;
|
||||
return isInsideCourses(userDayKey(at, ctx.timeZone), courses);
|
||||
return isInsideCourses(userDayKey(at, ctx.timeZone), courses, at);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,6 +252,7 @@ export function buildComplianceMedicationContext(
|
||||
med: {
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
endedAt: Date | null;
|
||||
oneShot: boolean;
|
||||
createdAt: Date;
|
||||
/** v1.16.3 — thread the archived eras when the bundle carries them. */
|
||||
@@ -260,6 +268,7 @@ export function buildComplianceMedicationContext(
|
||||
return {
|
||||
startsOn: med.startsOn,
|
||||
endsOn: med.endsOn,
|
||||
endedAt: med.endedAt,
|
||||
oneShot: med.oneShot,
|
||||
createdAt: med.createdAt,
|
||||
lastIntakeAt,
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
* summary, never an overdue state, until a fresh block arrives.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { medicationWindow } from "@/lib/medications/course-window";
|
||||
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
|
||||
import { loadAutoTakeRollingAnchors } from "@/lib/medications/auto-take";
|
||||
import {
|
||||
@@ -220,8 +221,7 @@ export async function buildMedsTodayBlock(
|
||||
const display = computeDisplayDue({
|
||||
medication: {
|
||||
id: m.id,
|
||||
startsOn: m.startsOn,
|
||||
endsOn: m.endsOn,
|
||||
...medicationWindow(m),
|
||||
oneShot: m.oneShot,
|
||||
createdAt: m.createdAt,
|
||||
},
|
||||
|
||||
@@ -615,6 +615,8 @@ export interface DoctorReportComplianceMedication {
|
||||
trackIntake: boolean;
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
/** #1280 — the instant "End course" ended the latest course. */
|
||||
endedAt: Date | null;
|
||||
oneShot: boolean;
|
||||
createdAt: Date;
|
||||
schedules: ComplianceSchedule[];
|
||||
@@ -622,7 +624,7 @@ export interface DoctorReportComplianceMedication {
|
||||
/** v1.25 — pause eras so paused days drop out of the denominator. */
|
||||
pauseEras?: MedicationPauseEraLike[];
|
||||
/** v1.40 (#1024) — the courses, so a gap between two expects nothing. */
|
||||
courses?: { startsOn: Date; endsOn: Date | null }[];
|
||||
courses?: { startsOn: Date; endsOn: Date | null; endedAt: Date | null }[];
|
||||
}
|
||||
|
||||
/** A window-bounded intake row keyed to its medication. */
|
||||
|
||||
@@ -15,7 +15,11 @@
|
||||
*
|
||||
* Pure data assembly — no auth, no rate limit, no audit. Idempotent.
|
||||
*/
|
||||
import { courseStatusOn, dayKeyOfDate } from "@/lib/medications/course-window";
|
||||
import {
|
||||
courseStatusOn,
|
||||
dayKeyOfDate,
|
||||
medicationWindow,
|
||||
} from "@/lib/medications/course-window";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
getEffectiveRange,
|
||||
@@ -484,8 +488,7 @@ export async function collectDoctorReportData(
|
||||
name: m.name,
|
||||
asNeeded: m.asNeeded,
|
||||
trackIntake: m.trackIntake,
|
||||
startsOn: m.startsOn,
|
||||
endsOn: m.endsOn,
|
||||
...medicationWindow(m),
|
||||
oneShot: m.oneShot,
|
||||
createdAt: m.createdAt,
|
||||
schedules: m.schedules,
|
||||
|
||||
@@ -210,6 +210,8 @@ function makePrisma() {
|
||||
id: "course-march",
|
||||
startsOn: new Date("2026-03-01T00:00:00.000Z"),
|
||||
endsOn: new Date("2026-03-07T00:00:00.000Z"),
|
||||
// #1280 — ended with "End course" after the morning dose.
|
||||
endedAt: new Date("2026-03-07T09:30:00.000Z"),
|
||||
noteEncrypted: encryptToBytes("the March flu"),
|
||||
createdAt: new Date("2026-03-01T08:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-01T08:05:00.000Z"),
|
||||
@@ -218,6 +220,7 @@ function makePrisma() {
|
||||
id: "course-june",
|
||||
startsOn: new Date("2026-06-10T00:00:00.000Z"),
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
noteEncrypted: null,
|
||||
createdAt: new Date("2026-06-10T08:00:00.000Z"),
|
||||
updatedAt: new Date("2026-06-10T08:05:00.000Z"),
|
||||
@@ -806,12 +809,14 @@ describe("buildFullBackupPayload — medication courses (v1.40, #1024)", () => {
|
||||
{
|
||||
startsOn: "2026-03-01",
|
||||
endsOn: "2026-03-07",
|
||||
endedAt: "2026-03-07T09:30:00.000Z",
|
||||
note: "the March flu",
|
||||
createdAt: "2026-03-01T08:00:00.000Z",
|
||||
},
|
||||
{
|
||||
startsOn: "2026-06-10",
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
note: null,
|
||||
createdAt: "2026-06-10T08:00:00.000Z",
|
||||
},
|
||||
@@ -823,6 +828,7 @@ describe("buildFullBackupPayload — medication courses (v1.40, #1024)", () => {
|
||||
expect(march).toMatchObject({
|
||||
startsOn: "2026-03-01",
|
||||
endsOn: "2026-03-07",
|
||||
endedAt: "2026-03-07T09:30:00.000Z",
|
||||
note: null,
|
||||
createdAt: "2026-03-01T08:00:00.000Z",
|
||||
updatedAt: "2026-03-01T08:05:00.000Z",
|
||||
|
||||
@@ -123,6 +123,9 @@ export const medicationCourseSchema = z
|
||||
.object({
|
||||
startsOn: z.iso.date(),
|
||||
endsOn: z.iso.date().nullable(),
|
||||
// #1280 — the instant "End course" ended the course, on its `endsOn`
|
||||
// day. A file without it (any written before) restores the full-day end.
|
||||
endedAt: isoDateTime.nullable().optional(),
|
||||
note: z.string().max(280).nullable().optional(),
|
||||
// The note's ciphertext, disaster-recovery files only. A file without it
|
||||
// (any written before) carries the readable note, which the restore seals.
|
||||
|
||||
@@ -228,6 +228,10 @@ function serialiseMedicationHistory(
|
||||
courses: m.courses.map((c) => ({
|
||||
startsOn: dayKeyOfDate(c.startsOn),
|
||||
endsOn: c.endsOn ? dayKeyOfDate(c.endsOn) : null,
|
||||
// #1280 — when "End course" ended it. Both purposes: without it the
|
||||
// restored course runs to midnight of its last day and expects the
|
||||
// doses the person had stopped.
|
||||
endedAt: c.endedAt ? c.endedAt.toISOString() : null,
|
||||
...(disasterRecovery
|
||||
? {
|
||||
note: null,
|
||||
|
||||
@@ -349,6 +349,7 @@ async function restoreCourses(
|
||||
const courseRows: Array<{
|
||||
startsOn: Date;
|
||||
endsOn: Date | null;
|
||||
endedAt?: Date | null;
|
||||
note: string | null;
|
||||
noteEncrypted?: string | null;
|
||||
createdAt?: Date;
|
||||
@@ -358,6 +359,7 @@ async function restoreCourses(
|
||||
? m.courses.map((c) => ({
|
||||
startsOn: dateOfDayKey(c.startsOn),
|
||||
endsOn: c.endsOn ? dateOfDayKey(c.endsOn) : null,
|
||||
endedAt: c.endedAt ? new Date(c.endedAt) : null,
|
||||
note: c.note ?? null,
|
||||
...(c.noteEncrypted !== undefined
|
||||
? { noteEncrypted: c.noteEncrypted }
|
||||
@@ -380,6 +382,7 @@ async function restoreCourses(
|
||||
userId: ownerId,
|
||||
startsOn: c.startsOn,
|
||||
endsOn: c.endsOn,
|
||||
endedAt: c.endedAt ?? null,
|
||||
// Ciphertext verbatim from a disaster-recovery file; the readable note
|
||||
// of a portable or older file sealed here.
|
||||
noteEncrypted:
|
||||
@@ -397,7 +400,11 @@ async function restoreCourses(
|
||||
const window = projectCourseWindow(courseRows);
|
||||
await tx.medication.update({
|
||||
where: { id: created.id },
|
||||
data: { startsOn: window.startsOn, endsOn: window.endsOn },
|
||||
data: {
|
||||
startsOn: window.startsOn,
|
||||
endsOn: window.endsOn,
|
||||
endedAt: window.endedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -210,4 +210,18 @@ describe("adherence storyline course window", () => {
|
||||
);
|
||||
expect(where.OR[1].endsOn.gte.toISOString()).toBe(lastDay.toISOString());
|
||||
});
|
||||
|
||||
it("leaves out a course ended with End course on its last day (#1280)", async () => {
|
||||
await buildAdherenceStoryline(
|
||||
"user-1",
|
||||
"UTC",
|
||||
new Date("2026-06-21T12:00:00.000Z"),
|
||||
);
|
||||
// The candidate read is the one that bounds the window by `endsOn`.
|
||||
const where = mocks.medicationFindMany.mock.calls
|
||||
.map(([args]) => (args as { where?: Record<string, unknown> }).where)
|
||||
.reverse()
|
||||
.find((w) => w && "OR" in w);
|
||||
expect(where).toHaveProperty("endedAt", null);
|
||||
});
|
||||
});
|
||||
@@ -184,6 +184,8 @@ export async function buildAdherenceStoryline(
|
||||
{ endsOn: null },
|
||||
{ endsOn: { gte: dayKeyAsUtcMidnight(todayKey) } },
|
||||
],
|
||||
// #1280 — a course ended with "End course" is over from that instant.
|
||||
endedAt: null,
|
||||
},
|
||||
select: { name: true, treatmentClass: true },
|
||||
});
|
||||
|
||||
@@ -108,6 +108,7 @@ function medication(
|
||||
userId: overrides.userId ?? "u1",
|
||||
startsOn: overrides.startsOn ?? null,
|
||||
endsOn: overrides.endsOn ?? null,
|
||||
endedAt: null,
|
||||
oneShot: overrides.oneShot ?? false,
|
||||
createdAt: overrides.createdAt ?? new Date("2026-01-01T00:00:00Z"),
|
||||
autoMarkTakenSince:
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
* owns alone, so the two never write the same slot.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { MEDICATION_WINDOW_SELECT } from "@/lib/medications/course-window";
|
||||
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
@@ -154,6 +155,8 @@ interface AutoTakeMedication {
|
||||
userId: string;
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
/** #1280 — no slot at or after the instant the course was ended is filled. */
|
||||
endedAt: Date | null;
|
||||
oneShot: boolean;
|
||||
createdAt: Date;
|
||||
autoMarkTakenSince: Date | null;
|
||||
@@ -262,8 +265,7 @@ export async function runIntakeAutoTakePass(
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
startsOn: true,
|
||||
endsOn: true,
|
||||
...MEDICATION_WINDOW_SELECT,
|
||||
oneShot: true,
|
||||
createdAt: true,
|
||||
autoMarkTakenSince: true,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod/v4";
|
||||
import { medicationWindow } from "@/lib/medications/course-window";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -152,8 +153,7 @@ export const getMedicationScheduleTool: McpToolDefinition = {
|
||||
const display = computeDisplayDue({
|
||||
medication: {
|
||||
id: m.id,
|
||||
startsOn: m.startsOn,
|
||||
endsOn: m.endsOn,
|
||||
...medicationWindow(m),
|
||||
oneShot: m.oneShot,
|
||||
createdAt: m.createdAt,
|
||||
},
|
||||
|
||||
@@ -25,6 +25,7 @@ describe("sendsMedicationReminders", () => {
|
||||
trackIntake: (bits & 8) !== 0,
|
||||
asNeeded: (bits & 16) !== 0,
|
||||
endsOn: ended ? new Date("2026-06-09T00:00:00.000Z") : null,
|
||||
endedAt: null,
|
||||
};
|
||||
expect(sendsMedicationReminders(med, at), JSON.stringify(med)).toBe(
|
||||
med.notificationsEnabled &&
|
||||
@@ -44,6 +45,7 @@ describe("sendsMedicationReminders", () => {
|
||||
active: true,
|
||||
trackIntake: true,
|
||||
asNeeded: false,
|
||||
endedAt: null,
|
||||
};
|
||||
expect(
|
||||
sendsMedicationReminders(
|
||||
@@ -66,6 +68,29 @@ describe("sendsMedicationReminders", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("stops at the instant End course ended the course (#1280)", () => {
|
||||
const med = {
|
||||
notificationsEnabled: true,
|
||||
autoMarkTaken: false,
|
||||
active: true,
|
||||
trackIntake: true,
|
||||
asNeeded: false,
|
||||
endsOn: new Date("2026-06-10T00:00:00.000Z"),
|
||||
endedAt: new Date("2026-06-10T09:00:00.000Z"),
|
||||
};
|
||||
// 10:00Z is after the instant: no reminder for the rest of the day,
|
||||
// follow-ups of an earlier dose included.
|
||||
expect(sendsMedicationReminders(med, at)).toBe(false);
|
||||
expect(
|
||||
sendsMedicationReminders(med, {
|
||||
...at,
|
||||
now: new Date("2026-06-10T08:59:00.000Z"),
|
||||
}),
|
||||
).toBe(true);
|
||||
// The same last day without the instant reminds all day.
|
||||
expect(sendsMedicationReminders({ ...med, endedAt: null }, at)).toBe(true);
|
||||
});
|
||||
|
||||
it("is the decision at every gate and on every published DTO", () => {
|
||||
const src = (rel: string) =>
|
||||
readFileSync(join(process.cwd(), "src", rel), "utf8");
|
||||
|
||||
@@ -55,6 +55,7 @@ it("counts an off-schedule row as taken only when it has a taken time", async ()
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
asNeeded: false,
|
||||
trackIntake: true,
|
||||
|
||||
@@ -67,6 +67,7 @@ for (const tz of ["Asia/Kolkata", "America/New_York"]) {
|
||||
createdAt: d1(16, 5),
|
||||
startsOn: new Date("2026-09-23T00:00:00Z"),
|
||||
endsOn: new Date("2026-09-28T00:00:00Z"),
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
asNeeded: false,
|
||||
trackIntake: true,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canStartCourse,
|
||||
courseFromWindow,
|
||||
courseStatusAt,
|
||||
courseStatusOn,
|
||||
dateOfDayKey,
|
||||
isInsideCourses,
|
||||
@@ -26,11 +27,25 @@ describe("projectCourseWindow", () => {
|
||||
expect(projectCourseWindow([JUNE, MARCH])).toEqual({
|
||||
startsOn: d("2026-06-10"),
|
||||
endsOn: d("2026-06-16"),
|
||||
endedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("is empty with no course (chronic since creation)", () => {
|
||||
expect(projectCourseWindow([])).toEqual({ startsOn: null, endsOn: null });
|
||||
expect(projectCourseWindow([])).toEqual({
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("carries the instant the latest course was ended", () => {
|
||||
const endedAt = new Date("2026-06-16T10:00:00.000Z");
|
||||
expect(projectCourseWindow([MARCH, { ...JUNE, endedAt }])).toEqual({
|
||||
startsOn: d("2026-06-10"),
|
||||
endsOn: d("2026-06-16"),
|
||||
endedAt,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -116,17 +131,76 @@ describe("courseStatusOn", () => {
|
||||
});
|
||||
|
||||
describe("isInsideCourses", () => {
|
||||
/** A slot at noon UTC of a day, the instant the day key belongs to. */
|
||||
const noon = (key: string) => new Date(`${key}T12:00:00.000Z`);
|
||||
const inside = (key: string, courses: CourseSpan[]) =>
|
||||
isInsideCourses(key, courses, noon(key));
|
||||
|
||||
it("admits the course days and not the gap", () => {
|
||||
const courses = [MARCH, JUNE];
|
||||
expect(isInsideCourses("2026-03-01", courses)).toBe(true);
|
||||
expect(isInsideCourses("2026-03-07", courses)).toBe(true);
|
||||
expect(isInsideCourses("2026-03-08", courses)).toBe(false);
|
||||
expect(isInsideCourses("2026-06-16", courses)).toBe(true);
|
||||
expect(isInsideCourses("2026-06-17", courses)).toBe(false);
|
||||
expect(inside("2026-03-01", courses)).toBe(true);
|
||||
expect(inside("2026-03-07", courses)).toBe(true);
|
||||
expect(inside("2026-03-08", courses)).toBe(false);
|
||||
expect(inside("2026-06-16", courses)).toBe(true);
|
||||
expect(inside("2026-06-17", courses)).toBe(false);
|
||||
});
|
||||
|
||||
it("places no restriction without courses", () => {
|
||||
expect(isInsideCourses("2026-03-08", [])).toBe(true);
|
||||
expect(inside("2026-03-08", [])).toBe(true);
|
||||
});
|
||||
|
||||
it("stops an ended course at the instant it was ended (#1280)", () => {
|
||||
// MARCH ended with "End course" at 10:00Z on its last day: the morning
|
||||
// slot belongs to it, the evening one does not.
|
||||
const ended = { ...MARCH, endedAt: new Date("2026-03-07T10:00:00.000Z") };
|
||||
const courses = [ended, JUNE];
|
||||
const day = "2026-03-07";
|
||||
expect(
|
||||
isInsideCourses(day, courses, new Date("2026-03-07T06:00:00.000Z")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isInsideCourses(day, courses, new Date("2026-03-07T10:00:00.000Z")),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isInsideCourses(day, courses, new Date("2026-03-07T18:00:00.000Z")),
|
||||
).toBe(false);
|
||||
// The days before the last one are untouched.
|
||||
expect(inside("2026-03-06", courses)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ending a course now (#1280)", () => {
|
||||
const ENDED_AT = new Date("2026-06-12T10:00:00.000Z");
|
||||
const ended = { ...JUNE, endsOn: d("2026-06-12"), endedAt: ENDED_AT };
|
||||
|
||||
it("reads ENDED on the last day already, by day and by instant", () => {
|
||||
expect(courseStatusOn(ended, "2026-06-12")).toBe("ENDED");
|
||||
expect(courseStatusOn(ended, "2026-06-11")).toBe("CURRENT");
|
||||
// The same end date set in the editor keeps the whole day.
|
||||
expect(courseStatusOn({ ...ended, endedAt: null }, "2026-06-12")).toBe(
|
||||
"CURRENT",
|
||||
);
|
||||
const at = (iso: string) => courseStatusAt(ended, new Date(iso), "UTC");
|
||||
expect(at("2026-06-12T09:59:59.999Z")).toBe("CURRENT");
|
||||
expect(at("2026-06-12T10:00:00.000Z")).toBe("ENDED");
|
||||
expect(at("2026-06-12T22:00:00.000Z")).toBe("ENDED");
|
||||
});
|
||||
|
||||
it("lets a new course start once the ended one is behind it", () => {
|
||||
const base = { active: true, oneShot: false, todayKey: "2026-06-12" };
|
||||
expect(canStartCourse({ ...base, courses: [MARCH, ended] })).toBe(true);
|
||||
// The next course may start tomorrow, not on the ended course's day.
|
||||
const next = course("2026-06-13", null);
|
||||
expect(validateCourses([MARCH, ended, next], "2026-06-12", false)).toBe(
|
||||
null,
|
||||
);
|
||||
expect(
|
||||
validateCourses(
|
||||
[MARCH, ended, course("2026-06-12", null)],
|
||||
"2026-06-12",
|
||||
false,
|
||||
),
|
||||
).toBe("overlap");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ const base = {
|
||||
trackIntake: true,
|
||||
startsOn: day("2026-09-01"),
|
||||
endsOn: day("2026-09-20"),
|
||||
endedAt: null,
|
||||
};
|
||||
|
||||
describe.each(ZONES)("course window in %s", (tz) => {
|
||||
@@ -42,13 +43,19 @@ describe.each(ZONES)("course window in %s", (tz) => {
|
||||
[23, 59],
|
||||
] as const) {
|
||||
const r = resolveIntakeActionability(base, at(tz, 20, h, m), tz);
|
||||
expect(r).toEqual({ courseStatus: "CURRENT", intakeActionable: true });
|
||||
expect(r).toEqual({
|
||||
courseStatus: "CURRENT",
|
||||
intakeActionable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("is ended from local midnight of the day after endsOn", () => {
|
||||
const r = resolveIntakeActionability(base, at(tz, 21, 0, 0), tz);
|
||||
expect(r).toEqual({ courseStatus: "ENDED", intakeActionable: false });
|
||||
expect(r).toEqual({
|
||||
courseStatus: "ENDED",
|
||||
intakeActionable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("is upcoming until local midnight of startsOn", () => {
|
||||
@@ -80,7 +87,10 @@ describe("resolveIntakeActionability", () => {
|
||||
|
||||
it("refuses a paused or archived medication inside its course", () => {
|
||||
const r = resolveIntakeActionability({ ...base, active: false }, now, tz);
|
||||
expect(r).toEqual({ courseStatus: "CURRENT", intakeActionable: false });
|
||||
expect(r).toEqual({
|
||||
courseStatus: "CURRENT",
|
||||
intakeActionable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a record-only medication inside its course", () => {
|
||||
@@ -89,7 +99,10 @@ describe("resolveIntakeActionability", () => {
|
||||
now,
|
||||
tz,
|
||||
);
|
||||
expect(r).toEqual({ courseStatus: "CURRENT", intakeActionable: false });
|
||||
expect(r).toEqual({
|
||||
courseStatus: "CURRENT",
|
||||
intakeActionable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports ENDED for a course that is also paused", () => {
|
||||
@@ -101,3 +114,32 @@ describe("resolveIntakeActionability", () => {
|
||||
expect(r.courseStatus).toBe("ENDED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a course ended with End course (#1280)", () => {
|
||||
const ended = {
|
||||
...base,
|
||||
endsOn: day("2026-09-20"),
|
||||
endedAt: new Date("2026-09-20T10:00:00.000Z"),
|
||||
};
|
||||
|
||||
it("is running until the instant and ended from it, not from midnight", () => {
|
||||
const before = resolveIntakeActionability(
|
||||
ended,
|
||||
new Date("2026-09-20T09:59:00.000Z"),
|
||||
"UTC",
|
||||
);
|
||||
expect(before).toEqual({
|
||||
courseStatus: "CURRENT",
|
||||
intakeActionable: true,
|
||||
});
|
||||
const after = resolveIntakeActionability(
|
||||
ended,
|
||||
new Date("2026-09-20T10:00:00.000Z"),
|
||||
"UTC",
|
||||
);
|
||||
expect(after).toEqual({
|
||||
courseStatus: "ENDED",
|
||||
intakeActionable: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -93,6 +93,7 @@ describe("buildCompliancePayload", () => {
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
asNeeded: false,
|
||||
trackIntake: false,
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
* worker expands no slot past it), it has doses that fall due (intake
|
||||
* tracked, not as-needed), and its doses are not recorded automatically
|
||||
* (`autoMarkTaken`). A course that has not started yet still reminds once
|
||||
* it does. The person's own reminder preference stays stored untouched:
|
||||
* it does. #1280 — a course ended with "End course" stops reminding at the
|
||||
* instant it was ended (`endedAt`), including the follow-up nags of a dose
|
||||
* that fell due before it. The person's own reminder preference stays stored untouched:
|
||||
* switching auto-mark-taken off restores whatever they had chosen.
|
||||
*/
|
||||
import { courseStatusAt } from "@/lib/medications/scheduling/recurrence";
|
||||
import { courseStatusAt } from "@/lib/medications/course-window";
|
||||
|
||||
export interface ReminderGateMedication {
|
||||
notificationsEnabled: boolean;
|
||||
@@ -24,6 +26,7 @@ export interface ReminderGateMedication {
|
||||
trackIntake: boolean;
|
||||
asNeeded: boolean;
|
||||
endsOn: Date | null;
|
||||
endedAt: Date | null;
|
||||
}
|
||||
|
||||
export function sendsMedicationReminders(
|
||||
@@ -37,7 +40,11 @@ export function sendsMedicationReminders(
|
||||
medication.trackIntake &&
|
||||
!medication.asNeeded &&
|
||||
courseStatusAt(
|
||||
{ startsOn: null, endsOn: medication.endsOn },
|
||||
{
|
||||
startsOn: null,
|
||||
endsOn: medication.endsOn,
|
||||
endedAt: medication.endedAt,
|
||||
},
|
||||
at.now,
|
||||
at.timeZone,
|
||||
) !== "ENDED"
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* effective `lastIntakeAt`.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { MEDICATION_WINDOW_SELECT } from "@/lib/medications/course-window";
|
||||
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
import {
|
||||
@@ -361,8 +362,7 @@ export async function loadAutoTakeRollingAnchors(
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
startsOn: true,
|
||||
endsOn: true,
|
||||
...MEDICATION_WINDOW_SELECT,
|
||||
oneShot: true,
|
||||
createdAt: true,
|
||||
autoMarkTakenSince: true,
|
||||
|
||||
@@ -92,6 +92,8 @@ export interface ComplianceMedicationInput {
|
||||
createdAt: Date;
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
/** #1280 — the instant "End course" ended the latest course. */
|
||||
endedAt: Date | null;
|
||||
oneShot: boolean;
|
||||
asNeeded: boolean;
|
||||
/** v1.39.1 (#1033) — intake tracking off: no adherence, heatmap empty. */
|
||||
|
||||
@@ -15,13 +15,27 @@
|
||||
* projection written at the last course write stays correct as days pass. A
|
||||
* write that would break the invariant is refused (`validateCourses`).
|
||||
*
|
||||
* #1280 — "End course" ends a course at an instant, `endedAt`, which lies on
|
||||
* its `endsOn` day. A slot at or after it is not part of the course, and the
|
||||
* course reads as ENDED from then on. An end date set in the editor carries
|
||||
* no `endedAt` and keeps the full-day reading. The instant is what keeps
|
||||
* compliance honest both ways: a dose taken earlier that day stays inside
|
||||
* the course and counts, and a later slot of the same day is neither due nor
|
||||
* missed.
|
||||
*
|
||||
* Pure: no database, no clock (callers pass `today`).
|
||||
*/
|
||||
import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
export interface CourseSpan {
|
||||
startsOn: Date;
|
||||
endsOn: Date | null;
|
||||
/**
|
||||
* #1280 — the instant "End course" ended this course, on its `endsOn` day.
|
||||
* Absent or null: the course runs to the end of `endsOn`.
|
||||
*/
|
||||
endedAt?: Date | null;
|
||||
}
|
||||
|
||||
export type CourseStatus = "UPCOMING" | "CURRENT" | "ENDED";
|
||||
@@ -32,6 +46,26 @@ export const dayKeyOfDate = dateOnlyKey;
|
||||
/** A `YYYY-MM-DD` key as the `@db.Date` value Prisma writes. */
|
||||
export const dateOfDayKey = dayKeyAsUtcMidnight;
|
||||
|
||||
/**
|
||||
* The medication's window as the engines read it: the latest course's
|
||||
* `startsOn` / `endsOn` and, since #1280, the instant "End course" ended it.
|
||||
* One select and one picker, so a reader cannot take the dates and drop the
|
||||
* instant.
|
||||
*/
|
||||
export const MEDICATION_WINDOW_SELECT = {
|
||||
startsOn: true,
|
||||
endsOn: true,
|
||||
endedAt: true,
|
||||
} as const;
|
||||
|
||||
export function medicationWindow(m: {
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
endedAt: Date | null;
|
||||
}): { startsOn: Date | null; endsOn: Date | null; endedAt: Date | null } {
|
||||
return { startsOn: m.startsOn, endsOn: m.endsOn, endedAt: m.endedAt };
|
||||
}
|
||||
|
||||
/** Courses ascending by start. */
|
||||
export function sortCourses<T extends CourseSpan>(courses: readonly T[]): T[] {
|
||||
return [...courses].sort(
|
||||
@@ -40,24 +74,71 @@ export function sortCourses<T extends CourseSpan>(courses: readonly T[]): T[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* The window `Medication.startsOn/endsOn` carries: the latest course, or
|
||||
* nothing at all when the medication has no course (chronic since creation).
|
||||
* The window `Medication.startsOn/endsOn/endedAt` carries: the latest course,
|
||||
* or nothing at all when the medication has no course (chronic since
|
||||
* creation).
|
||||
*/
|
||||
export function projectCourseWindow(courses: readonly CourseSpan[]): {
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
endedAt: Date | null;
|
||||
} {
|
||||
if (courses.length === 0) return { startsOn: null, endsOn: null };
|
||||
if (courses.length === 0) {
|
||||
return { startsOn: null, endsOn: null, endedAt: null };
|
||||
}
|
||||
const latest = sortCourses(courses)[courses.length - 1];
|
||||
return { startsOn: latest.startsOn, endsOn: latest.endsOn };
|
||||
return {
|
||||
startsOn: latest.startsOn,
|
||||
endsOn: latest.endsOn,
|
||||
endedAt: latest.endedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Where `todayKey` sits in one course. Both ends inclusive. */
|
||||
/**
|
||||
* Where `todayKey` sits in one course. Both ends inclusive, except that a
|
||||
* course ended with "End course" is ENDED on its `endsOn` day already: the
|
||||
* instant it ended lies on that day, and every read that asks about that day
|
||||
* comes after the instant (it is written as the moment of the request).
|
||||
*/
|
||||
export function courseStatusOn(
|
||||
course: CourseSpan,
|
||||
todayKey: string,
|
||||
): CourseStatus {
|
||||
if (todayKey < dayKeyOfDate(course.startsOn)) return "UPCOMING";
|
||||
if (course.endsOn) {
|
||||
const endKey = dayKeyOfDate(course.endsOn);
|
||||
if (todayKey > endKey) return "ENDED";
|
||||
if (course.endedAt && todayKey === endKey) return "ENDED";
|
||||
}
|
||||
return "CURRENT";
|
||||
}
|
||||
|
||||
/**
|
||||
* Where `now` sits in a medication's window (`Medication.startsOn/endsOn/
|
||||
* endedAt`, the projection of its latest course), in calendar days on the
|
||||
* person's clock. `startsOn` / `endsOn` are calendar dates and are read as
|
||||
* such; only `now` is converted to its local day. Both ends are inclusive:
|
||||
* a course that ends today is CURRENT for the whole of today, one that
|
||||
* starts today is CURRENT from local midnight. A medication without dates is
|
||||
* CURRENT. #1280 — a course ended with "End course" is ENDED from the
|
||||
* instant it was ended (`endedAt`), not from the next local midnight.
|
||||
*/
|
||||
export function courseStatusAt(
|
||||
course: {
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
endedAt?: Date | null;
|
||||
},
|
||||
now: Date,
|
||||
timeZone: string,
|
||||
): CourseStatus {
|
||||
if (course.endedAt && now.getTime() >= course.endedAt.getTime()) {
|
||||
return "ENDED";
|
||||
}
|
||||
const todayKey = userDayKey(now, timeZone);
|
||||
if (course.startsOn && todayKey < dayKeyOfDate(course.startsOn)) {
|
||||
return "UPCOMING";
|
||||
}
|
||||
if (course.endsOn && todayKey > dayKeyOfDate(course.endsOn)) return "ENDED";
|
||||
return "CURRENT";
|
||||
}
|
||||
@@ -104,18 +185,22 @@ export function validateCourses(
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an instant's local day (`dayKey`) falls inside any course. No
|
||||
* courses means no restriction (chronic since creation).
|
||||
* Whether a slot at `at`, whose local day is `dayKey`, falls inside any
|
||||
* course. No courses means no restriction (chronic since creation). A course
|
||||
* ended with "End course" stops at its `endedAt`: a slot at or after it is
|
||||
* outside, even on the `endsOn` day.
|
||||
*/
|
||||
export function isInsideCourses(
|
||||
dayKey: string,
|
||||
courses: readonly CourseSpan[],
|
||||
at: Date,
|
||||
): boolean {
|
||||
if (courses.length === 0) return true;
|
||||
return courses.some(
|
||||
(c) =>
|
||||
dayKey >= dayKeyOfDate(c.startsOn) &&
|
||||
(c.endsOn === null || dayKey <= dayKeyOfDate(c.endsOn)),
|
||||
(c.endsOn === null || dayKey <= dayKeyOfDate(c.endsOn)) &&
|
||||
(!c.endedAt || at.getTime() < c.endedAt.getTime()),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
* columns for a medication that has courses, so reminders, the next-due line
|
||||
* and `intakeActionable` — which read the columns — can never disagree with
|
||||
* the course list.
|
||||
*
|
||||
* #1280 — a course may carry `endedAt`, the instant it was ended at. Every
|
||||
* write that moves a course's `endsOn` clears it: an end date chosen in the
|
||||
* editor means the whole day.
|
||||
*/
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
@@ -99,9 +103,19 @@ export interface CourseRow {
|
||||
id: string;
|
||||
startsOn: Date;
|
||||
endsOn: Date | null;
|
||||
endedAt: Date | null;
|
||||
noteEncrypted: Uint8Array | null;
|
||||
}
|
||||
|
||||
/** The course columns every course read and write selects. */
|
||||
const COURSE_ROW_SELECT = {
|
||||
id: true,
|
||||
startsOn: true,
|
||||
endsOn: true,
|
||||
endedAt: true,
|
||||
noteEncrypted: true,
|
||||
} as const;
|
||||
|
||||
async function lockMedication(tx: Tx, medicationId: string) {
|
||||
await tx.$queryRaw`
|
||||
SELECT pg_advisory_xact_lock(
|
||||
@@ -119,6 +133,7 @@ async function loadOwned(tx: Tx, userId: string, medicationId: string) {
|
||||
oneShot: true,
|
||||
startsOn: true,
|
||||
endsOn: true,
|
||||
endedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
@@ -126,12 +141,16 @@ async function loadOwned(tx: Tx, userId: string, medicationId: string) {
|
||||
const courses = await tx.medicationCourse.findMany({
|
||||
where: { medicationId },
|
||||
orderBy: [{ startsOn: "asc" }, { id: "asc" }],
|
||||
select: { id: true, startsOn: true, endsOn: true, noteEncrypted: true },
|
||||
select: COURSE_ROW_SELECT,
|
||||
});
|
||||
return { medication, courses };
|
||||
}
|
||||
|
||||
type ProjectedWindow = { startsOn: Date | null; endsOn: Date | null };
|
||||
type ProjectedWindow = {
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
endedAt: Date | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Write the medication's window from its courses. The only writer.
|
||||
@@ -148,13 +167,17 @@ async function project(tx: Tx, medicationId: string): Promise<ProjectedWindow> {
|
||||
}),
|
||||
tx.medicationCourse.findMany({
|
||||
where: { medicationId },
|
||||
select: { startsOn: true, endsOn: true },
|
||||
select: { startsOn: true, endsOn: true, endedAt: true },
|
||||
}),
|
||||
]);
|
||||
const window = projectCourseWindow(courses);
|
||||
await tx.medication.update({
|
||||
where: { id: medicationId },
|
||||
data: { startsOn: window.startsOn, endsOn: window.endsOn },
|
||||
data: {
|
||||
startsOn: window.startsOn,
|
||||
endsOn: window.endsOn,
|
||||
endedAt: window.endedAt,
|
||||
},
|
||||
});
|
||||
if (before && windowWidens(before, window)) {
|
||||
await moveAutoTakeFloorToNow(tx, medicationId);
|
||||
@@ -244,7 +267,7 @@ export async function createCourse(
|
||||
endsOn: args.endsOn,
|
||||
noteEncrypted: encryptNote(args.note) ?? null,
|
||||
},
|
||||
select: { id: true, startsOn: true, endsOn: true, noteEncrypted: true },
|
||||
select: COURSE_ROW_SELECT,
|
||||
});
|
||||
await project(tx, args.medicationId);
|
||||
return created;
|
||||
@@ -269,9 +292,11 @@ export async function updateCourse(
|
||||
);
|
||||
const current = courses.find((c) => c.id === args.courseId);
|
||||
if (!current) throw new CourseWriteError("notFound");
|
||||
const endsOn = args.endsOn === undefined ? current.endsOn : args.endsOn;
|
||||
const next = {
|
||||
startsOn: args.startsOn ?? current.startsOn,
|
||||
endsOn: args.endsOn === undefined ? current.endsOn : args.endsOn,
|
||||
endsOn,
|
||||
endedAt: keptEndedAt(current, endsOn),
|
||||
};
|
||||
refuseIfInvalid(
|
||||
courses.map((c) => (c.id === current.id ? next : c)),
|
||||
@@ -284,9 +309,10 @@ export async function updateCourse(
|
||||
data: {
|
||||
startsOn: next.startsOn,
|
||||
endsOn: next.endsOn,
|
||||
endedAt: next.endedAt,
|
||||
...(noteEncrypted !== undefined && { noteEncrypted }),
|
||||
},
|
||||
select: { id: true, startsOn: true, endsOn: true, noteEncrypted: true },
|
||||
select: COURSE_ROW_SELECT,
|
||||
});
|
||||
await project(tx, args.medicationId);
|
||||
return updated;
|
||||
@@ -385,14 +411,15 @@ async function writeCurrentWindow(
|
||||
);
|
||||
|
||||
if (latest) {
|
||||
const edited = { ...next, endedAt: keptEndedAt(latest, next.endsOn) };
|
||||
refuseIfInvalid(
|
||||
courses.map((c) => (c.id === latest.id ? next : c)),
|
||||
courses.map((c) => (c.id === latest.id ? edited : c)),
|
||||
todayKey,
|
||||
medication.oneShot,
|
||||
);
|
||||
await tx.medicationCourse.update({
|
||||
where: { id: latest.id },
|
||||
data: next,
|
||||
data: edited,
|
||||
});
|
||||
} else {
|
||||
refuseIfInvalid([next], todayKey, medication.oneShot);
|
||||
@@ -407,6 +434,22 @@ async function writeCurrentWindow(
|
||||
return project(tx, args.medicationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* #1280 — the `endedAt` a course keeps through an edit: its own while its
|
||||
* `endsOn` stays the day it was ended on, none once the end moves. An end
|
||||
* date written in the editor is a whole day; only "End course" ends a course
|
||||
* at an instant.
|
||||
*/
|
||||
function keptEndedAt(
|
||||
course: { endsOn: Date | null; endedAt: Date | null },
|
||||
nextEndsOn: Date | null,
|
||||
): Date | null {
|
||||
if (!course.endedAt || !course.endsOn || !nextEndsOn) return null;
|
||||
return course.endsOn.getTime() === nextEndsOn.getTime()
|
||||
? course.endedAt
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the window a PUT is about to write would be refused. Checked
|
||||
* BEFORE the medication row is updated, so a refused window leaves the row
|
||||
@@ -424,7 +467,7 @@ export async function checkCurrentWindow(
|
||||
const courses = await prisma.medicationCourse.findMany({
|
||||
where: { medicationId: args.medicationId, userId: args.userId },
|
||||
orderBy: [{ startsOn: "asc" }, { id: "asc" }],
|
||||
select: { id: true, startsOn: true, endsOn: true },
|
||||
select: { id: true, startsOn: true, endsOn: true, endedAt: true },
|
||||
});
|
||||
const latest = courses.at(-1);
|
||||
if (args.startsOn === null && args.endsOn === null) {
|
||||
@@ -438,7 +481,13 @@ export async function checkCurrentWindow(
|
||||
args.timeZone,
|
||||
);
|
||||
return validateCourses(
|
||||
latest ? courses.map((c) => (c.id === latest.id ? next : c)) : [next],
|
||||
latest
|
||||
? courses.map((c) =>
|
||||
c.id === latest.id
|
||||
? { ...next, endedAt: keptEndedAt(latest, next.endsOn) }
|
||||
: c,
|
||||
)
|
||||
: [next],
|
||||
todayKey,
|
||||
args.oneShot,
|
||||
);
|
||||
@@ -450,6 +499,11 @@ export interface MedicationCourseDto {
|
||||
id: string;
|
||||
startsOn: string;
|
||||
endsOn: string | null;
|
||||
/**
|
||||
* #1280 — the instant "End course" ended this course (ISO), or null when
|
||||
* the whole `endsOn` day counts.
|
||||
*/
|
||||
endedAt: string | null;
|
||||
status: CourseStatus;
|
||||
/** Doses logged as taken whose slot falls on a day of this course. */
|
||||
takenDoses: number;
|
||||
@@ -473,13 +527,7 @@ export async function loadCoursesFor(
|
||||
const rows = await prisma.medicationCourse.findMany({
|
||||
where: { medicationId: { in: medicationIds } },
|
||||
orderBy: [{ startsOn: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
medicationId: true,
|
||||
startsOn: true,
|
||||
endsOn: true,
|
||||
noteEncrypted: true,
|
||||
},
|
||||
select: { ...COURSE_ROW_SELECT, medicationId: true },
|
||||
});
|
||||
for (const { medicationId, ...row } of rows) {
|
||||
const list = byMedication.get(medicationId) ?? [];
|
||||
@@ -530,6 +578,7 @@ export function buildCourseFields(
|
||||
id: c.id,
|
||||
startsOn: dayKeyOfDate(c.startsOn),
|
||||
endsOn: c.endsOn ? dayKeyOfDate(c.endsOn) : null,
|
||||
endedAt: c.endedAt ? c.endedAt.toISOString() : null,
|
||||
status: courseStatusOn(c, todayKey),
|
||||
takenDoses: takenByCourse.get(c.id) ?? 0,
|
||||
note: c.noteEncrypted ? decryptFromBytes(c.noteEncrypted) : null,
|
||||
|
||||
@@ -22,11 +22,14 @@
|
||||
*
|
||||
* Logging a past dose from the history is a different verb and is not
|
||||
* gated here; an ended course can still be completed after the fact.
|
||||
*
|
||||
* #1280 — a course ended at an instant (`endedAt`) is ENDED from it, so the
|
||||
* action row goes away at once instead of at midnight.
|
||||
*/
|
||||
import {
|
||||
courseStatusAt,
|
||||
type CourseStatus,
|
||||
} from "@/lib/medications/scheduling/recurrence";
|
||||
} from "@/lib/medications/course-window";
|
||||
import { isRecordOnly } from "@/lib/medications/intake-tracking";
|
||||
|
||||
export interface IntakeActionability {
|
||||
@@ -40,6 +43,7 @@ export function resolveIntakeActionability(
|
||||
trackIntake: boolean;
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
endedAt: Date | null;
|
||||
},
|
||||
now: Date,
|
||||
timeZone: string,
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
* unregistered queue silently never drains.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
MEDICATION_WINDOW_SELECT,
|
||||
medicationWindow,
|
||||
} from "@/lib/medications/course-window";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { restoreForIntake } from "@/lib/medications/inventory/consumption";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
@@ -201,8 +205,7 @@ export async function dedupeUserIntakeSlots(
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
startsOn: true,
|
||||
endsOn: true,
|
||||
...MEDICATION_WINDOW_SELECT,
|
||||
oneShot: true,
|
||||
createdAt: true,
|
||||
schedules: {
|
||||
@@ -348,8 +351,7 @@ export async function dedupeUserIntakeSlots(
|
||||
const canonical = resolveCanonicalSlotInstant({
|
||||
medication: {
|
||||
id: med.id,
|
||||
startsOn: med.startsOn,
|
||||
endsOn: med.endsOn,
|
||||
...medicationWindow(med),
|
||||
oneShot: med.oneShot,
|
||||
createdAt: med.createdAt,
|
||||
schedules: med.schedules,
|
||||
@@ -551,8 +553,7 @@ async function convergeAdhocRowsOntoPendingSlots(input: {
|
||||
|
||||
const medication: AttributeIntakeMedication = {
|
||||
id: med.id,
|
||||
startsOn: med.startsOn,
|
||||
endsOn: med.endsOn,
|
||||
...medicationWindow(med),
|
||||
oneShot: med.oneShot,
|
||||
createdAt: med.createdAt,
|
||||
schedules: med.schedules,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* readers. Mirrors `src/lib/dashboard/snapshot-read.ts` for the dashboard.
|
||||
*/
|
||||
import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken";
|
||||
import { medicationWindow } from "@/lib/medications/course-window";
|
||||
import { loadAutoTakeRollingAnchors } from "@/lib/medications/auto-take";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
@@ -244,8 +245,7 @@ export async function buildMedicationsList(
|
||||
const display = computeDisplayDue({
|
||||
medication: {
|
||||
id: m.id,
|
||||
startsOn: m.startsOn,
|
||||
endsOn: m.endsOn,
|
||||
...medicationWindow(m),
|
||||
oneShot: m.oneShot,
|
||||
createdAt: m.createdAt,
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* medication is not the record's.
|
||||
*/
|
||||
import type { Medication, MedicationSchedule } from "@/generated/prisma/client";
|
||||
import { medicationWindow } from "@/lib/medications/course-window";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken";
|
||||
import { toIngredientDtos } from "@/lib/medications/ingredients/composition";
|
||||
@@ -113,8 +114,7 @@ async function loadDisplayDue(
|
||||
const display = computeDisplayDue({
|
||||
medication: {
|
||||
id: medication.id,
|
||||
startsOn: medication.startsOn,
|
||||
endsOn: medication.endsOn,
|
||||
...medicationWindow(medication),
|
||||
oneShot: medication.oneShot,
|
||||
createdAt: medication.createdAt,
|
||||
},
|
||||
|
||||
@@ -51,6 +51,7 @@ function med(
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
schedules,
|
||||
|
||||
@@ -35,6 +35,7 @@ function makeMedication(
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt: d("2026-01-01T00:00:00Z"),
|
||||
...overrides,
|
||||
|
||||
@@ -1295,3 +1295,71 @@ describe("occurrencesBetween — CYCLIC", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// #1280 — a course ended with "End course" stops at that instant
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("a course ended at an instant (#1280)", () => {
|
||||
// 08:00 and 20:00 in Berlin (UTC+2 in June), ended at 12:00 local on the
|
||||
// 10th, which is also its last day.
|
||||
const schedule = makeSchedule({
|
||||
rrule: "FREQ=DAILY",
|
||||
timesOfDay: ["08:00", "20:00"],
|
||||
});
|
||||
const ended = makeCtx({
|
||||
medication: {
|
||||
endsOn: d("2026-06-10T00:00:00Z"),
|
||||
endedAt: d("2026-06-10T10:00:00Z"),
|
||||
},
|
||||
});
|
||||
const wholeDay = makeCtx({
|
||||
medication: { endsOn: d("2026-06-10T00:00:00Z") },
|
||||
});
|
||||
|
||||
it("emits the slot before the instant and none after it", () => {
|
||||
const from = d("2026-06-09T22:00:00Z");
|
||||
const to = d("2026-06-10T21:59:59Z");
|
||||
expect(
|
||||
occurrencesBetween(schedule, from, to, ended).map((o) =>
|
||||
o.at.toISOString(),
|
||||
),
|
||||
).toEqual(["2026-06-10T06:00:00.000Z"]);
|
||||
// The same end date without the instant keeps the evening dose.
|
||||
expect(occurrencesBetween(schedule, from, to, wholeDay)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("has no next dose once the course was ended", () => {
|
||||
expect(
|
||||
nextOccurrenceAfter(schedule, d("2026-06-10T10:30:00Z"), ended),
|
||||
).toBeNull();
|
||||
expect(
|
||||
nextOccurrenceAfter(schedule, d("2026-06-10T10:30:00Z"), wholeDay)?.at,
|
||||
).toEqual(d("2026-06-10T18:00:00Z"));
|
||||
});
|
||||
|
||||
it("stops a rolling cadence whose next dose falls after the instant", () => {
|
||||
const rolling = makeSchedule({
|
||||
rollingIntervalDays: 1,
|
||||
timesOfDay: ["20:00"],
|
||||
});
|
||||
const ctx = makeCtx({
|
||||
lastIntakeAt: d("2026-06-09T18:00:00Z"),
|
||||
medication: {
|
||||
endsOn: d("2026-06-10T00:00:00Z"),
|
||||
endedAt: d("2026-06-10T10:00:00Z"),
|
||||
},
|
||||
});
|
||||
expect(
|
||||
occurrencesBetween(
|
||||
rolling,
|
||||
d("2026-06-09T22:00:00Z"),
|
||||
d("2026-06-10T21:59:59Z"),
|
||||
ctx,
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
nextOccurrenceAfter(rolling, d("2026-06-10T10:30:00Z"), ctx),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,7 @@ function makeMedication(
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
schedules,
|
||||
|
||||
@@ -34,6 +34,7 @@ const medication: WorkerMedicationRow = {
|
||||
id: "med-1",
|
||||
startsOn: new Date("2026-06-02T00:00:00.000Z"),
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
};
|
||||
|
||||
@@ -77,6 +77,7 @@ const medication = {
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt,
|
||||
};
|
||||
@@ -384,6 +385,7 @@ describe("write-attribution era pick", () => {
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
endedAt: null,
|
||||
oneShot: false,
|
||||
createdAt,
|
||||
schedules: [
|
||||
|
||||
@@ -128,6 +128,8 @@ export interface ScheduleLike {
|
||||
export interface CadenceEngineContext {
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
/** #1280 — when "End course" ended the latest course (see the engine). */
|
||||
endedAt?: Date | null;
|
||||
oneShot: boolean;
|
||||
createdAt: Date;
|
||||
/** Latest non-skipped intake — only read by rolling cadences. */
|
||||
@@ -150,18 +152,17 @@ export interface CadenceEngineContext {
|
||||
}
|
||||
|
||||
/** v1.40 (#1024) — the span a timeline expands over (see `courses`). */
|
||||
function mintWindow(engineCtx: CadenceEngineContext): {
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
} {
|
||||
function mintWindow(engineCtx: CadenceEngineContext) {
|
||||
const courses = engineCtx.courses;
|
||||
const endedAt = engineCtx.endedAt ?? null;
|
||||
if (!courses || courses.length < 2) {
|
||||
return { startsOn: engineCtx.startsOn, endsOn: engineCtx.endsOn };
|
||||
return { startsOn: engineCtx.startsOn, endsOn: engineCtx.endsOn, endedAt };
|
||||
}
|
||||
const sorted = sortCourses(courses);
|
||||
return {
|
||||
startsOn: sorted[0].startsOn,
|
||||
endsOn: sorted[sorted.length - 1].endsOn,
|
||||
endedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -169,7 +170,7 @@ function mintWindow(engineCtx: CadenceEngineContext): {
|
||||
function onCourseDay(engineCtx: CadenceEngineContext, at: Date): boolean {
|
||||
const courses = engineCtx.courses;
|
||||
if (!courses || courses.length < 2) return true;
|
||||
return isInsideCourses(userDayKey(at, engineCtx.timeZone), courses);
|
||||
return isInsideCourses(userDayKey(at, engineCtx.timeZone), courses, at);
|
||||
}
|
||||
|
||||
/** Build a `CanonicalSchedule` from a `ScheduleLike` + its index. */
|
||||
@@ -201,8 +202,7 @@ function toRecurrenceContext(
|
||||
return {
|
||||
medication: {
|
||||
id: "cadence-med",
|
||||
startsOn: window.startsOn,
|
||||
endsOn: window.endsOn,
|
||||
...window,
|
||||
oneShot: engineCtx.oneShot,
|
||||
createdAt: engineCtx.createdAt,
|
||||
},
|
||||
@@ -908,16 +908,14 @@ function missedFromLedger(
|
||||
const window = mintWindow(engineCtx);
|
||||
const medication: BandMinterMedication = {
|
||||
id: "missed-tally",
|
||||
startsOn: window.startsOn,
|
||||
endsOn: window.endsOn,
|
||||
...window,
|
||||
oneShot: engineCtx.oneShot,
|
||||
createdAt: engineCtx.createdAt,
|
||||
};
|
||||
const recurrenceCtx: RecurrenceContext = {
|
||||
medication: {
|
||||
id: "missed-tally",
|
||||
startsOn: window.startsOn,
|
||||
endsOn: window.endsOn,
|
||||
...window,
|
||||
oneShot: engineCtx.oneShot,
|
||||
createdAt: engineCtx.createdAt,
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
* Returns the counts so the caller can surface them on `annotate(...)`.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { MEDICATION_WINDOW_SELECT } from "@/lib/medications/course-window";
|
||||
import {
|
||||
buildCanonicalSchedule,
|
||||
buildRecurrenceContext,
|
||||
@@ -58,8 +59,7 @@ export async function projectTodayIntakesAndRecompute(input: {
|
||||
where: { userId, active: true, ...TRACKED_INTAKE_WHERE },
|
||||
select: {
|
||||
id: true,
|
||||
startsOn: true,
|
||||
endsOn: true,
|
||||
...MEDICATION_WINDOW_SELECT,
|
||||
oneShot: true,
|
||||
createdAt: true,
|
||||
// The live era start: no slot anchored before it is projected. Turning
|
||||
@@ -160,6 +160,9 @@ export async function projectTodayIntakesAndRecompute(input: {
|
||||
if (!Number.isFinite(h) || !Number.isFinite(m)) continue;
|
||||
const scheduledFor = localHmAsUtc(now, userTz, h, m);
|
||||
if (isBeforeLiveEra(scheduledFor, eraStart)) continue;
|
||||
// #1280 — a course ended with "End course" expects nothing from
|
||||
// that instant on; the schedule still emits its earlier slots today.
|
||||
if (med.endedAt && scheduledFor >= med.endedAt) continue;
|
||||
projected.push({
|
||||
medicationId: schedule.medicationId,
|
||||
scheduledFor,
|
||||
|
||||
@@ -143,6 +143,8 @@ export interface RecurrenceContext {
|
||||
id: string;
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
/** #1280 — when "End course" ended it; see `beforeCourseEnded`. */
|
||||
endedAt?: Date | null;
|
||||
oneShot: boolean;
|
||||
createdAt: Date;
|
||||
};
|
||||
@@ -211,31 +213,6 @@ const ONE_MINUTE_MS = 60_000;
|
||||
// Public API
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Where `now` sits relative to a medication's course, in calendar days on
|
||||
* the user's clock. `startsOn` / `endsOn` are calendar dates and are read as
|
||||
* such (never shifted through `timeZone`); only `now` is converted to the
|
||||
* local day it falls on. Both ends are inclusive: a course that ends today
|
||||
* is CURRENT for the whole of today, one that starts today is CURRENT from
|
||||
* local midnight.
|
||||
*/
|
||||
export type CourseStatus = "UPCOMING" | "CURRENT" | "ENDED";
|
||||
|
||||
export function courseStatusAt(
|
||||
course: { startsOn: Date | null; endsOn: Date | null },
|
||||
now: Date,
|
||||
timeZone: string,
|
||||
): CourseStatus {
|
||||
const today = civilDayOfInstant(now, timeZone).getTime();
|
||||
if (course.startsOn && today < civilDayOfDate(course.startsOn).getTime()) {
|
||||
return "UPCOMING";
|
||||
}
|
||||
if (course.endsOn && today > civilDayOfDate(course.endsOn).getTime()) {
|
||||
return "ENDED";
|
||||
}
|
||||
return "CURRENT";
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit every occurrence in `[from, to]` (inclusive of both ends) in
|
||||
* chronological order. Pure: no DB access, no side effects.
|
||||
@@ -273,9 +250,22 @@ export function occurrencesBetween(
|
||||
// independent: the legacy stride filters weeks first, the cyclic gate
|
||||
// filters the surviving slots second.
|
||||
if (schedule.scheduleType === "CYCLIC") {
|
||||
return slots.filter((s) => isInCyclicOnWeek(s.at, schedule, ctx));
|
||||
slots = slots.filter((s) => isInCyclicOnWeek(s.at, schedule, ctx));
|
||||
}
|
||||
return slots;
|
||||
// #1280 — a course ended with "End course" stops at that instant.
|
||||
return slots.filter((s) => beforeCourseEnded(s.at, ctx));
|
||||
}
|
||||
|
||||
/**
|
||||
* #1280 — the cut every expansion path applies on top of the `endsOn` day
|
||||
* cap: no slot at or after the instant "End course" ended the course. A dose
|
||||
* later that day is not due, reminded or expected; an earlier one is. Every
|
||||
* medication context builder threads `endedAt` (the course-anchor guard
|
||||
* holds the list); a measurement or booster reminder has no course to end.
|
||||
*/
|
||||
function beforeCourseEnded(at: Date, ctx: RecurrenceContext): boolean {
|
||||
const endedAt = ctx.medication.endedAt;
|
||||
return !endedAt || at.getTime() < endedAt.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -354,7 +344,7 @@ export function nextOccurrenceAfter(
|
||||
for (const s of slots) {
|
||||
if (s.at.getTime() <= after.getTime()) continue;
|
||||
if (cyclic && !isInCyclicOnWeek(s.at, schedule, ctx)) continue;
|
||||
return s;
|
||||
return beforeCourseEnded(s.at, ctx) ? s : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -405,7 +395,7 @@ export function nextOccurrenceAfter(
|
||||
for (const s of slots) {
|
||||
if (s.at.getTime() < floor.getTime()) continue;
|
||||
if (cyclic && !isInCyclicOnWeek(s.at, schedule, ctx)) continue;
|
||||
return s;
|
||||
return beforeCourseEnded(s.at, ctx) ? s : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -608,6 +598,7 @@ export function advanceRollingOccurrence(
|
||||
return null;
|
||||
}
|
||||
const at = applyTimeOfDayToCivilDay(nextDay, time, ctx.timeZone);
|
||||
if (!beforeCourseEnded(at, ctx)) return null;
|
||||
return buildOccurrence(at, time, schedule);
|
||||
}
|
||||
|
||||
@@ -698,7 +689,8 @@ export function expandRollingRetrospective(
|
||||
const inWindow = (at: Date): boolean =>
|
||||
at.getTime() >= from.getTime() &&
|
||||
at.getTime() <= to.getTime() &&
|
||||
at.getTime() <= endsCap;
|
||||
at.getTime() <= endsCap &&
|
||||
beforeCourseEnded(at, ctx);
|
||||
|
||||
const slots: Occurrence[] = [];
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
MedicationIntakeEvent,
|
||||
PrismaClient,
|
||||
} from "@/generated/prisma/client";
|
||||
import { medicationWindow } from "@/lib/medications/course-window";
|
||||
|
||||
import { prisma as defaultPrisma } from "@/lib/db";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
@@ -472,6 +473,7 @@ const MEDICATION_SELECT = {
|
||||
id: true,
|
||||
startsOn: true,
|
||||
endsOn: true,
|
||||
endedAt: true,
|
||||
oneShot: true,
|
||||
createdAt: true,
|
||||
schedules: { select: SCHEDULE_SELECT },
|
||||
@@ -557,8 +559,7 @@ export async function resolveSlotInstantForWrite(
|
||||
return resolveCanonicalSlotInstant({
|
||||
medication: {
|
||||
id: medication.id,
|
||||
startsOn: medication.startsOn,
|
||||
endsOn: medication.endsOn,
|
||||
...medicationWindow(medication),
|
||||
oneShot: medication.oneShot,
|
||||
createdAt: medication.createdAt,
|
||||
schedules: medication.schedules as WorkerScheduleRow[],
|
||||
@@ -612,8 +613,7 @@ async function loadAttributeMedication(input: {
|
||||
return {
|
||||
medication: {
|
||||
id: medication.id,
|
||||
startsOn: medication.startsOn,
|
||||
endsOn: medication.endsOn,
|
||||
...medicationWindow(medication),
|
||||
oneShot: medication.oneShot,
|
||||
createdAt: medication.createdAt,
|
||||
schedules: medication.schedules as WorkerScheduleRow[],
|
||||
|
||||
@@ -69,6 +69,8 @@ export interface WorkerMedicationRow {
|
||||
id: string;
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
/** #1280 — the instant "End course" ended the course (see the engine). */
|
||||
endedAt: Date | null;
|
||||
oneShot: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -149,6 +151,7 @@ export function buildRecurrenceContext(input: {
|
||||
id: input.medication.id,
|
||||
startsOn: input.medication.startsOn,
|
||||
endsOn: input.medication.endsOn,
|
||||
endedAt: input.medication.endedAt,
|
||||
oneShot: input.medication.oneShot,
|
||||
createdAt: input.medication.createdAt,
|
||||
},
|
||||
|
||||
@@ -66,9 +66,16 @@ export const medicationCourseResource = z
|
||||
.date()
|
||||
.nullable()
|
||||
.describe("Last day, inclusive. NULL = open (only the latest course)."),
|
||||
endedAt: isoDateTime()
|
||||
.nullable()
|
||||
.describe(
|
||||
'#1280 — the instant "End course" ended this course, on its `endsOn` day. No dose slot at or after it is due, reminded or expected, and the course is ENDED from it on. NULL when the whole `endsOn` day counts (an end date set in the editor). Any edit that moves `endsOn` clears it.',
|
||||
),
|
||||
status: z
|
||||
.enum(["UPCOMING", "CURRENT", "ENDED"])
|
||||
.describe("Where today, on the person's clock, sits in this course."),
|
||||
.describe(
|
||||
"Where today, on the person's clock, sits in this course. A course with `endedAt` is ENDED on its `endsOn` day already.",
|
||||
),
|
||||
takenDoses: z
|
||||
.number()
|
||||
.int()
|
||||
@@ -364,6 +371,12 @@ export const medicationResource = z
|
||||
.describe(
|
||||
"v1.5 course end (ISO date). NULL means chronic. Equals `startsOn` when `oneShot` is true.",
|
||||
),
|
||||
endedAt: isoDateTime()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'#1280 — the instant the latest course was ended with "End course" (the course\'s own `endedAt`). From it on no dose is due or reminded and `courseStatus` is ENDED; NULL when the course runs to the end of `endsOn`. Read-only — written by the course writer only.',
|
||||
),
|
||||
oneShot: z
|
||||
.boolean()
|
||||
.describe(
|
||||
@@ -435,12 +448,12 @@ export const medicationListEntry = medicationResource
|
||||
remindersActive: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"v1.42.2 — whether the server sends dose reminders for this medication: true only when `notificationsEnabled` is on, the medication is `active`, its course has not ended (`endsOn` is today or later on the person's clock, or unset), its intake is tracked (`trackIntake`), it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side reminder send. A client that arms local reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not stored.",
|
||||
"v1.42.2 — whether the server sends dose reminders for this medication: true only when `notificationsEnabled` is on, the medication is `active`, its course has not ended (`endsOn` is today or later on the person's clock, or unset, and no `endedAt` has passed), its intake is tracked (`trackIntake`), it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side reminder send. A client that arms local reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not stored.",
|
||||
),
|
||||
courseStatus: z
|
||||
.enum(["UPCOMING", "CURRENT", "ENDED"])
|
||||
.describe(
|
||||
"v1.39.4 — where today (on the user's clock) sits in the course: UPCOMING before `startsOn`, ENDED after `endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all day. Read-only — computed, not stored.",
|
||||
'v1.39.4 — where today (on the user\'s clock) sits in the course: UPCOMING before `startsOn`, ENDED after `endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all day, unless it was ended with "End course": then it is ENDED from that instant (`endedAt`). Read-only — computed, not stored.',
|
||||
),
|
||||
intakeActionable: z
|
||||
.boolean()
|
||||
@@ -506,7 +519,7 @@ export const medicationDetailEntry = medicationResource
|
||||
courseStatus: z
|
||||
.enum(["UPCOMING", "CURRENT", "ENDED"])
|
||||
.describe(
|
||||
"v1.39.4 — where today (on the user's clock) sits in the course: UPCOMING before `startsOn`, ENDED after `endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all day. Read-only — computed, not stored.",
|
||||
'v1.39.4 — where today (on the user\'s clock) sits in the course: UPCOMING before `startsOn`, ENDED after `endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all day, unless it was ended with "End course": then it is ENDED from that instant (`endedAt`). Read-only — computed, not stored.',
|
||||
),
|
||||
intakeActionable: z
|
||||
.boolean()
|
||||
|
||||
@@ -72,12 +72,14 @@ export interface TargetMedication {
|
||||
createdAt: Date;
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
/** #1280 — the instant "End course" ended the latest course. */
|
||||
endedAt: Date | null;
|
||||
oneShot: boolean;
|
||||
schedules: ComplianceSchedule[];
|
||||
scheduleRevisions?: ScheduleRevisionLike[];
|
||||
pauseEras?: MedicationPauseEraLike[];
|
||||
/** v1.40 (#1024) — the courses, so a gap between two expects nothing. */
|
||||
courses?: { startsOn: Date; endsOn: Date | null }[];
|
||||
courses?: { startsOn: Date; endsOn: Date | null; endedAt: Date | null }[];
|
||||
}
|
||||
|
||||
export interface TargetIntakeEvent extends IntakeEvent {
|
||||
|
||||
@@ -335,7 +335,7 @@
|
||||
"reorderLeadDays": 3,
|
||||
"externalSource": "WEB",
|
||||
"externalId": "rt-Medication-externalId",
|
||||
"updatedAt": "2026-05-01T11:51:00.000Z",
|
||||
"updatedAt": "2026-05-01T11:52:00.000Z",
|
||||
"categoryUpdatedAt": "2026-05-01T21:22:00.000Z",
|
||||
"name": "Round-trip tablet",
|
||||
"dose": "5 mg",
|
||||
@@ -344,7 +344,7 @@
|
||||
"autoMarkTaken": true,
|
||||
"autoMarkTakenSince": "2026-06-30T12:34:56.789Z",
|
||||
"category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10",
|
||||
"createdAt": "2026-05-01T11:50:00.000Z",
|
||||
"createdAt": "2026-05-01T11:51:00.000Z",
|
||||
"schedules": [
|
||||
{
|
||||
"id": "<id:7>",
|
||||
@@ -428,18 +428,20 @@
|
||||
{
|
||||
"startsOn": "2026-03-01",
|
||||
"endsOn": "2026-03-07",
|
||||
"endedAt": null,
|
||||
"note": null,
|
||||
"noteEncrypted": "<sealed:v1:the March flu>",
|
||||
"updatedAt": "2026-05-02T00:47:00.000Z",
|
||||
"createdAt": "2026-05-02T00:46:00.000Z"
|
||||
"updatedAt": "2026-05-02T00:48:00.000Z",
|
||||
"createdAt": "2026-05-02T00:47:00.000Z"
|
||||
},
|
||||
{
|
||||
"startsOn": "2026-06-10",
|
||||
"endsOn": "2026-06-16",
|
||||
"endedAt": "2026-06-16T09:30:00.000Z",
|
||||
"note": null,
|
||||
"noteEncrypted": null,
|
||||
"updatedAt": "2026-05-02T00:47:01.000Z",
|
||||
"createdAt": "2026-05-02T00:46:01.000Z"
|
||||
"updatedAt": "2026-05-02T00:48:01.000Z",
|
||||
"createdAt": "2026-05-02T00:47:01.000Z"
|
||||
}
|
||||
],
|
||||
"doseChanges": [
|
||||
@@ -2772,8 +2774,8 @@
|
||||
"reorderLeadDays": 3,
|
||||
"externalSource": "WEB",
|
||||
"externalId": "rt-Medication-externalId",
|
||||
"createdAt": "2026-05-01T11:50:00.000Z",
|
||||
"updatedAt": "2026-05-01T11:51:00.000Z",
|
||||
"createdAt": "2026-05-01T11:51:00.000Z",
|
||||
"updatedAt": "2026-05-01T11:52:00.000Z",
|
||||
"schedules": [
|
||||
{
|
||||
"id": "<id:7>",
|
||||
@@ -2995,18 +2997,20 @@
|
||||
{
|
||||
"startsOn": "2026-03-01",
|
||||
"endsOn": "2026-03-07",
|
||||
"endedAt": null,
|
||||
"note": null,
|
||||
"noteEncrypted": "<sealed:v1:the March flu>",
|
||||
"createdAt": "2026-05-02T00:46:00.000Z",
|
||||
"updatedAt": "2026-05-02T00:47:00.000Z"
|
||||
"createdAt": "2026-05-02T00:47:00.000Z",
|
||||
"updatedAt": "2026-05-02T00:48:00.000Z"
|
||||
},
|
||||
{
|
||||
"startsOn": "2026-06-10",
|
||||
"endsOn": "2026-06-16",
|
||||
"endedAt": "2026-06-16T09:30:00.000Z",
|
||||
"note": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "2026-05-02T00:46:01.000Z",
|
||||
"updatedAt": "2026-05-02T00:47:01.000Z"
|
||||
"createdAt": "2026-05-02T00:47:01.000Z",
|
||||
"updatedAt": "2026-05-02T00:48:01.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@
|
||||
"autoMarkTaken": true,
|
||||
"autoMarkTakenSince": "2026-06-30T12:34:56.789Z",
|
||||
"category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10",
|
||||
"createdAt": "2026-05-01T11:50:00.000Z",
|
||||
"createdAt": "2026-05-01T11:51:00.000Z",
|
||||
"schedules": [
|
||||
{
|
||||
"windowStart": "08:00",
|
||||
@@ -250,14 +250,16 @@
|
||||
{
|
||||
"startsOn": "2026-03-01",
|
||||
"endsOn": "2026-03-07",
|
||||
"endedAt": null,
|
||||
"note": "the March flu",
|
||||
"createdAt": "2026-05-02T00:46:00.000Z"
|
||||
"createdAt": "2026-05-02T00:47:00.000Z"
|
||||
},
|
||||
{
|
||||
"startsOn": "2026-06-10",
|
||||
"endsOn": "2026-06-16",
|
||||
"endedAt": "2026-06-16T09:30:00.000Z",
|
||||
"note": null,
|
||||
"createdAt": "2026-05-02T00:46:01.000Z"
|
||||
"createdAt": "2026-05-02T00:47:01.000Z"
|
||||
}
|
||||
],
|
||||
"doseChanges": [
|
||||
@@ -1850,7 +1852,7 @@
|
||||
"autoMarkTaken": true,
|
||||
"autoMarkTakenSince": "2026-06-30T12:34:56.789Z",
|
||||
"category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10",
|
||||
"createdAt": "2026-05-01T11:50:00.000Z",
|
||||
"createdAt": "2026-05-01T11:51:00.000Z",
|
||||
"schedules": [
|
||||
{
|
||||
"windowStart": "08:00",
|
||||
@@ -2022,14 +2024,16 @@
|
||||
{
|
||||
"startsOn": "2026-03-01",
|
||||
"endsOn": "2026-03-07",
|
||||
"endedAt": null,
|
||||
"note": "the March flu",
|
||||
"createdAt": "2026-05-02T00:46:00.000Z"
|
||||
"createdAt": "2026-05-02T00:47:00.000Z"
|
||||
},
|
||||
{
|
||||
"startsOn": "2026-06-10",
|
||||
"endsOn": "2026-06-16",
|
||||
"endedAt": "2026-06-16T09:30:00.000Z",
|
||||
"note": null,
|
||||
"createdAt": "2026-05-02T00:46:01.000Z"
|
||||
"createdAt": "2026-05-02T00:47:01.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -614,6 +614,7 @@
|
||||
"snoozedUntil": "2026-06-30T12:34:56.789Z",
|
||||
"startsOn": "2026-06-10T00:00:00.000Z",
|
||||
"endsOn": "2026-06-16T00:00:00.000Z",
|
||||
"endedAt": "2026-06-16T09:30:00.000Z",
|
||||
"oneShot": true,
|
||||
"asNeeded": true,
|
||||
"trackIntake": false,
|
||||
@@ -633,8 +634,8 @@
|
||||
"reorderLeadDays": 3,
|
||||
"externalSource": "WEB",
|
||||
"externalId": "rt-Medication-externalId",
|
||||
"createdAt": "2026-05-01T11:50:00.000Z",
|
||||
"updatedAt": "2026-05-01T11:51:00.000Z"
|
||||
"createdAt": "2026-05-01T11:51:00.000Z",
|
||||
"updatedAt": "2026-05-01T11:52:00.000Z"
|
||||
}
|
||||
],
|
||||
"MedicationSchedule": [
|
||||
@@ -805,9 +806,10 @@
|
||||
"userId": "round-trip-owner",
|
||||
"startsOn": "2026-03-01T00:00:00.000Z",
|
||||
"endsOn": "2026-03-07T00:00:00.000Z",
|
||||
"endedAt": null,
|
||||
"noteEncrypted": "<sealed:v1:the March flu>",
|
||||
"createdAt": "2026-05-02T00:46:00.000Z",
|
||||
"updatedAt": "2026-05-02T00:47:00.000Z"
|
||||
"createdAt": "2026-05-02T00:47:00.000Z",
|
||||
"updatedAt": "2026-05-02T00:48:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "<id:19>",
|
||||
@@ -815,9 +817,10 @@
|
||||
"userId": "round-trip-owner",
|
||||
"startsOn": "2026-06-10T00:00:00.000Z",
|
||||
"endsOn": "2026-06-16T00:00:00.000Z",
|
||||
"endedAt": "2026-06-16T09:30:00.000Z",
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "2026-05-02T00:46:01.000Z",
|
||||
"updatedAt": "2026-05-02T00:47:01.000Z"
|
||||
"createdAt": "2026-05-02T00:47:01.000Z",
|
||||
"updatedAt": "2026-05-02T00:48:01.000Z"
|
||||
}
|
||||
],
|
||||
"MedicationIngredient": [
|
||||
@@ -3237,7 +3240,7 @@
|
||||
"reorderLeadDays": 3,
|
||||
"externalSource": "WEB",
|
||||
"externalId": "rt-Medication-externalId",
|
||||
"updatedAt": "2026-05-01T11:51:00.000Z",
|
||||
"updatedAt": "2026-05-01T11:52:00.000Z",
|
||||
"categoryUpdatedAt": "2026-05-01T21:22:00.000Z",
|
||||
"name": "Round-trip tablet",
|
||||
"dose": "5 mg",
|
||||
@@ -3246,7 +3249,7 @@
|
||||
"autoMarkTaken": true,
|
||||
"autoMarkTakenSince": "<now>",
|
||||
"category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10",
|
||||
"createdAt": "2026-05-01T11:50:00.000Z",
|
||||
"createdAt": "2026-05-01T11:51:00.000Z",
|
||||
"schedules": [
|
||||
{
|
||||
"id": "<id:7>",
|
||||
@@ -3330,18 +3333,20 @@
|
||||
{
|
||||
"startsOn": "2026-03-01",
|
||||
"endsOn": "2026-03-07",
|
||||
"endedAt": null,
|
||||
"note": null,
|
||||
"noteEncrypted": "<sealed:v1:the March flu>",
|
||||
"updatedAt": "2026-05-02T00:47:00.000Z",
|
||||
"createdAt": "2026-05-02T00:46:00.000Z"
|
||||
"updatedAt": "2026-05-02T00:48:00.000Z",
|
||||
"createdAt": "2026-05-02T00:47:00.000Z"
|
||||
},
|
||||
{
|
||||
"startsOn": "2026-06-10",
|
||||
"endsOn": "2026-06-16",
|
||||
"endedAt": "2026-06-16T09:30:00.000Z",
|
||||
"note": null,
|
||||
"noteEncrypted": null,
|
||||
"updatedAt": "2026-05-02T00:47:01.000Z",
|
||||
"createdAt": "2026-05-02T00:46:01.000Z"
|
||||
"updatedAt": "2026-05-02T00:48:01.000Z",
|
||||
"createdAt": "2026-05-02T00:47:01.000Z"
|
||||
}
|
||||
],
|
||||
"doseChanges": [
|
||||
|
||||
@@ -681,6 +681,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-06-10T00:00:00.000Z",
|
||||
"endsOn": "2026-06-16T00:00:00.000Z",
|
||||
"endedAt": "2026-06-16T09:30:00.000Z",
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": false,
|
||||
@@ -698,7 +699,7 @@
|
||||
"reorderLeadDays": null,
|
||||
"externalSource": null,
|
||||
"externalId": null,
|
||||
"createdAt": "2026-05-01T11:50:00.000Z",
|
||||
"createdAt": "2026-05-01T11:51:00.000Z",
|
||||
"updatedAt": "<now>"
|
||||
}
|
||||
],
|
||||
@@ -843,8 +844,9 @@
|
||||
"userId": "round-trip-owner",
|
||||
"startsOn": "2026-03-01T00:00:00.000Z",
|
||||
"endsOn": "2026-03-07T00:00:00.000Z",
|
||||
"endedAt": null,
|
||||
"noteEncrypted": "<sealed:v2:the March flu>",
|
||||
"createdAt": "2026-05-02T00:46:00.000Z",
|
||||
"createdAt": "2026-05-02T00:47:00.000Z",
|
||||
"updatedAt": "<now>"
|
||||
},
|
||||
{
|
||||
@@ -853,8 +855,9 @@
|
||||
"userId": "round-trip-owner",
|
||||
"startsOn": "2026-06-10T00:00:00.000Z",
|
||||
"endsOn": "2026-06-16T00:00:00.000Z",
|
||||
"endedAt": "2026-06-16T09:30:00.000Z",
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "2026-05-02T00:46:01.000Z",
|
||||
"createdAt": "2026-05-02T00:47:01.000Z",
|
||||
"updatedAt": "<now>"
|
||||
}
|
||||
],
|
||||
@@ -2596,7 +2599,7 @@
|
||||
"autoMarkTaken": true,
|
||||
"autoMarkTakenSince": "<now>",
|
||||
"category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10",
|
||||
"createdAt": "2026-05-01T11:50:00.000Z",
|
||||
"createdAt": "2026-05-01T11:51:00.000Z",
|
||||
"schedules": [
|
||||
{
|
||||
"windowStart": "08:00",
|
||||
@@ -2647,14 +2650,16 @@
|
||||
{
|
||||
"startsOn": "2026-03-01",
|
||||
"endsOn": "2026-03-07",
|
||||
"endedAt": null,
|
||||
"note": "the March flu",
|
||||
"createdAt": "2026-05-02T00:46:00.000Z"
|
||||
"createdAt": "2026-05-02T00:47:00.000Z"
|
||||
},
|
||||
{
|
||||
"startsOn": "2026-06-10",
|
||||
"endsOn": "2026-06-16",
|
||||
"endedAt": "2026-06-16T09:30:00.000Z",
|
||||
"note": null,
|
||||
"createdAt": "2026-05-02T00:46:01.000Z"
|
||||
"createdAt": "2026-05-02T00:47:01.000Z"
|
||||
}
|
||||
],
|
||||
"doseChanges": [
|
||||
|
||||
@@ -570,6 +570,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
|
||||
@@ -521,6 +521,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -555,6 +556,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-07-01T00:00:00.000Z",
|
||||
"endsOn": "2026-08-01T00:00:00.000Z",
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -652,6 +654,7 @@
|
||||
"userId": "round-trip-owner",
|
||||
"startsOn": "2026-07-01T00:00:00.000Z",
|
||||
"endsOn": "2026-08-01T00:00:00.000Z",
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<now>",
|
||||
"updatedAt": "<now>"
|
||||
@@ -1480,6 +1483,7 @@
|
||||
{
|
||||
"startsOn": "2026-07-01",
|
||||
"endsOn": "2026-08-01",
|
||||
"endedAt": null,
|
||||
"note": null,
|
||||
"noteEncrypted": null,
|
||||
"updatedAt": "<now>",
|
||||
|
||||
@@ -212,6 +212,7 @@
|
||||
"snoozedUntil": "2026-06-30T12:34:56.789Z",
|
||||
"startsOn": "2026-06-10T00:00:00.000Z",
|
||||
"endsOn": "2026-06-16T00:00:00.000Z",
|
||||
"endedAt": "2026-06-16T09:30:00.000Z",
|
||||
"oneShot": true,
|
||||
"asNeeded": true,
|
||||
"trackIntake": false,
|
||||
@@ -231,8 +232,8 @@
|
||||
"reorderLeadDays": 3,
|
||||
"externalSource": "WEB",
|
||||
"externalId": "rt-Medication-externalId",
|
||||
"createdAt": "2026-05-01T11:50:00.000Z",
|
||||
"updatedAt": "2026-05-01T11:51:00.000Z"
|
||||
"createdAt": "2026-05-01T11:51:00.000Z",
|
||||
"updatedAt": "2026-05-01T11:52:00.000Z"
|
||||
}
|
||||
],
|
||||
"MedicationSchedule": [
|
||||
@@ -403,9 +404,10 @@
|
||||
"userId": "round-trip-owner",
|
||||
"startsOn": "2026-03-01T00:00:00.000Z",
|
||||
"endsOn": "2026-03-07T00:00:00.000Z",
|
||||
"endedAt": null,
|
||||
"noteEncrypted": "<sealed:v1:the March flu>",
|
||||
"createdAt": "2026-05-02T00:46:00.000Z",
|
||||
"updatedAt": "2026-05-02T00:47:00.000Z"
|
||||
"createdAt": "2026-05-02T00:47:00.000Z",
|
||||
"updatedAt": "2026-05-02T00:48:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "<id:19>",
|
||||
@@ -413,9 +415,10 @@
|
||||
"userId": "round-trip-owner",
|
||||
"startsOn": "2026-06-10T00:00:00.000Z",
|
||||
"endsOn": "2026-06-16T00:00:00.000Z",
|
||||
"endedAt": "2026-06-16T09:30:00.000Z",
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "2026-05-02T00:46:01.000Z",
|
||||
"updatedAt": "2026-05-02T00:47:01.000Z"
|
||||
"createdAt": "2026-05-02T00:47:01.000Z",
|
||||
"updatedAt": "2026-05-02T00:48:01.000Z"
|
||||
}
|
||||
],
|
||||
"MedicationIngredient": [
|
||||
|
||||
@@ -190,7 +190,9 @@ export async function seedEveryTwoEndedModel(
|
||||
});
|
||||
|
||||
// Two courses with a gap, and the window the row projects from them (the
|
||||
// latest). The earlier course exists nowhere else once it has ended.
|
||||
// latest). The earlier course exists nowhere else once it has ended. The
|
||||
// latest was ended with "End course" (#1280), so the instant has to come
|
||||
// back with it.
|
||||
const medication = await prisma.medication.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
@@ -198,6 +200,7 @@ export async function seedEveryTwoEndedModel(
|
||||
dose: "5 mg",
|
||||
startsOn: AT("2026-06-10T00:00:00.000Z"),
|
||||
endsOn: AT("2026-06-16T00:00:00.000Z"),
|
||||
endedAt: AT("2026-06-16T09:30:00.000Z"),
|
||||
schedules: {
|
||||
create: { windowStart: "08:00", windowEnd: "09:00", label: "Morning" },
|
||||
},
|
||||
@@ -213,6 +216,7 @@ export async function seedEveryTwoEndedModel(
|
||||
userId: OWNER_ID,
|
||||
startsOn: AT("2026-06-10T00:00:00.000Z"),
|
||||
endsOn: AT("2026-06-16T00:00:00.000Z"),
|
||||
endedAt: AT("2026-06-16T09:30:00.000Z"),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -217,6 +217,7 @@ async function readDisplayDue(medId: string, now: Date): Promise<Date | null> {
|
||||
id: med.id,
|
||||
startsOn: med.startsOn,
|
||||
endsOn: med.endsOn,
|
||||
endedAt: null,
|
||||
oneShot: med.oneShot,
|
||||
createdAt: med.createdAt,
|
||||
},
|
||||
|
||||
@@ -585,6 +585,7 @@ describe("remindersActive — the published value is the gate", () => {
|
||||
trackIntake: boolean;
|
||||
asNeeded: boolean;
|
||||
endsOn: Date | null;
|
||||
endedAt: Date | null;
|
||||
}> = [];
|
||||
for (let bits = 0; bits < 64; bits++) {
|
||||
combos.push({
|
||||
@@ -595,6 +596,7 @@ describe("remindersActive — the published value is the gate", () => {
|
||||
asNeeded: (bits & 16) !== 0,
|
||||
// A course that ended the day before.
|
||||
endsOn: (bits & 32) !== 0 ? new Date("2026-06-09T00:00:00.000Z") : null,
|
||||
endedAt: null,
|
||||
});
|
||||
}
|
||||
const ids: string[] = [];
|
||||
|
||||
Reference in new issue
Block a user