mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32: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
@@ -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