mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge remote-tracking branch 'origin/release/v1.46.0' into fix/status-parity-flake
This commit is contained in:
commit
3984ceb01c
236 files changed
+2987
-277
No files matched your search
+200
-55
@@ -6861,6 +6861,49 @@ paths:
|
||||
"409": *a7
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/medications/{id}/courses/end:
|
||||
post:
|
||||
tags:
|
||||
- Medications
|
||||
summary: End the running course now
|
||||
description: "#1280 — \"End course\". No body. The running course's last day becomes today on the person's clock and the
|
||||
moment is stored as its `endedAt`: a dose slot later today is not due, not reminded and not expected by
|
||||
compliance, a dose taken earlier today still counts, and `courseStatus` reads ENDED at once. Pending
|
||||
placeholders for slots at or after the moment are removed (tombstoned for sync). A medication with no dates gets
|
||||
the course its window describes, from creation to today. An end date set through `PUT /api/medications/{id}` or
|
||||
the course editor keeps meaning the whole day; only this action ends a course at an instant. Refused with 409
|
||||
`medications.course.notRunning` while no course is running — the rule the reads publish as `canEndCourse`.
|
||||
Owner-only."
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
- *a6
|
||||
responses:
|
||||
"200":
|
||||
description: The medication's window after ending.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EndMedicationCourseResponse"
|
||||
"401": *a1
|
||||
"403": *a9
|
||||
"404":
|
||||
description: Medication not found (`medications.course.notFound`), or another user's.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"409":
|
||||
description: "No course is running now (`medications.course.notRunning`): it has ended already or has not started."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/medications/{id}/courses/{courseId}:
|
||||
patch:
|
||||
tags:
|
||||
@@ -38495,55 +38538,56 @@ components:
|
||||
medication.intake.notTracked, medication.link.invalid; medications — medications.category.invalid,
|
||||
medications.category.limitReached, medications.category.unknown, medications.course.currentOpen,
|
||||
medications.course.invalid, medications.course.invalidRange, medications.course.notFound,
|
||||
medications.course.oneShot, medications.course.overlap, medications.course.windowRequired,
|
||||
medications.ingredients.extract.budgetExceeded, medications.ingredients.extract.failed,
|
||||
medications.ingredients.extract.fileTooLarge, medications.ingredients.extract.fileType,
|
||||
medications.ingredients.extract.rateLimited, medications.ingredients.invalid,
|
||||
medications.ingredients.unitMismatch, medications.intake.bulk.apple_health_not_mirrored,
|
||||
medications.intake.bulk.invalid, medications.intake.bulk.too_large,
|
||||
medications.intake.force_slot.invalid, medications.intake.force_slot.occupied,
|
||||
medications.intake.injection_site.disallowed, medications.intake.taken_at.before_start,
|
||||
medications.mirror.limit_exceeded, medications.notFound; mentalHealth — mentalHealth.rateLimited; module
|
||||
— module.disabled; modules — modules.body.invalid_json, modules.invalid; mood —
|
||||
mood.bulk-delete.invalid, mood.bulk.invalid, mood.bulk.too_large, mood.create.external_id_not_delegable,
|
||||
mood.create.invalid, mood.duplicate_timestamp, mood.linkedContext.invalid, mood.list.invalid,
|
||||
mood.not_found, mood.ratedFactor.out_of_range, mood.restore.invalid, mood.update.invalid;
|
||||
notification-prefs — notification-prefs.body.invalid_json; notification —
|
||||
notification.action.already_used, notification.action.expired, notification.action.invalid,
|
||||
notification.action.module_disabled; nutrient — nutrient.batch.invalid, nutrient.batch.too_large,
|
||||
nutrient.daily.invalid, nutrient.read.invalid, nutrient.water.invalid, nutrient.water.invalid_day;
|
||||
onboarding — onboarding.answers.invalid, onboarding.answers.rateLimited, onboarding.complete.invalid,
|
||||
onboarding.complete.recordNotFound, onboarding.complete.recordTargetMismatch,
|
||||
onboarding.disclaimer.invalid, onboarding.restart.invalid, onboarding.restart.rateLimited,
|
||||
onboarding.tour.invalid; passkey — passkey.challenge.expired, passkey.response.invalid, passkey.unknown,
|
||||
passkey.verification.failed; personal_records — personal_records.invalid_query; practitioner —
|
||||
practitioner.invalid; profile — profile.update.emailInUse, profile.update.emailRateLimited,
|
||||
profile.update.emailSecondFactorRequired, profile.update.invalidBody, profile.update.nothingSaved;
|
||||
profile_claim — profile_claim.invalid, profile_claim.oidc_only_unsupported,
|
||||
profile_claim.sso_unavailable, profile_claim.taken; record_write — record_write.rate_limited;
|
||||
report-selection — report-selection.body.invalid_json, report-selection.body.invalid_shape,
|
||||
report-selection.leaves.unknown; route — route.retired; search — search.invalid; security_activity —
|
||||
security_activity.invalid; share-link — share-link.document.decryptFailed, share-link.document.notFound,
|
||||
share-link.document.rateLimited, share-link.documents.invalid, share-link.report.notFound,
|
||||
share-link.report.rateLimited, share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf;
|
||||
sharing — sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending,
|
||||
sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid,
|
||||
sharing.invite.invalid_scope, sharing.invite.manage_browser_only, sharing.invite.no_account,
|
||||
sharing.invite.refused, sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended,
|
||||
sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found,
|
||||
sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority —
|
||||
source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms —
|
||||
symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen,
|
||||
symptoms.event.notFound, symptoms.invalid; timeline — timeline.invalid; tokens —
|
||||
tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached,
|
||||
tokens.mirror.not_measurement_token, tokens.workouts.ceiling_reached; vaccination —
|
||||
vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope,
|
||||
vaccination.custom-vaccine-not-found, vaccination.custom.invalid, vaccination.custom.name-taken,
|
||||
vaccination.custom.not-found, vaccination.encounter-not-found, vaccination.identity-required,
|
||||
vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
|
||||
vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large,
|
||||
workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed)
|
||||
— about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file,
|
||||
medications.course.notRunning, medications.course.oneShot, medications.course.overlap,
|
||||
medications.course.windowRequired, medications.ingredients.extract.budgetExceeded,
|
||||
medications.ingredients.extract.failed, medications.ingredients.extract.fileTooLarge,
|
||||
medications.ingredients.extract.fileType, medications.ingredients.extract.rateLimited,
|
||||
medications.ingredients.invalid, medications.ingredients.unitMismatch,
|
||||
medications.intake.bulk.apple_health_not_mirrored, medications.intake.bulk.invalid,
|
||||
medications.intake.bulk.too_large, medications.intake.force_slot.invalid,
|
||||
medications.intake.force_slot.occupied, medications.intake.injection_site.disallowed,
|
||||
medications.intake.taken_at.before_start, medications.mirror.limit_exceeded, medications.notFound;
|
||||
mentalHealth — mentalHealth.rateLimited; module — module.disabled; modules — modules.body.invalid_json,
|
||||
modules.invalid; mood — mood.bulk-delete.invalid, mood.bulk.invalid, mood.bulk.too_large,
|
||||
mood.create.external_id_not_delegable, mood.create.invalid, mood.duplicate_timestamp,
|
||||
mood.linkedContext.invalid, mood.list.invalid, mood.not_found, mood.ratedFactor.out_of_range,
|
||||
mood.restore.invalid, mood.update.invalid; notification-prefs — notification-prefs.body.invalid_json;
|
||||
notification — notification.action.already_used, notification.action.expired,
|
||||
notification.action.invalid, notification.action.module_disabled; nutrient — nutrient.batch.invalid,
|
||||
nutrient.batch.too_large, nutrient.daily.invalid, nutrient.read.invalid, nutrient.water.invalid,
|
||||
nutrient.water.invalid_day; onboarding — onboarding.answers.invalid, onboarding.answers.rateLimited,
|
||||
onboarding.complete.invalid, onboarding.complete.recordNotFound,
|
||||
onboarding.complete.recordTargetMismatch, onboarding.disclaimer.invalid, onboarding.restart.invalid,
|
||||
onboarding.restart.rateLimited, onboarding.tour.invalid; passkey — passkey.challenge.expired,
|
||||
passkey.response.invalid, passkey.unknown, passkey.verification.failed; personal_records —
|
||||
personal_records.invalid_query; practitioner — practitioner.invalid; profile —
|
||||
profile.update.emailInUse, profile.update.emailRateLimited, profile.update.emailSecondFactorRequired,
|
||||
profile.update.invalidBody, profile.update.nothingSaved; profile_claim — profile_claim.invalid,
|
||||
profile_claim.oidc_only_unsupported, profile_claim.sso_unavailable, profile_claim.taken; record_write —
|
||||
record_write.rate_limited; report-selection — report-selection.body.invalid_json,
|
||||
report-selection.body.invalid_shape, report-selection.leaves.unknown; route — route.retired; search —
|
||||
search.invalid; security_activity — security_activity.invalid; share-link —
|
||||
share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited,
|
||||
share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited,
|
||||
share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing —
|
||||
sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked,
|
||||
sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope,
|
||||
sharing.invite.manage_browser_only, sharing.invite.no_account, sharing.invite.refused,
|
||||
sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, sharing.renounce.not_found,
|
||||
sharing.revoke.already_ended, sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid,
|
||||
sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json,
|
||||
source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached,
|
||||
symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid;
|
||||
timeline — timeline.invalid; tokens — tokens.documents.ceiling_reached,
|
||||
tokens.measurements.ceiling_reached, tokens.mirror.not_measurement_token,
|
||||
tokens.workouts.ceiling_reached; vaccination — vaccination.booster-invalid,
|
||||
vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found,
|
||||
vaccination.custom.invalid, vaccination.custom.name-taken, vaccination.custom.not-found,
|
||||
vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid,
|
||||
vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout —
|
||||
workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted,
|
||||
workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict,
|
||||
ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file,
|
||||
health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at,
|
||||
json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns,
|
||||
modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only,
|
||||
@@ -42034,6 +42078,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 +53836,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 +53863,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 `POST /api/medications/{id}/courses/end` (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,12 +53969,19 @@ 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 `POST /api/medications/{id}/courses/end`: 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
|
||||
tracked (`trackIntake`) and `courseStatus` is CURRENT. Clients render the dose actions from this and do not
|
||||
re-derive it. Logging a past dose from the history is not gated by it. Read-only — computed, not stored."
|
||||
canEndCourse:
|
||||
type: boolean
|
||||
description: '#1280 — whether `POST /api/medications/{id}/courses/end` may end the course now: true exactly while a
|
||||
course is running (`courseStatus` CURRENT; a medication with no dates is running). Clients offer "End
|
||||
course" from this and do not re-derive it; the action refuses with 409 `medications.course.notRunning` when
|
||||
it is false. Read-only — computed, not stored.'
|
||||
lastTakenAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -54001,6 +54067,7 @@ components:
|
||||
- nextDueScheduleId
|
||||
- courseStatus
|
||||
- intakeActionable
|
||||
- canEndCourse
|
||||
- lastTakenAt
|
||||
- todayEventCount
|
||||
- stockUnitsRemaining
|
||||
@@ -54037,13 +54104,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 `POST /api/medications/{id}/courses/end` 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 +54135,7 @@ components:
|
||||
- id
|
||||
- startsOn
|
||||
- endsOn
|
||||
- endedAt
|
||||
- status
|
||||
- takenDoses
|
||||
- note
|
||||
@@ -54244,6 +54322,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 `POST /api/medications/{id}/courses/end` (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,12 +54423,19 @@ 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 `POST /api/medications/{id}/courses/end`: 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
|
||||
tracked (`trackIntake`) and `courseStatus` is CURRENT. Clients render the dose actions from this and do not
|
||||
re-derive it. Logging a past dose from the history is not gated by it. Read-only — computed, not stored."
|
||||
canEndCourse:
|
||||
type: boolean
|
||||
description: '#1280 — whether `POST /api/medications/{id}/courses/end` may end the course now: true exactly while a
|
||||
course is running (`courseStatus` CURRENT; a medication with no dates is running). Clients offer "End
|
||||
course" from this and do not re-derive it; the action refuses with 409 `medications.course.notRunning` when
|
||||
it is false. Read-only — computed, not stored.'
|
||||
stockUnitsRemaining:
|
||||
description: "v1.37.19 — usable inventory units left (same semantics as the list entry's field): NULL = inventory
|
||||
tracking off; 0 = tracking on, supply ran out."
|
||||
@@ -54408,6 +54502,7 @@ components:
|
||||
- canStartCourse
|
||||
- courseStatus
|
||||
- intakeActionable
|
||||
- canEndCourse
|
||||
- stockUnitsRemaining
|
||||
- stockDosesRemaining
|
||||
- runwayDays
|
||||
@@ -54688,6 +54783,47 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
EndMedicationCourseResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
startsOn:
|
||||
anyOf:
|
||||
- type: string
|
||||
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"
|
||||
endsOn:
|
||||
anyOf:
|
||||
- type: string
|
||||
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
|
||||
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"
|
||||
required:
|
||||
- startsOn
|
||||
- endsOn
|
||||
- endedAt
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
UpdateMedicationCourseResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -93793,6 +93929,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 `POST /api/medications/{id}/courses/end` (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")
|
||||
|
||||
@@ -73,6 +73,11 @@ const ALLOWLIST: Record<string, { max: number; reason: string }> = {
|
||||
max: 2,
|
||||
reason: "the cases pin the clock with fake timers around the fixed dates",
|
||||
},
|
||||
"tests/integration/medication-course-end.test.ts": {
|
||||
max: 1,
|
||||
reason:
|
||||
"the cases pin the clock with fake timers; the history read starts on the fixed day the course was ended",
|
||||
},
|
||||
"tests/integration/symptoms.test.ts": {
|
||||
max: 1,
|
||||
reason:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 87,
|
||||
"total": 97468,
|
||||
"total": 97455,
|
||||
"entries": {
|
||||
"app/medications/page-client.tsx": 839,
|
||||
"components/charts/mood-chart.tsx": 1166,
|
||||
@@ -13,7 +13,7 @@
|
||||
"components/insights/health-score-card.tsx": 1023,
|
||||
"components/insights/insights-tab-strip.tsx": 1086,
|
||||
"components/measurements/measurement-form.tsx": 815,
|
||||
"components/medications/detail/medication-detail-tabs.tsx": 1160,
|
||||
"components/medications/detail/medication-detail-tabs.tsx": 1157,
|
||||
"components/medications/medication-table.tsx": 853,
|
||||
"components/medications/wizard/wizard-payload.ts": 1414,
|
||||
"components/mood/mood-list.tsx": 1408,
|
||||
@@ -76,8 +76,8 @@
|
||||
"lib/mcp/rich-reads.ts": 1467,
|
||||
"lib/measurements/dense-intraday-retention.ts": 1057,
|
||||
"lib/measurements/import-apple-health-export.ts": 1722,
|
||||
"lib/medications/scheduling/cadence.ts": 978,
|
||||
"lib/medications/scheduling/recurrence.ts": 1108,
|
||||
"lib/medications/scheduling/cadence.ts": 976,
|
||||
"lib/medications/scheduling/recurrence.ts": 1100,
|
||||
"lib/medications/scheduling/slot-upsert.ts": 912,
|
||||
"lib/notifications/senders/apns.ts": 1100,
|
||||
"lib/oura/sync.ts": 900,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 351,
|
||||
"total": 113681,
|
||||
"total": 113672,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
|
||||
@@ -152,7 +152,7 @@
|
||||
"components/measurement-reminders/vorsorge-section.tsx :: VorsorgeSection": 352,
|
||||
"components/measurements/measurement-form.tsx :: MeasurementForm": 500,
|
||||
"components/medications/detail/efficacy/efficacy-chart.tsx :: EfficacyChart": 361,
|
||||
"components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 771,
|
||||
"components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 767,
|
||||
"components/medications/dose-history-add-dialog.tsx :: LedgerAddDialog": 223,
|
||||
"components/medications/dose-strength-curve.tsx :: DoseStrengthCurve": 266,
|
||||
"components/medications/glp1-medication-card.tsx :: Glp1MedicationCard": 405,
|
||||
@@ -248,7 +248,7 @@
|
||||
"lib/ai/anthropic-client.ts :: generateCompletion": 294,
|
||||
"lib/ai/coach/cycle-snapshot.ts :: buildCycleSnapshotBlock": 218,
|
||||
"lib/ai/coach/glp1-snapshot.ts :: buildGlp1SnapshotBlock": 218,
|
||||
"lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 882,
|
||||
"lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 881,
|
||||
"lib/ai/coach/tools/loop.ts :: runCoachToolLoop": 438,
|
||||
"lib/ai/coach/turn/model.ts :: runTurnModel": 540,
|
||||
"lib/ai/coach/turn/pipeline.ts :: runTurn": 274,
|
||||
@@ -276,7 +276,7 @@
|
||||
"lib/doctor-report-pdf/clinical-records-notes-section.ts :: buildClinicalRecordsNotesSection": 648,
|
||||
"lib/doctor-report-pdf/measurements-charts-section.ts :: buildMeasurementsChartsSection": 384,
|
||||
"lib/doctor-report-pdf/medication-mood-wellness-section.ts :: buildMedicationMoodWellnessSection": 509,
|
||||
"lib/doctor-report/collect.ts :: collectDoctorReportData": 668,
|
||||
"lib/doctor-report/collect.ts :: collectDoctorReportData": 667,
|
||||
"lib/doctor-report/dense-buckets.ts :: loadDenseMeasurementBuckets": 210,
|
||||
"lib/documents/fenced-chat.ts :: streamFencedReply": 309,
|
||||
"lib/documents/ingest.ts :: ingestDocument": 272,
|
||||
@@ -333,8 +333,8 @@
|
||||
"lib/measurements/import-apple-health-export.ts :: streamParseExportXml": 1245,
|
||||
"lib/measurements/import-apple-health-export.ts :: streamParseExportXml > (anonymous) #2": 323,
|
||||
"lib/medications/efficacy/build-efficacy.ts :: buildMedicationEfficacy": 268,
|
||||
"lib/medications/intake-slot-dedup.ts :: dedupeUserIntakeSlots": 321,
|
||||
"lib/medications/list-read.ts :: buildMedicationsList": 309,
|
||||
"lib/medications/intake-slot-dedup.ts :: dedupeUserIntakeSlots": 319,
|
||||
"lib/medications/list-read.ts :: buildMedicationsList": 308,
|
||||
"lib/medications/scheduling/dose-history.ts :: reconstructDoseHistory": 218,
|
||||
"lib/multipart/stream-to-disk.ts :: streamMultipartToDisk": 272,
|
||||
"lib/notifications/dispatcher.ts :: dispatchNotification": 378,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -285,6 +285,8 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
// v1.40 (#1024) — a medication's courses, same again.
|
||||
"src/app/api/medications/[id]/courses/route.ts",
|
||||
"src/app/api/medications/[id]/courses/[courseId]/route.ts",
|
||||
// #1280 — ending the running course is a write to the same course rows.
|
||||
"src/app/api/medications/[id]/courses/end/route.ts",
|
||||
"src/app/api/medications/compliance/route.ts",
|
||||
"src/app/api/medications/intake/route.ts",
|
||||
"src/app/api/medications/intake/bulk/route.ts",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* #1280 — "End course": end the running course now.
|
||||
*
|
||||
* POST /api/medications/{id}/courses/end — no body. The course's last day
|
||||
* becomes today on the person's clock and the moment is recorded as
|
||||
* its `endedAt`, so a dose later today is not due, not reminded and
|
||||
* not expected, while one taken earlier today still counts. The
|
||||
* medication reads as ENDED at once.
|
||||
*
|
||||
* An action of its own rather than `PUT { endsOn }`: an end date set through
|
||||
* the PUT or the course editor keeps meaning the whole day. Refused with 409
|
||||
* `medications.course.notRunning` while no course is running, the same rule
|
||||
* the reads publish as `canEndCourse`.
|
||||
*
|
||||
* Owner-only, like the other course routes.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
CourseWriteError,
|
||||
courseRefusalResponse,
|
||||
endCurrentCourse,
|
||||
} from "@/lib/medications/courses";
|
||||
import { dayKeyOfDate } from "@/lib/medications/course-window";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const POST = apiHandler(
|
||||
withIdempotency<[NextRequest, RouteParams]>(postEndCourse),
|
||||
);
|
||||
|
||||
async function postEndCourse(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams,
|
||||
): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const window = await endCurrentCourse({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
timeZone: user.timezone || DEFAULT_TIMEZONE,
|
||||
});
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
const body = {
|
||||
startsOn: window.startsOn ? dayKeyOfDate(window.startsOn) : null,
|
||||
endsOn: window.endsOn ? dayKeyOfDate(window.endsOn) : null,
|
||||
endedAt: window.endedAt ? window.endedAt.toISOString() : null,
|
||||
};
|
||||
await auditLog("medication.course.end", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { medicationId: id, ...body },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "medication.course.end" },
|
||||
meta: { medicationId: id },
|
||||
});
|
||||
return apiSuccess(body);
|
||||
} catch (err) {
|
||||
if (err instanceof CourseWriteError) {
|
||||
const r = courseRefusalResponse(err.refusal);
|
||||
annotate({ meta: { "medication.course.refused": err.refusal } });
|
||||
return apiError(r.message, r.status, { errorCode: r.errorCode });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -234,7 +234,7 @@ export const GET = apiHandler(
|
||||
createdAt: medication.createdAt,
|
||||
};
|
||||
const ctx = buildRecurrenceContext({
|
||||
medication: bandMedication,
|
||||
medication: { ...bandMedication, endedAt: medication.endedAt },
|
||||
userTz,
|
||||
lastIntakeAt,
|
||||
});
|
||||
|
||||
@@ -193,6 +193,7 @@ export interface MedicationDetailSnapshot {
|
||||
/** v1.40 (#1024) — the server-resolved courses. */
|
||||
courses?: MedicationCourseWire[];
|
||||
canStartCourse?: boolean;
|
||||
canEndCourse?: boolean;
|
||||
recordedSchedules?: ScheduleSnapshot[];
|
||||
schedules: ScheduleSnapshot[];
|
||||
/** v1.43 — the composition per unit, as the server delivered it. */
|
||||
@@ -923,11 +924,7 @@ export function MedicationDetailTabs({
|
||||
defaultOpen={false}
|
||||
>
|
||||
<div className="py-3">
|
||||
<LifecycleManageBody
|
||||
medicationId={id}
|
||||
medicationName={medication.name}
|
||||
active={medication.active}
|
||||
/>
|
||||
<LifecycleManageBody medication={medication} />
|
||||
</div>
|
||||
{/* v1.40 (#1024) — the courses: when it was taken, and a new
|
||||
course once the last one has ended. */}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
canEndCourse,
|
||||
courseEndNow,
|
||||
dayKeyOfDate,
|
||||
} from "@/lib/medications/course-window";
|
||||
import { updateMedicationSchema } from "@/lib/validations/medication/create-update";
|
||||
|
||||
import { LifecycleManageBody } from "../destructive-zone-section";
|
||||
|
||||
/** The end day End writes, as the `YYYY-MM-DD` an `endsOn` field carries. */
|
||||
function endCourseBody(now: Date, timeZone: string): { endsOn: string } {
|
||||
return { endsOn: dayKeyOfDate(courseEndNow(now, timeZone).endsOn) };
|
||||
}
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "test-user", username: "tester", role: "USER" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
/**
|
||||
* "End medication" sent `new Date().toISOString()`, a full instant, against an
|
||||
* `endsOn` that only takes `YYYY-MM-DD`, so every press answered 422 and the
|
||||
* toast said the medication could not be ended. End now asks the server to
|
||||
* end the course (`POST /courses/end`), which writes the person's own
|
||||
* calendar day (`courseEndNow`) as the last day.
|
||||
*/
|
||||
describe("endCourseBody", () => {
|
||||
it("is a date the update schema accepts", () => {
|
||||
const body = endCourseBody(
|
||||
new Date("2026-10-21T09:30:00.000Z"),
|
||||
"Europe/Berlin",
|
||||
);
|
||||
expect(body).toEqual({ endsOn: "2026-10-21" });
|
||||
expect(updateMedicationSchema.safeParse(body).success).toBe(true);
|
||||
});
|
||||
|
||||
it("is the person's day, not the UTC one", () => {
|
||||
// 23:30 UTC is already the next day in Warsaw and still the same day in
|
||||
// New York.
|
||||
const lateUtc = new Date("2026-10-20T23:30:00.000Z");
|
||||
expect(endCourseBody(lateUtc, "Europe/Warsaw").endsOn).toBe("2026-10-21");
|
||||
expect(endCourseBody(lateUtc, "America/New_York").endsOn).toBe(
|
||||
"2026-10-20",
|
||||
);
|
||||
});
|
||||
|
||||
it("is what the update schema refused before: a full instant is not a date", () => {
|
||||
expect(
|
||||
updateMedicationSchema.safeParse({
|
||||
endsOn: new Date("2026-10-21T09:30:00.000Z").toISOString(),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Ending writes today as the end of the latest course. On a course that has
|
||||
* already ended that would reopen it up to today, and on one that has not
|
||||
* started it would become a one-day course today, so End is only offered while
|
||||
* the course runs. The server resolves that as `canEndCourse` (a medication
|
||||
* with no dates is CURRENT); the controls render from it, and without it
|
||||
* (a read that does not carry it) End stays hidden.
|
||||
*/
|
||||
describe("End is offered only for a running course", () => {
|
||||
function render(served: boolean | undefined) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<LifecycleManageBody
|
||||
medication={{
|
||||
id: "med-1",
|
||||
name: "Example",
|
||||
active: true,
|
||||
canEndCourse: served,
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
it.each([
|
||||
["CURRENT", true],
|
||||
[undefined, false],
|
||||
["ENDED", false],
|
||||
["UPCOMING", false],
|
||||
] as const)("course %s → End shown: %s", (status, shown) => {
|
||||
const served = status === undefined ? undefined : canEndCourse(status);
|
||||
expect(served ?? false).toBe(shown);
|
||||
const html = render(served);
|
||||
expect(html.includes('data-slot="destructive-zone-end"')).toBe(shown);
|
||||
// Pausing stays available whatever the course.
|
||||
expect(html).toContain('id="medication-detail-pause-switch"');
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,10 @@
|
||||
* - Tier 1 Pausieren: PUT `{ active: false | true }`. Server derives
|
||||
* `pausedAt`. Switch wraps in `<label>` so the entire row is the
|
||||
* AT hit target.
|
||||
* - Tier 2 Beenden: PUT `{ endsOn: <today-iso> }`. AlertDialog
|
||||
* confirm; Cancel autofocus stays default.
|
||||
* - Tier 2 Beenden: POST `/courses/end` (#1280), no body: the server ends
|
||||
* the running course at that moment, on the person's own day. Offered
|
||||
* while the read serves `canEndCourse`. AlertDialog confirm; Cancel
|
||||
* autofocus stays default.
|
||||
* - Tier 3a Verlauf löschen: DELETE `/intake/purge`. Disabled when
|
||||
* `intakeCount === 0`. AlertDialog confirm.
|
||||
* - Tier 3b Medikament löschen: DELETE `/api/medications/[id]`.
|
||||
@@ -48,7 +50,7 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateMedicationReads, queryKeys } from "@/lib/query-keys";
|
||||
import { apiDelete, apiPut } from "@/lib/api/api-fetch";
|
||||
import { apiDelete, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
|
||||
const PAUSE_SWITCH_ID = "medication-detail-pause-switch";
|
||||
const PAUSE_TITLE_ID = "medication-detail-pause-title";
|
||||
@@ -59,23 +61,28 @@ const PAUSE_HELPER_ID = "medication-detail-pause-helper";
|
||||
* Bare (no section / card chrome); the caller supplies its own grouping.
|
||||
*/
|
||||
export function LifecycleManageBody({
|
||||
medicationId,
|
||||
medicationName,
|
||||
active,
|
||||
medication,
|
||||
onAfterAction,
|
||||
}: {
|
||||
medicationId: string;
|
||||
medicationName: string;
|
||||
active: boolean;
|
||||
medication: {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
/**
|
||||
* Whether a course is running that End may end now (server-resolved).
|
||||
* Ending writes today as the course's last day, so on an ended course it
|
||||
* would reopen it and on one not yet started invent a one-day course.
|
||||
*/
|
||||
canEndCourse?: boolean;
|
||||
};
|
||||
onAfterAction?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const medicationId = medication.id;
|
||||
|
||||
const [paused, setPaused] = useState(!active);
|
||||
const [paused, setPaused] = useState(!medication.active);
|
||||
const [tier1Busy, setTier1Busy] = useState(false);
|
||||
const [endDialogOpen, setEndDialogOpen] = useState(false);
|
||||
const [tier2Busy, setTier2Busy] = useState(false);
|
||||
|
||||
async function tier1Toggle(pauseNext: boolean) {
|
||||
if (tier1Busy) return;
|
||||
@@ -103,25 +110,6 @@ export function LifecycleManageBody({
|
||||
}
|
||||
}
|
||||
|
||||
async function tier2End() {
|
||||
if (tier2Busy) return;
|
||||
setTier2Busy(true);
|
||||
try {
|
||||
const today = new Date().toISOString();
|
||||
await apiPut(`/api/medications/${medicationId}`, { endsOn: today });
|
||||
await invalidateMedicationReads(queryClient);
|
||||
toast.success(t("medications.detail.zone.end.toast"));
|
||||
setEndDialogOpen(false);
|
||||
onAfterAction?.();
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.zone.end.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setTier2Busy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-slot="destructive-zone-card-a">
|
||||
<label
|
||||
@@ -152,6 +140,60 @@ export function LifecycleManageBody({
|
||||
/>
|
||||
</label>
|
||||
|
||||
{medication.canEndCourse === true && (
|
||||
<EndCourseControl
|
||||
medicationId={medicationId}
|
||||
medicationName={medication.name}
|
||||
onAfterAction={onAfterAction}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier 2: End the course today, behind a confirmation. Rendered only while
|
||||
* the course runs, so when End stops being offered (the course ended in
|
||||
* another tab) the open dialog and its state go away with it.
|
||||
*/
|
||||
function EndCourseControl({
|
||||
medicationId,
|
||||
medicationName,
|
||||
onAfterAction,
|
||||
}: {
|
||||
medicationId: string;
|
||||
medicationName: string;
|
||||
onAfterAction?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [endDialogOpen, setEndDialogOpen] = useState(false);
|
||||
const [tier2Busy, setTier2Busy] = useState(false);
|
||||
|
||||
async function tier2End() {
|
||||
if (tier2Busy) return;
|
||||
setTier2Busy(true);
|
||||
try {
|
||||
await apiPost(`/api/medications/${medicationId}/courses/end`);
|
||||
await invalidateMedicationReads(queryClient);
|
||||
toast.success(t("medications.detail.zone.end.toast"));
|
||||
setEndDialogOpen(false);
|
||||
onAfterAction?.();
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.zone.end.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
// A 409 means the course is no longer running (ended in another tab):
|
||||
// refetch, so the served `canEndCourse` takes the button away.
|
||||
void invalidateMedicationReads(queryClient);
|
||||
} finally {
|
||||
setTier2Busy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
@@ -211,7 +253,7 @@ export function LifecycleManageBody({
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
canEndCourse,
|
||||
canStartCourse,
|
||||
courseEndNow,
|
||||
courseFromWindow,
|
||||
courseStatusAt,
|
||||
courseStatusOn,
|
||||
dateOfDayKey,
|
||||
isInsideCourses,
|
||||
@@ -26,11 +29,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 +133,99 @@ 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("offers End only while a course runs", () => {
|
||||
expect(canEndCourse("CURRENT")).toBe(true);
|
||||
expect(canEndCourse("ENDED")).toBe(false);
|
||||
expect(canEndCourse("UPCOMING")).toBe(false);
|
||||
// No dates at all is running.
|
||||
expect(
|
||||
canEndCourse(
|
||||
courseStatusAt({ startsOn: null, endsOn: null }, ENDED_AT, "UTC"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ends on the person's own day, at the moment itself", () => {
|
||||
const late = new Date("2026-06-11T23:30:00.000Z");
|
||||
expect(courseEndNow(late, "Europe/Warsaw")).toEqual({
|
||||
endsOn: d("2026-06-12"),
|
||||
endedAt: late,
|
||||
});
|
||||
expect(courseEndNow(late, "America/New_York").endsOn).toEqual(
|
||||
d("2026-06-11"),
|
||||
);
|
||||
});
|
||||
|
||||
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,21 @@ 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,
|
||||
canEndCourse: 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,
|
||||
canEndCourse: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("is upcoming until local midnight of startsOn", () => {
|
||||
@@ -56,10 +65,12 @@ describe.each(ZONES)("course window in %s", (tz) => {
|
||||
expect(resolveIntakeActionability(med, at(tz, 24, 23, 59), tz)).toEqual({
|
||||
courseStatus: "UPCOMING",
|
||||
intakeActionable: false,
|
||||
canEndCourse: false,
|
||||
});
|
||||
expect(resolveIntakeActionability(med, at(tz, 25, 0, 0), tz)).toEqual({
|
||||
courseStatus: "CURRENT",
|
||||
intakeActionable: true,
|
||||
canEndCourse: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -80,7 +91,11 @@ 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,
|
||||
canEndCourse: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a record-only medication inside its course", () => {
|
||||
@@ -89,7 +104,11 @@ describe("resolveIntakeActionability", () => {
|
||||
now,
|
||||
tz,
|
||||
);
|
||||
expect(r).toEqual({ courseStatus: "CURRENT", intakeActionable: false });
|
||||
expect(r).toEqual({
|
||||
courseStatus: "CURRENT",
|
||||
intakeActionable: false,
|
||||
canEndCourse: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports ENDED for a course that is also paused", () => {
|
||||
@@ -101,3 +120,34 @@ 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,
|
||||
canEndCourse: true,
|
||||
});
|
||||
const after = resolveIntakeActionability(
|
||||
ended,
|
||||
new Date("2026-09-20T10:00:00.000Z"),
|
||||
"UTC",
|
||||
);
|
||||
expect(after).toEqual({
|
||||
courseStatus: "ENDED",
|
||||
intakeActionable: false,
|
||||
canEndCourse: 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,28 +74,98 @@ 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";
|
||||
}
|
||||
|
||||
/**
|
||||
* #1280 — the end "End course" writes: the person's own calendar day as the
|
||||
* course's last day (never the UTC one, a day off around midnight), and the
|
||||
* moment itself as `endedAt`.
|
||||
*/
|
||||
export function courseEndNow(
|
||||
now: Date,
|
||||
timeZone: string,
|
||||
): { endsOn: Date; endedAt: Date } {
|
||||
return { endsOn: dateOfDayKey(userDayKey(now, timeZone)), endedAt: now };
|
||||
}
|
||||
|
||||
/**
|
||||
* #1280 — whether "End course" may be pressed: a course is running now. On
|
||||
* a course that has ended, ending it again would reopen it up to today; on
|
||||
* one that has not started, it would turn into a one-day course today. The
|
||||
* medication read wire publishes the answer as `canEndCourse`, and the End
|
||||
* action refuses when it is false.
|
||||
*/
|
||||
export function canEndCourse(courseStatus: CourseStatus): boolean {
|
||||
return courseStatus === "CURRENT";
|
||||
}
|
||||
|
||||
export type CourseRefusal =
|
||||
/** `endsOn` before `startsOn`. */
|
||||
| "invalidRange"
|
||||
@@ -104,18 +208,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()),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+162
-19
@@ -9,13 +9,22 @@
|
||||
* 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 — "End course" (`endCurrentCourse`) ends the running course at the
|
||||
* moment it is pressed: `endsOn` becomes today and `endedAt` records the
|
||||
* instant, so no slot after it is due, reminded or expected. Every other
|
||||
* write that moves a course's `endsOn` clears `endedAt`: 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";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
canEndCourse,
|
||||
canStartCourse,
|
||||
courseEndNow,
|
||||
courseFromWindow,
|
||||
courseStatusAt,
|
||||
courseStatusOn,
|
||||
dateOfDayKey,
|
||||
dayKeyOfDate,
|
||||
@@ -39,7 +48,9 @@ export type CourseWriteRefusal =
|
||||
/** Clearing the window of a medication that has several courses. */
|
||||
| "windowRequired"
|
||||
/** The course or the medication is not the caller's. */
|
||||
| "notFound";
|
||||
| "notFound"
|
||||
/** #1280 — "End course" on a medication with no course running now. */
|
||||
| "notRunning";
|
||||
|
||||
export class CourseWriteError extends Error {
|
||||
constructor(readonly refusal: CourseWriteRefusal) {
|
||||
@@ -85,6 +96,12 @@ export function courseRefusalResponse(refusal: CourseWriteRefusal): {
|
||||
errorCode: "medications.course.invalidRange",
|
||||
message: "A course cannot end before it starts",
|
||||
};
|
||||
case "notRunning":
|
||||
return {
|
||||
status: 409,
|
||||
errorCode: "medications.course.notRunning",
|
||||
message: "No course is running to end",
|
||||
};
|
||||
case "windowRequired":
|
||||
return {
|
||||
status: 422,
|
||||
@@ -99,9 +116,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 +146,7 @@ async function loadOwned(tx: Tx, userId: string, medicationId: string) {
|
||||
oneShot: true,
|
||||
startsOn: true,
|
||||
endsOn: true,
|
||||
endedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
@@ -126,12 +154,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 +180,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 +280,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 +305,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 +322,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 +424,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 +447,103 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* #1280 — "End course": end the running course now.
|
||||
*
|
||||
* The course's last day becomes today on the person's clock and `endedAt`
|
||||
* records the moment. Doses earlier today stay inside the course and count;
|
||||
* a slot later today is not due, not reminded and not expected, and the
|
||||
* course reads as ENDED at once (`courseStatusAt`). A medication without a
|
||||
* course row (chronic since creation, or a window never materialised) gets
|
||||
* the course its window describes, ended now.
|
||||
*
|
||||
* The pending placeholders already minted for slots at or after that moment
|
||||
* (the today projector mints the whole day) are tombstoned, as a schedule
|
||||
* replace does, so no list shows them and the auto-miss pass never stamps
|
||||
* them a miss. An actioned row is history and stays.
|
||||
*
|
||||
* Refused with `notRunning` unless a course is running now
|
||||
* (`canEndCourse`): ending an ended course would reopen it up to today, and
|
||||
* ending one that has not started would invent a one-day course.
|
||||
*/
|
||||
export async function endCurrentCourse(
|
||||
args: WriteContext,
|
||||
): Promise<ProjectedWindow> {
|
||||
const now = args.now ?? new Date();
|
||||
const todayKey = userDayKey(now, args.timeZone);
|
||||
const { endsOn: today, endedAt } = courseEndNow(now, args.timeZone);
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await lockMedication(tx, args.medicationId);
|
||||
const { medication, courses } = await loadOwned(
|
||||
tx,
|
||||
args.userId,
|
||||
args.medicationId,
|
||||
);
|
||||
if (!canEndCourse(courseStatusAt(medication, now, args.timeZone))) {
|
||||
throw new CourseWriteError("notRunning");
|
||||
}
|
||||
const latest = sortCourses(courses).at(-1);
|
||||
if (latest) {
|
||||
const ended = { startsOn: latest.startsOn, endsOn: today, endedAt };
|
||||
refuseIfInvalid(
|
||||
courses.map((c) => (c.id === latest.id ? ended : c)),
|
||||
todayKey,
|
||||
medication.oneShot,
|
||||
);
|
||||
await tx.medicationCourse.update({
|
||||
where: { id: latest.id },
|
||||
data: { endsOn: today, endedAt },
|
||||
});
|
||||
} else {
|
||||
const implicit = windowAsCourse(
|
||||
medication.startsOn,
|
||||
today,
|
||||
undefined,
|
||||
medication.createdAt,
|
||||
args.timeZone,
|
||||
);
|
||||
refuseIfInvalid([implicit], todayKey, medication.oneShot);
|
||||
await tx.medicationCourse.create({
|
||||
data: {
|
||||
medicationId: args.medicationId,
|
||||
userId: args.userId,
|
||||
...implicit,
|
||||
endedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
await tx.medicationIntakeEvent.updateMany({
|
||||
where: {
|
||||
userId: args.userId,
|
||||
medicationId: args.medicationId,
|
||||
deletedAt: null,
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
scheduledFor: { gte: now },
|
||||
},
|
||||
data: { deletedAt: now, syncVersion: { increment: 1 } },
|
||||
});
|
||||
return project(tx, args.medicationId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +561,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 +575,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 +593,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 +621,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 +672,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,16 +22,23 @@
|
||||
*
|
||||
* 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 — `canEndCourse` rides along: "End course" is offered exactly while
|
||||
* the course is CURRENT, from the same resolution. A course ended with it is
|
||||
* ENDED from that instant (`endedAt`), so the action row and the End button
|
||||
* go away at once instead of at midnight.
|
||||
*/
|
||||
import {
|
||||
canEndCourse,
|
||||
courseStatusAt,
|
||||
type CourseStatus,
|
||||
} from "@/lib/medications/scheduling/recurrence";
|
||||
} from "@/lib/medications/course-window";
|
||||
import { isRecordOnly } from "@/lib/medications/intake-tracking";
|
||||
|
||||
export interface IntakeActionability {
|
||||
courseStatus: CourseStatus;
|
||||
intakeActionable: boolean;
|
||||
canEndCourse: boolean;
|
||||
}
|
||||
|
||||
export function resolveIntakeActionability(
|
||||
@@ -40,6 +47,7 @@ export function resolveIntakeActionability(
|
||||
trackIntake: boolean;
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
endedAt: Date | null;
|
||||
},
|
||||
now: Date,
|
||||
timeZone: string,
|
||||
@@ -51,5 +59,6 @@ export function resolveIntakeActionability(
|
||||
medication.active &&
|
||||
!isRecordOnly(medication) &&
|
||||
courseStatus === "CURRENT",
|
||||
canEndCourse: canEndCourse(courseStatus),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -312,6 +312,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"medications.course.invalid",
|
||||
"medications.course.invalidRange",
|
||||
"medications.course.notFound",
|
||||
"medications.course.notRunning",
|
||||
"medications.course.oneShot",
|
||||
"medications.course.overlap",
|
||||
"medications.course.windowRequired",
|
||||
|
||||
@@ -1178,6 +1178,45 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/courses/end": {
|
||||
post: {
|
||||
parameters: [idempotencyKeyParameter],
|
||||
tags: ["Medications"],
|
||||
summary: "End the running course now",
|
||||
description:
|
||||
"#1280 — \"End course\". No body. The running course's last day becomes today on the person's clock and the moment is stored as its `endedAt`: a dose slot later today is not due, not reminded and not expected by compliance, a dose taken earlier today still counts, and `courseStatus` reads ENDED at once. Pending placeholders for slots at or after the moment are removed (tombstoned for sync). A medication with no dates gets the course its window describes, from creation to today. An end date set through `PUT /api/medications/{id}` or the course editor keeps meaning the whole day; only this action ends a course at an instant. Refused with 409 `medications.course.notRunning` while no course is running — the rule the reads publish as `canEndCourse`. Owner-only.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
"200": {
|
||||
description: "The medication's window after ending.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
startsOn: z.iso.date().nullable(),
|
||||
endsOn: z.iso.date().nullable(),
|
||||
endedAt: isoDateTime().nullable(),
|
||||
}),
|
||||
"EndMedicationCourseResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"Medication not found (`medications.course.notFound`), or another user's.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"No course is running now (`medications.course.notRunning`): it has ended already or has not started.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/courses/{courseId}": {
|
||||
patch: {
|
||||
tags: ["Medications"],
|
||||
|
||||
@@ -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 `POST /api/medications/{id}/courses/end` 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 `POST /api/medications/{id}/courses/end` (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,18 +448,23 @@ 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 `POST /api/medications/{id}/courses/end`: then it is ENDED from that instant (`endedAt`). Read-only — computed, not stored.",
|
||||
),
|
||||
intakeActionable: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"v1.39.4 — whether the medication offers taken / skip today: true only when it is `active`, its intake is tracked (`trackIntake`) and `courseStatus` is CURRENT. Clients render the dose actions from this and do not re-derive it. Logging a past dose from the history is not gated by it. Read-only — computed, not stored.",
|
||||
),
|
||||
canEndCourse: z
|
||||
.boolean()
|
||||
.describe(
|
||||
'#1280 — whether `POST /api/medications/{id}/courses/end` may end the course now: true exactly while a course is running (`courseStatus` CURRENT; a medication with no dates is running). Clients offer "End course" from this and do not re-derive it; the action refuses with 409 `medications.course.notRunning` when it is false. Read-only — computed, not stored.',
|
||||
),
|
||||
lastTakenAt: isoDateTime()
|
||||
.nullable()
|
||||
.describe(
|
||||
@@ -506,13 +524,18 @@ 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 `POST /api/medications/{id}/courses/end`: then it is ENDED from that instant (`endedAt`). Read-only — computed, not stored.",
|
||||
),
|
||||
intakeActionable: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"v1.39.4 — whether the medication offers taken / skip today: true only when it is `active`, its intake is tracked (`trackIntake`) and `courseStatus` is CURRENT. Clients render the dose actions from this and do not re-derive it. Logging a past dose from the history is not gated by it. Read-only — computed, not stored.",
|
||||
),
|
||||
canEndCourse: z
|
||||
.boolean()
|
||||
.describe(
|
||||
'#1280 — whether `POST /api/medications/{id}/courses/end` may end the course now: true exactly while a course is running (`courseStatus` CURRENT; a medication with no dates is running). Clients offer "End course" from this and do not re-derive it; the action refuses with 409 `medications.course.notRunning` when it is false. Read-only — computed, not stored.',
|
||||
),
|
||||
stockUnitsRemaining: z
|
||||
.number()
|
||||
.nullable()
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -708,6 +708,37 @@ export async function seedContractAccount(
|
||||
});
|
||||
if (ingested.kind === "stored") ids.document = ingested.document.id;
|
||||
else failures.push(`document: ingest answered ${ingested.kind}`);
|
||||
// A second document, deleted the way the delete route tombstones one, so
|
||||
// Recently deleted answers with a row.
|
||||
const deleted = await ingestDocument({
|
||||
userId: CONTRACT_USER_ID,
|
||||
scoped: false,
|
||||
ipAddress: null,
|
||||
bytes: Buffer.concat([pdf, Buffer.from("% deleted\n")]),
|
||||
filename: "deleted.pdf",
|
||||
title: "Deleted letter",
|
||||
kind: null,
|
||||
documentDate: dayKey(daysAgo(12)),
|
||||
episodeIds: [],
|
||||
encounterIds: [],
|
||||
sourceSystem: null,
|
||||
sourceId: null,
|
||||
sourceInstance: null,
|
||||
aiDeferred: true,
|
||||
sourceKeyChecked: false,
|
||||
limits: { maxFileBytes: 10 * 1024 * 1024, quotaBytes: 100 * 1024 * 1024 },
|
||||
documentAi: async () => ({
|
||||
available: false,
|
||||
reason: "no_provider",
|
||||
onDeviceAllowed: false,
|
||||
}),
|
||||
});
|
||||
if (deleted.kind === "stored") {
|
||||
await prisma.inboundDocument.update({
|
||||
where: { id: deleted.document.id },
|
||||
data: { deletedAt: daysAgo(2) },
|
||||
});
|
||||
} else failures.push(`deleted document: ingest answered ${deleted.kind}`);
|
||||
// A chat about that document, as the document sheet starts one.
|
||||
if (ids.document) {
|
||||
const documentChat = await createConversation({
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
/**
|
||||
* "End course" (#1280) against a real Postgres, through the shipped routes,
|
||||
* the reminder worker, the today projector and the auto-miss pass.
|
||||
*
|
||||
* What the file pins:
|
||||
* - the medication's PUT takes `endsOn` as a date (the request the End
|
||||
* button used to send as a full instant was refused with 422), and an
|
||||
* end date set that way keeps meaning the whole day;
|
||||
* - `POST /courses/end` ends the running course at that moment: the last
|
||||
* day is today on the person's clock and `endedAt` the instant, and the
|
||||
* reads serve it ENDED with `canEndCourse` false at once;
|
||||
* - a dose slot later that day is not due (no placeholder on the today
|
||||
* list), not reminded and never missed, while a dose taken earlier that
|
||||
* day still counts in compliance;
|
||||
* - `canEndCourse` is true only while a course runs, and the action refuses
|
||||
* an ended or upcoming course with 409;
|
||||
* - an edit that moves the end clears the instant.
|
||||
*
|
||||
* The person lives in Europe/Berlin (UTC+2 in June). The medication doses at
|
||||
* 08:00 and 20:00 local, 06:00Z and 18:00Z.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/notifications/dispatcher", () => ({
|
||||
dispatchNotification: vi.fn().mockResolvedValue({
|
||||
dispatched: true,
|
||||
channelsAttempted: 1,
|
||||
channelsSucceeded: 1,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/notifications/medication-intake-sync", () => ({
|
||||
queueMedicationIntakeSync: vi.fn(),
|
||||
}));
|
||||
|
||||
const { dispatchNotification } = await import("@/lib/notifications/dispatcher");
|
||||
const { runIntakeAutoSkipPass } = await import("@/lib/jobs/intake-auto-skip");
|
||||
const { handleReminderCheck } =
|
||||
await import("@/lib/jobs/reminder/medication-reminder-check");
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
const MORNING = "2026-06-10T06:00:00.000Z";
|
||||
const EVENING = "2026-06-10T18:00:00.000Z";
|
||||
const ENDED_AT = "2026-06-10T10:00:00.000Z";
|
||||
|
||||
type Handler = (
|
||||
request: NextRequest,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
context: { params: Promise<any> },
|
||||
) => Promise<Response>;
|
||||
|
||||
function at(iso: string): void {
|
||||
vi.setSystemTime(new Date(iso));
|
||||
}
|
||||
|
||||
function req(method: string, path: string, body?: unknown) {
|
||||
return new NextRequest(`http://localhost${path}`, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
}
|
||||
|
||||
async function call(
|
||||
modulePath: string,
|
||||
method: "GET" | "POST" | "PUT" | "PATCH",
|
||||
path: string,
|
||||
params: Record<string, string>,
|
||||
body?: unknown,
|
||||
) {
|
||||
const route = (await import(/* @vite-ignore */ modulePath)) as Record<
|
||||
string,
|
||||
Handler
|
||||
>;
|
||||
const res = await route[method](req(method, path, body), {
|
||||
params: Promise.resolve(params),
|
||||
});
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
const endCourse = (id: string) =>
|
||||
call(
|
||||
"@/app/api/medications/[id]/courses/end/route",
|
||||
"POST",
|
||||
`/api/medications/${id}/courses/end`,
|
||||
{ id },
|
||||
);
|
||||
const putMedication = (id: string, body: unknown) =>
|
||||
call(
|
||||
"@/app/api/medications/[id]/route",
|
||||
"PUT",
|
||||
`/api/medications/${id}`,
|
||||
{ id },
|
||||
body,
|
||||
);
|
||||
const detail = async (id: string) =>
|
||||
(
|
||||
await call(
|
||||
"@/app/api/medications/[id]/route",
|
||||
"GET",
|
||||
`/api/medications/${id}`,
|
||||
{ id },
|
||||
)
|
||||
).body.data;
|
||||
|
||||
async function makeUser(): Promise<string> {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "course-end",
|
||||
email: "course-end@example.test",
|
||||
timezone: TZ,
|
||||
locale: "en",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
expiresAt: new Date("2030-01-01T00:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return user.id;
|
||||
}
|
||||
|
||||
/** Twice a day, 08:00 and 20:00, created early on the 10th. */
|
||||
async function seedTwiceDaily(
|
||||
userId: string,
|
||||
window: { startsOn?: Date; endsOn?: Date } = {},
|
||||
): Promise<string> {
|
||||
const medication = await getPrismaClient().medication.create({
|
||||
data: {
|
||||
userId,
|
||||
name: "Amoxicillin",
|
||||
dose: "500 mg",
|
||||
createdAt: new Date("2026-06-10T03:00:00.000Z"),
|
||||
notificationsEnabled: true,
|
||||
...window,
|
||||
schedules: {
|
||||
create: {
|
||||
windowStart: "08:00",
|
||||
windowEnd: "08:00",
|
||||
timesOfDay: ["08:00", "20:00"],
|
||||
rrule: "FREQ=DAILY",
|
||||
scheduleType: "SCHEDULED",
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return medication.id;
|
||||
}
|
||||
|
||||
async function takeMorningDose(userId: string, medicationId: string) {
|
||||
await getPrismaClient().medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId,
|
||||
medicationId,
|
||||
scheduledFor: new Date(MORNING),
|
||||
takenAt: new Date("2026-06-10T06:10:00.000Z"),
|
||||
source: "WEB",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function liveRowsOn(medicationId: string, scheduledFor: string) {
|
||||
return getPrismaClient().medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
medicationId,
|
||||
deletedAt: null,
|
||||
scheduledFor: new Date(scheduledFor),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function dispatchedFor(medicationId: string): number {
|
||||
return vi
|
||||
.mocked(dispatchNotification)
|
||||
.mock.calls.filter(
|
||||
([arg]) =>
|
||||
(arg as { metadata?: { medicationId?: string } }).metadata
|
||||
?.medicationId === medicationId,
|
||||
).length;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.mocked(dispatchNotification).mockClear();
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("the medication PUT takes the end as a date (#1280)", () => {
|
||||
it("answers 200 for a date and 422 for the full instant the button used to send", async () => {
|
||||
at(ENDED_AT);
|
||||
const userId = await makeUser();
|
||||
const id = await seedTwiceDaily(userId);
|
||||
|
||||
const instant = await putMedication(id, { endsOn: ENDED_AT });
|
||||
expect(instant.status).toBe(422);
|
||||
|
||||
const date = await putMedication(id, { endsOn: "2026-06-10" });
|
||||
expect(date.status, JSON.stringify(date.body)).toBe(200);
|
||||
// An end date written this way is the whole day: still running, still
|
||||
// offering End, no instant recorded.
|
||||
const read = await detail(id);
|
||||
expect(read.courseStatus).toBe("CURRENT");
|
||||
expect(read.canEndCourse).toBe(true);
|
||||
expect(read.endedAt).toBeNull();
|
||||
expect(read.courses[0]).toMatchObject({
|
||||
endsOn: "2026-06-10",
|
||||
endedAt: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /courses/end ends the course now", () => {
|
||||
it("records today and the instant, and the reads serve it ended at once", async () => {
|
||||
at(ENDED_AT);
|
||||
const userId = await makeUser();
|
||||
const id = await seedTwiceDaily(userId);
|
||||
expect((await detail(id)).canEndCourse).toBe(true);
|
||||
|
||||
const res = await endCourse(id);
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body.data).toEqual({
|
||||
startsOn: "2026-06-10",
|
||||
endsOn: "2026-06-10",
|
||||
endedAt: ENDED_AT,
|
||||
});
|
||||
|
||||
const course = await getPrismaClient().medicationCourse.findFirstOrThrow({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
expect(course.endedAt?.toISOString()).toBe(ENDED_AT);
|
||||
const read = await detail(id);
|
||||
expect(read).toMatchObject({
|
||||
endedAt: ENDED_AT,
|
||||
courseStatus: "ENDED",
|
||||
canEndCourse: false,
|
||||
intakeActionable: false,
|
||||
remindersActive: false,
|
||||
canStartCourse: true,
|
||||
});
|
||||
expect(read.courses[0]).toMatchObject({
|
||||
status: "ENDED",
|
||||
endsOn: "2026-06-10",
|
||||
endedAt: ENDED_AT,
|
||||
});
|
||||
|
||||
// A second press finds nothing running.
|
||||
const again = await endCourse(id);
|
||||
expect(again.status).toBe(409);
|
||||
expect(again.body.meta.errorCode).toBe("medications.course.notRunning");
|
||||
});
|
||||
|
||||
it("drops the evening dose: not listed, not reminded, never missed", async () => {
|
||||
at("2026-06-10T09:00:00.000Z");
|
||||
const userId = await makeUser();
|
||||
const id = await seedTwiceDaily(userId);
|
||||
// The today list mints both placeholders before the course is ended.
|
||||
const today = () =>
|
||||
call(
|
||||
"@/app/api/medications/intake/route",
|
||||
"GET",
|
||||
"/api/medications/intake?scope=today",
|
||||
{},
|
||||
);
|
||||
expect((await today()).status).toBe(200);
|
||||
expect(await liveRowsOn(id, EVENING)).toHaveLength(1);
|
||||
|
||||
at(ENDED_AT);
|
||||
expect((await endCourse(id)).status).toBe(200);
|
||||
expect(await liveRowsOn(id, EVENING)).toHaveLength(0);
|
||||
|
||||
// The today list does not bring it back.
|
||||
at("2026-06-10T12:00:00.000Z");
|
||||
expect((await today()).status).toBe(200);
|
||||
expect(await liveRowsOn(id, EVENING)).toHaveLength(0);
|
||||
|
||||
// Through the evening window and its follow-ups: no reminder, no
|
||||
// missed-dose placeholder.
|
||||
for (const tick of [
|
||||
"2026-06-10T18:00:00.000Z",
|
||||
"2026-06-10T18:45:00.000Z",
|
||||
"2026-06-10T19:30:00.000Z",
|
||||
"2026-06-10T21:00:00.000Z",
|
||||
]) {
|
||||
at(tick);
|
||||
await handleReminderCheck([]);
|
||||
}
|
||||
expect(dispatchedFor(id)).toBe(0);
|
||||
expect(await liveRowsOn(id, EVENING)).toHaveLength(0);
|
||||
|
||||
// Two days on, the auto-miss pass stamps the morning dose, which fell
|
||||
// due while the course ran and was never taken, and nothing after the
|
||||
// end.
|
||||
await runIntakeAutoSkipPass(getPrismaClient(), {
|
||||
nowMs: Date.parse("2026-06-12T12:00:00.000Z"),
|
||||
});
|
||||
const missed = await getPrismaClient().medicationIntakeEvent.findMany({
|
||||
where: { medicationId: id, autoMissed: true },
|
||||
select: { scheduledFor: true },
|
||||
});
|
||||
expect(missed.map((r) => r.scheduledFor.toISOString())).toEqual([MORNING]);
|
||||
});
|
||||
|
||||
it("lists no evening dose when the day's list is first read after the end", async () => {
|
||||
at(ENDED_AT);
|
||||
const userId = await makeUser();
|
||||
const id = await seedTwiceDaily(userId);
|
||||
expect((await endCourse(id)).status).toBe(200);
|
||||
const today = await call(
|
||||
"@/app/api/medications/intake/route",
|
||||
"GET",
|
||||
"/api/medications/intake?scope=today",
|
||||
{},
|
||||
);
|
||||
expect(today.status).toBe(200);
|
||||
expect(await liveRowsOn(id, MORNING)).toHaveLength(1);
|
||||
expect(await liveRowsOn(id, EVENING)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps the morning dose taken before the end in compliance", async () => {
|
||||
at(ENDED_AT);
|
||||
const userId = await makeUser();
|
||||
const id = await seedTwiceDaily(userId);
|
||||
await takeMorningDose(userId, id);
|
||||
expect((await endCourse(id)).status).toBe(200);
|
||||
|
||||
at("2026-06-11T08:00:00.000Z");
|
||||
const compliance = await call(
|
||||
"@/app/api/medications/[id]/compliance/route",
|
||||
"GET",
|
||||
`/api/medications/${id}/compliance`,
|
||||
{ id },
|
||||
);
|
||||
expect(compliance.status).toBe(200);
|
||||
expect(compliance.body.data.compliance7).toMatchObject({
|
||||
totalExpected: 1,
|
||||
taken: 1,
|
||||
missed: 0,
|
||||
rate: 100,
|
||||
});
|
||||
|
||||
const history = await call(
|
||||
"@/app/api/medications/[id]/dose-history/route",
|
||||
"GET",
|
||||
`/api/medications/${id}/dose-history?from=2026-06-10T00:00:00.000Z`,
|
||||
{ id },
|
||||
);
|
||||
expect(history.status).toBe(200);
|
||||
const slots = (
|
||||
history.body.data.rows as Array<{
|
||||
kind: string;
|
||||
at: string;
|
||||
status: string;
|
||||
}>
|
||||
).filter((r) => r.kind === "slot");
|
||||
expect(slots.map((r) => [r.at, r.status])).toEqual([
|
||||
[MORNING, "taken_on_time"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("ends a medication without dates from its creation day", async () => {
|
||||
at(ENDED_AT);
|
||||
const userId = await makeUser();
|
||||
const id = await seedTwiceDaily(userId);
|
||||
expect(
|
||||
await getPrismaClient().medicationCourse.count({
|
||||
where: { medicationId: id },
|
||||
}),
|
||||
).toBe(0);
|
||||
expect((await endCourse(id)).status).toBe(200);
|
||||
const row = await getPrismaClient().medication.findUniqueOrThrow({
|
||||
where: { id },
|
||||
select: { startsOn: true, endsOn: true, endedAt: true },
|
||||
});
|
||||
expect(row.startsOn?.toISOString().slice(0, 10)).toBe("2026-06-10");
|
||||
expect(row.endsOn?.toISOString().slice(0, 10)).toBe("2026-06-10");
|
||||
expect(row.endedAt?.toISOString()).toBe(ENDED_AT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canEndCourse follows the course", () => {
|
||||
it.each([
|
||||
["running (no dates)", {}, true],
|
||||
[
|
||||
"running (dated)",
|
||||
{ startsOn: new Date("2026-06-01T00:00:00.000Z") },
|
||||
true,
|
||||
],
|
||||
["upcoming", { startsOn: new Date("2026-06-20T00:00:00.000Z") }, false],
|
||||
[
|
||||
"ended",
|
||||
{
|
||||
startsOn: new Date("2026-05-01T00:00:00.000Z"),
|
||||
endsOn: new Date("2026-05-31T00:00:00.000Z"),
|
||||
},
|
||||
false,
|
||||
],
|
||||
] as const)("%s → %s", async (_label, window, expected) => {
|
||||
at(ENDED_AT);
|
||||
const userId = await makeUser();
|
||||
const id = await seedTwiceDaily(userId, window);
|
||||
expect((await detail(id)).canEndCourse).toBe(expected);
|
||||
const list = await call(
|
||||
"@/app/api/medications/route",
|
||||
"GET",
|
||||
"/api/medications",
|
||||
{},
|
||||
);
|
||||
const entry = (
|
||||
list.body.data as Array<{ id: string; canEndCourse: boolean }>
|
||||
).find((m) => m.id === id);
|
||||
expect(entry?.canEndCourse).toBe(expected);
|
||||
|
||||
const res = await endCourse(id);
|
||||
expect(res.status).toBe(expected ? 200 : 409);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an edit that moves the end clears the instant", () => {
|
||||
it("reopens the course to the end of its new last day", async () => {
|
||||
at(ENDED_AT);
|
||||
const userId = await makeUser();
|
||||
const id = await seedTwiceDaily(userId);
|
||||
expect((await endCourse(id)).status).toBe(200);
|
||||
const course = await getPrismaClient().medicationCourse.findFirstOrThrow({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
|
||||
// Same last day, only the note: the instant stays.
|
||||
const note = await call(
|
||||
"@/app/api/medications/[id]/courses/[courseId]/route",
|
||||
"PATCH",
|
||||
`/api/medications/${id}/courses/${course.id}`,
|
||||
{ id, courseId: course.id },
|
||||
{ note: "Stopped after the morning dose" },
|
||||
);
|
||||
expect(note.status, JSON.stringify(note.body)).toBe(200);
|
||||
expect((await detail(id)).endedAt).toBe(ENDED_AT);
|
||||
|
||||
// A later last day: a whole-day end again, running once more.
|
||||
const moved = await call(
|
||||
"@/app/api/medications/[id]/courses/[courseId]/route",
|
||||
"PATCH",
|
||||
`/api/medications/${id}/courses/${course.id}`,
|
||||
{ id, courseId: course.id },
|
||||
{ endsOn: "2026-06-12" },
|
||||
);
|
||||
expect(moved.status, JSON.stringify(moved.body)).toBe(200);
|
||||
const read = await detail(id);
|
||||
expect(read).toMatchObject({
|
||||
endedAt: null,
|
||||
courseStatus: "CURRENT",
|
||||
canEndCourse: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -68,6 +69,7 @@
|
||||
"nextDueOverdue": false,
|
||||
"courseStatus": "CURRENT",
|
||||
"intakeActionable": true,
|
||||
"canEndCourse": true,
|
||||
"stockUnitsRemaining": 3,
|
||||
"stockDosesRemaining": 3,
|
||||
"runwayDays": 21,
|
||||
@@ -116,6 +118,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": true,
|
||||
"trackIntake": true,
|
||||
@@ -150,6 +153,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -184,6 +188,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -218,6 +223,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -252,6 +258,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": false,
|
||||
@@ -286,6 +293,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -320,6 +328,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -492,6 +501,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-08-01T00:00:00.000Z",
|
||||
"endsOn": "2026-08-31T00:00:00.000Z",
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
@@ -502,6 +512,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -89,6 +90,7 @@
|
||||
"nextDueOverdue": false,
|
||||
"courseStatus": "CURRENT",
|
||||
"intakeActionable": true,
|
||||
"canEndCourse": true,
|
||||
"stockUnitsRemaining": null,
|
||||
"stockDosesRemaining": null,
|
||||
"runwayDays": null,
|
||||
@@ -127,6 +129,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": true,
|
||||
"trackIntake": true,
|
||||
@@ -161,6 +164,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -195,6 +199,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -229,6 +234,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -263,6 +269,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": false,
|
||||
@@ -297,6 +304,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -331,6 +339,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -503,6 +512,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-08-01T00:00:00.000Z",
|
||||
"endsOn": "2026-08-31T00:00:00.000Z",
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
@@ -513,6 +523,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
|
||||
+11
@@ -15,6 +15,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": false,
|
||||
@@ -69,6 +70,7 @@
|
||||
"nextDueOverdue": false,
|
||||
"courseStatus": "CURRENT",
|
||||
"intakeActionable": false,
|
||||
"canEndCourse": true,
|
||||
"stockUnitsRemaining": null,
|
||||
"stockDosesRemaining": null,
|
||||
"runwayDays": null,
|
||||
@@ -107,6 +109,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": true,
|
||||
"trackIntake": true,
|
||||
@@ -141,6 +144,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -175,6 +179,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -209,6 +214,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -243,6 +249,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": false,
|
||||
@@ -277,6 +284,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -311,6 +319,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -483,6 +492,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-08-01T00:00:00.000Z",
|
||||
"endsOn": "2026-08-31T00:00:00.000Z",
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
@@ -493,6 +503,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": true,
|
||||
"trackIntake": true,
|
||||
@@ -68,6 +69,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -102,6 +104,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -136,6 +139,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -170,6 +174,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": false,
|
||||
@@ -204,6 +209,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -238,6 +244,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -410,6 +417,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-08-01T00:00:00.000Z",
|
||||
"endsOn": "2026-08-31T00:00:00.000Z",
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
@@ -420,6 +428,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -68,6 +69,7 @@
|
||||
"nextDueOverdue": false,
|
||||
"courseStatus": "CURRENT",
|
||||
"intakeActionable": true,
|
||||
"canEndCourse": true,
|
||||
"stockUnitsRemaining": 3,
|
||||
"stockDosesRemaining": 3,
|
||||
"runwayDays": 21,
|
||||
@@ -116,6 +118,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": true,
|
||||
"trackIntake": true,
|
||||
@@ -150,6 +153,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -184,6 +188,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -218,6 +223,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -252,6 +258,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": false,
|
||||
@@ -286,6 +293,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -320,6 +328,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -492,6 +501,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-08-01T00:00:00.000Z",
|
||||
"endsOn": "2026-08-31T00:00:00.000Z",
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
@@ -502,6 +512,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
|
||||
+9
@@ -37,6 +37,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": true,
|
||||
"trackIntake": true,
|
||||
@@ -71,6 +72,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -105,6 +107,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -139,6 +142,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -173,6 +177,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": false,
|
||||
@@ -207,6 +212,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -241,6 +247,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -413,6 +420,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-08-01T00:00:00.000Z",
|
||||
"endsOn": "2026-08-31T00:00:00.000Z",
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
@@ -423,6 +431,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
|
||||
+11
@@ -15,6 +15,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -89,6 +90,7 @@
|
||||
"nextDueOverdue": false,
|
||||
"courseStatus": "CURRENT",
|
||||
"intakeActionable": true,
|
||||
"canEndCourse": true,
|
||||
"stockUnitsRemaining": null,
|
||||
"stockDosesRemaining": null,
|
||||
"runwayDays": null,
|
||||
@@ -127,6 +129,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": true,
|
||||
"trackIntake": true,
|
||||
@@ -161,6 +164,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -195,6 +199,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -229,6 +234,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -263,6 +269,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": false,
|
||||
@@ -297,6 +304,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -331,6 +339,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -503,6 +512,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-08-01T00:00:00.000Z",
|
||||
"endsOn": "2026-08-31T00:00:00.000Z",
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
@@ -513,6 +523,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
|
||||
+11
@@ -15,6 +15,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -95,6 +96,7 @@
|
||||
"nextDueOverdue": false,
|
||||
"courseStatus": "CURRENT",
|
||||
"intakeActionable": true,
|
||||
"canEndCourse": true,
|
||||
"stockUnitsRemaining": null,
|
||||
"stockDosesRemaining": null,
|
||||
"runwayDays": null,
|
||||
@@ -135,6 +137,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": true,
|
||||
"trackIntake": true,
|
||||
@@ -169,6 +172,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -203,6 +207,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -237,6 +242,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -271,6 +277,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": false,
|
||||
@@ -305,6 +312,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -339,6 +347,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -563,6 +572,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-08-01T00:00:00.000Z",
|
||||
"endsOn": "2026-08-31T00:00:00.000Z",
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
@@ -573,6 +583,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
|
||||
+11
@@ -15,6 +15,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -95,6 +96,7 @@
|
||||
"nextDueOverdue": false,
|
||||
"courseStatus": "CURRENT",
|
||||
"intakeActionable": true,
|
||||
"canEndCourse": true,
|
||||
"stockUnitsRemaining": null,
|
||||
"stockDosesRemaining": null,
|
||||
"runwayDays": null,
|
||||
@@ -133,6 +135,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": true,
|
||||
"trackIntake": true,
|
||||
@@ -167,6 +170,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -201,6 +205,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -235,6 +240,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -269,6 +275,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": false,
|
||||
@@ -303,6 +310,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -337,6 +345,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -561,6 +570,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-08-01T00:00:00.000Z",
|
||||
"endsOn": "2026-08-31T00:00:00.000Z",
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
@@ -571,6 +581,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
|
||||
+11
@@ -15,6 +15,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -95,6 +96,7 @@
|
||||
"nextDueOverdue": false,
|
||||
"courseStatus": "CURRENT",
|
||||
"intakeActionable": true,
|
||||
"canEndCourse": true,
|
||||
"stockUnitsRemaining": null,
|
||||
"stockDosesRemaining": null,
|
||||
"runwayDays": null,
|
||||
@@ -133,6 +135,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": true,
|
||||
"trackIntake": true,
|
||||
@@ -167,6 +170,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -201,6 +205,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -235,6 +240,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -269,6 +275,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": false,
|
||||
@@ -303,6 +310,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -337,6 +345,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -561,6 +570,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-08-01T00:00:00.000Z",
|
||||
"endsOn": "2026-08-31T00:00:00.000Z",
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
@@ -571,6 +581,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -95,6 +96,7 @@
|
||||
"nextDueOverdue": false,
|
||||
"courseStatus": "CURRENT",
|
||||
"intakeActionable": false,
|
||||
"canEndCourse": true,
|
||||
"stockUnitsRemaining": null,
|
||||
"stockDosesRemaining": null,
|
||||
"runwayDays": null,
|
||||
@@ -133,6 +135,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": true,
|
||||
"trackIntake": true,
|
||||
@@ -167,6 +170,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -201,6 +205,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -235,6 +240,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -269,6 +275,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": false,
|
||||
@@ -303,6 +310,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -337,6 +345,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -570,6 +579,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-08-01T00:00:00.000Z",
|
||||
"endsOn": "2026-08-31T00:00:00.000Z",
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
@@ -580,6 +590,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -95,6 +96,7 @@
|
||||
"nextDueOverdue": false,
|
||||
"courseStatus": "CURRENT",
|
||||
"intakeActionable": true,
|
||||
"canEndCourse": true,
|
||||
"stockUnitsRemaining": null,
|
||||
"stockDosesRemaining": null,
|
||||
"runwayDays": null,
|
||||
@@ -133,6 +135,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": true,
|
||||
"trackIntake": true,
|
||||
@@ -167,6 +170,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -201,6 +205,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -235,6 +240,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -269,6 +275,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": false,
|
||||
@@ -303,6 +310,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -337,6 +345,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -570,6 +579,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-08-01T00:00:00.000Z",
|
||||
"endsOn": "2026-08-31T00:00:00.000Z",
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
@@ -580,6 +590,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
|
||||
+11
@@ -15,6 +15,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -95,6 +96,7 @@
|
||||
"nextDueOverdue": false,
|
||||
"courseStatus": "CURRENT",
|
||||
"intakeActionable": true,
|
||||
"canEndCourse": true,
|
||||
"stockUnitsRemaining": null,
|
||||
"stockDosesRemaining": null,
|
||||
"runwayDays": null,
|
||||
@@ -133,6 +135,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": true,
|
||||
"trackIntake": true,
|
||||
@@ -167,6 +170,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -201,6 +205,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -235,6 +240,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -269,6 +275,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": false,
|
||||
@@ -303,6 +310,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -337,6 +345,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -570,6 +579,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-08-01T00:00:00.000Z",
|
||||
"endsOn": "2026-08-31T00:00:00.000Z",
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
@@ -580,6 +590,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
|
||||
+11
@@ -15,6 +15,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -95,6 +96,7 @@
|
||||
"nextDueOverdue": false,
|
||||
"courseStatus": "CURRENT",
|
||||
"intakeActionable": true,
|
||||
"canEndCourse": true,
|
||||
"stockUnitsRemaining": null,
|
||||
"stockDosesRemaining": null,
|
||||
"runwayDays": null,
|
||||
@@ -133,6 +135,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": true,
|
||||
"trackIntake": true,
|
||||
@@ -167,6 +170,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -201,6 +205,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -235,6 +240,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -269,6 +275,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": false,
|
||||
@@ -303,6 +310,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -337,6 +345,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -570,6 +579,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-08-01T00:00:00.000Z",
|
||||
"endsOn": "2026-08-31T00:00:00.000Z",
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
@@ -580,6 +590,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
|
||||
+11
@@ -15,6 +15,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -95,6 +96,7 @@
|
||||
"nextDueOverdue": false,
|
||||
"courseStatus": "CURRENT",
|
||||
"intakeActionable": true,
|
||||
"canEndCourse": true,
|
||||
"stockUnitsRemaining": null,
|
||||
"stockDosesRemaining": null,
|
||||
"runwayDays": null,
|
||||
@@ -135,6 +137,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": true,
|
||||
"trackIntake": true,
|
||||
@@ -169,6 +172,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -203,6 +207,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -237,6 +242,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -271,6 +277,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": false,
|
||||
@@ -305,6 +312,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -339,6 +347,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -572,6 +581,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-08-01T00:00:00.000Z",
|
||||
"endsOn": "2026-08-31T00:00:00.000Z",
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
@@ -582,6 +592,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
|
||||
+11
@@ -15,6 +15,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -95,6 +96,7 @@
|
||||
"nextDueOverdue": false,
|
||||
"courseStatus": "CURRENT",
|
||||
"intakeActionable": true,
|
||||
"canEndCourse": true,
|
||||
"stockUnitsRemaining": null,
|
||||
"stockDosesRemaining": null,
|
||||
"runwayDays": null,
|
||||
@@ -133,6 +135,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": true,
|
||||
"trackIntake": true,
|
||||
@@ -167,6 +170,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -201,6 +205,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -235,6 +240,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -269,6 +275,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": false,
|
||||
@@ -303,6 +310,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -337,6 +345,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -570,6 +579,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-08-01T00:00:00.000Z",
|
||||
"endsOn": "2026-08-31T00:00:00.000Z",
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
@@ -580,6 +590,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
|
||||
+9
@@ -34,6 +34,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": true,
|
||||
"trackIntake": true,
|
||||
@@ -68,6 +69,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -102,6 +104,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -136,6 +139,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -170,6 +174,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": false,
|
||||
@@ -204,6 +209,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -238,6 +244,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -471,6 +478,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-08-01T00:00:00.000Z",
|
||||
"endsOn": "2026-08-31T00:00:00.000Z",
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
@@ -481,6 +489,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
|
||||
+11
@@ -15,6 +15,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": false,
|
||||
@@ -69,6 +70,7 @@
|
||||
"nextDueOverdue": false,
|
||||
"courseStatus": "CURRENT",
|
||||
"intakeActionable": false,
|
||||
"canEndCourse": true,
|
||||
"stockUnitsRemaining": null,
|
||||
"stockDosesRemaining": null,
|
||||
"runwayDays": null,
|
||||
@@ -111,6 +113,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": true,
|
||||
"trackIntake": true,
|
||||
@@ -145,6 +148,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -179,6 +183,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -213,6 +218,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -247,6 +253,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": false,
|
||||
@@ -281,6 +288,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -315,6 +323,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -548,6 +557,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-08-01T00:00:00.000Z",
|
||||
"endsOn": "2026-08-31T00:00:00.000Z",
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
@@ -558,6 +568,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
|
||||
+11
@@ -15,6 +15,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -68,6 +69,7 @@
|
||||
"nextDueOverdue": false,
|
||||
"courseStatus": "CURRENT",
|
||||
"intakeActionable": true,
|
||||
"canEndCourse": true,
|
||||
"stockUnitsRemaining": null,
|
||||
"stockDosesRemaining": null,
|
||||
"runwayDays": null,
|
||||
@@ -109,6 +111,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": true,
|
||||
"trackIntake": true,
|
||||
@@ -143,6 +146,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -177,6 +181,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -211,6 +216,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -245,6 +251,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -279,6 +286,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -313,6 +321,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -556,6 +565,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-08-01T00:00:00.000Z",
|
||||
"endsOn": "2026-08-31T00:00:00.000Z",
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
@@ -566,6 +576,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
|
||||
+11
@@ -15,6 +15,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": false,
|
||||
@@ -69,6 +70,7 @@
|
||||
"nextDueOverdue": false,
|
||||
"courseStatus": "CURRENT",
|
||||
"intakeActionable": false,
|
||||
"canEndCourse": true,
|
||||
"stockUnitsRemaining": null,
|
||||
"stockDosesRemaining": null,
|
||||
"runwayDays": null,
|
||||
@@ -110,6 +112,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": true,
|
||||
"trackIntake": true,
|
||||
@@ -144,6 +147,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -178,6 +182,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -212,6 +217,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -246,6 +252,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": false,
|
||||
@@ -280,6 +287,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -314,6 +322,7 @@
|
||||
"snoozedUntil": null,
|
||||
"startsOn": null,
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"oneShot": false,
|
||||
"asNeeded": false,
|
||||
"trackIntake": true,
|
||||
@@ -585,6 +594,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-08-01T00:00:00.000Z",
|
||||
"endsOn": "2026-08-31T00:00:00.000Z",
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
@@ -595,6 +605,7 @@
|
||||
"userId": "<id:2>",
|
||||
"startsOn": "2026-10-01T00:00:00.000Z",
|
||||
"endsOn": null,
|
||||
"endedAt": null,
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
|
||||
Loaded 100 of 236 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user