From 8295dfe9b662ed8a87c0ec27d6599ad492de9a83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 13:49:21 +0200 Subject: [PATCH 1/4] fix(medications): stop offering a dose once the course has ended A medication whose end date had passed still showed taken and skip in the table and on its card, because the action row only looked at whether the medication was active. The schedule engine already stops minting doses after the end date, so reminders, the doses card and the today list were quiet, but the medication itself kept inviting a dose that is no longer part of the course. The server now resolves one rule and publishes it on the list and detail reads: `courseStatus` (UPCOMING, CURRENT or ENDED, in calendar days on the user's clock, both ends inclusive) and `intakeActionable` (active, intake tracked, course includes today). The table and both card variants render their actions from it, the take-all flow skips such a medication, the card and table name an ended course with an "Ended" badge, and no overdue escalation is raised for it. A course that ends today keeps its actions for the whole local day. The detail header's "Ended" status now follows the same field. It compared the end date's UTC midnight with the clock and so called the whole last course day ended. Logging a past dose from the history is unchanged. --- docs/api/openapi.yaml | 32 +++++ messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + src/app/api/medications/[id]/route.ts | 7 ++ .../api/medications/__tests__/route.test.ts | 115 +++++++++++++++++- src/app/medications/page-client.tsx | 4 + .../medication-card-next-due.test.tsx | 59 +++++++++ .../medication-detail-summary-course.test.tsx | 71 +++++++++++ .../__tests__/medication-table.test.tsx | 33 +++++ .../__tests__/take-all-due.test.tsx | 17 +++ .../card-parts/medication-card-body.tsx | 15 ++- .../card-parts/medication-state-badges.tsx | 14 ++- .../detail/medication-detail-tabs.tsx | 4 +- .../medications/glp1-medication-card.tsx | 9 ++ .../medications/medication-card.tsx | 9 ++ .../medications/medication-detail-summary.tsx | 23 ++-- .../medications/medication-table.tsx | 23 +++- src/components/medications/take-all-due.ts | 10 +- .../__tests__/intake-actionable.test.ts | 103 ++++++++++++++++ src/lib/medications/intake-actionable.ts | 55 +++++++++ src/lib/medications/list-read.ts | 5 + src/lib/medications/scheduling/recurrence.ts | 25 ++++ src/lib/openapi/routes/medications/schemas.ts | 20 +++ 28 files changed, 640 insertions(+), 20 deletions(-) create mode 100644 src/components/medications/__tests__/medication-detail-summary-course.test.tsx create mode 100644 src/lib/medications/__tests__/intake-actionable.test.ts create mode 100644 src/lib/medications/intake-actionable.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1ba6ce29e..f8df7b97e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -43973,6 +43973,20 @@ components: HealthKit Medications sync; NULL is a native HealthLog medication. Read-only echo of the stored provenance — the create route sets it, the list read surfaces it so mirrored-vs-native is visible without inspecting the iOS client's local registry. + courseStatus: + type: string + enum: + - UPCOMING + - CURRENT + - 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." + 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." lastTakenAt: anyOf: - type: string @@ -44043,6 +44057,8 @@ components: - schedules - category - externalSource + - courseStatus + - intakeActionable - lastTakenAt - todayEventCount - stockUnitsRemaining @@ -44233,6 +44249,20 @@ components: $ref: "#/components/schemas/MedicationScheduleOutput" category: $ref: "#/components/schemas/MedicationCategory" + courseStatus: + type: string + enum: + - UPCOMING + - CURRENT + - 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." + 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." stockUnitsRemaining: anyOf: - type: number @@ -44279,6 +44309,8 @@ components: - updatedAt - schedules - category + - courseStatus + - intakeActionable - stockUnitsRemaining - stockDosesRemaining - runwayDays diff --git a/messages/de.json b/messages/de.json index 92cf76a1b..aea0a1d7e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1226,6 +1226,7 @@ "tomorrow": "Morgen", "asNeededMarker": "Bei Bedarf", "recordOnlyBadge": "Nur dokumentiert", + "courseEndedBadge": "Beendet", "trackIntake": { "title": "Einnahmen erfassen", "label": "Einnahmen erfassen", diff --git a/messages/en.json b/messages/en.json index 840bb19bb..b645c80d4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1226,6 +1226,7 @@ "tomorrow": "Tomorrow", "asNeededMarker": "As needed", "recordOnlyBadge": "Record only", + "courseEndedBadge": "Ended", "trackIntake": { "title": "Intake tracking", "label": "Track intake", diff --git a/messages/es.json b/messages/es.json index 34d461a2c..8afcaa88f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1226,6 +1226,7 @@ "tomorrow": "Mañana", "asNeededMarker": "Según necesidad", "recordOnlyBadge": "Solo registro", + "courseEndedBadge": "Finalizado", "trackIntake": { "title": "Registro de tomas", "label": "Registrar tomas", diff --git a/messages/fr.json b/messages/fr.json index ee56afde8..e0dfd01a5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1226,6 +1226,7 @@ "tomorrow": "Demain", "asNeededMarker": "Au besoin", "recordOnlyBadge": "Pour mémoire", + "courseEndedBadge": "Terminé", "trackIntake": { "title": "Suivi des prises", "label": "Suivre les prises", diff --git a/messages/it.json b/messages/it.json index dccfb26e6..9a5a5d262 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1226,6 +1226,7 @@ "tomorrow": "Domani", "asNeededMarker": "Al bisogno", "recordOnlyBadge": "Solo annotato", + "courseEndedBadge": "Terminato", "trackIntake": { "title": "Registrazione assunzioni", "label": "Registra le assunzioni", diff --git a/messages/ko.json b/messages/ko.json index e6f1f6ec1..c9d156ea8 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -1226,6 +1226,7 @@ "tomorrow": "내일", "asNeededMarker": "필요시", "recordOnlyBadge": "기록만", + "courseEndedBadge": "종료됨", "trackIntake": { "title": "복용 기록", "label": "복용 기록하기", diff --git a/messages/pl.json b/messages/pl.json index dac0acbe9..e0be302f3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1226,6 +1226,7 @@ "tomorrow": "Jutro", "asNeededMarker": "W razie potrzeby", "recordOnlyBadge": "Tylko zapis", + "courseEndedBadge": "Zakończony", "trackIntake": { "title": "Rejestrowanie przyjęć", "label": "Rejestruj przyjęcia", diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index e010f302e..dbae9fc1a 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -47,6 +47,7 @@ import { scheduleWireFields, } from "@/lib/medications/intake-tracking"; import { floorWhole } from "@/lib/medications/units-per-dose"; +import { resolveIntakeActionability } from "@/lib/medications/intake-actionable"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; @@ -243,6 +244,12 @@ export const GET = apiHandler( category, nextDueAt: display ? display.at.toISOString() : null, nextDueOverdue: display?.overdue ?? false, + // v1.39.4 (#1040) — see `resolveIntakeActionability`. + ...resolveIntakeActionability( + medication, + now, + user.timezone || DEFAULT_TIMEZONE, + ), stockUnitsRemaining, stockDosesRemaining, runwayDays, diff --git a/src/app/api/medications/__tests__/route.test.ts b/src/app/api/medications/__tests__/route.test.ts index 50c3e9e96..66d575baa 100644 --- a/src/app/api/medications/__tests__/route.test.ts +++ b/src/app/api/medications/__tests__/route.test.ts @@ -55,7 +55,10 @@ vi.mock("@/lib/medication-category", () => ({ getMedicationCategories: vi.fn().mockResolvedValue({}), setMedicationCategory: vi.fn().mockResolvedValue(undefined), })); -vi.mock("@/lib/tz/local-day", () => ({ +vi.mock("@/lib/tz/local-day", async (importOriginal) => ({ + // The schedule engine reads the real local-day helpers; only the today + // bounds are pinned per test. + ...(await importOriginal()), getUserTodayBounds: vi .fn() .mockReturnValue({ start: new Date(), end: new Date() }), @@ -916,3 +919,113 @@ describe("GET /api/medications — provenance echo (v1.32.25)", () => { expect(native?.externalSource).toBeNull(); }); }); + +describe("GET /api/medications — intake actions follow the course (#1040)", () => { + function wireEmptyReads() { + vi.mocked(cachedSwr).mockImplementation((async ( + _c: unknown, + _k: string, + f: () => Promise, + ) => f()) as never); + vi.mocked(getUserTodayBounds).mockReturnValue({ + start: new Date("2026-09-27T00:00:00Z"), + end: new Date("2026-09-27T23:59:59Z"), + } as never); + vi.mocked(prisma.medicationIntakeEvent.groupBy).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationScheduleRevision.groupBy).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationInventoryItem.groupBy).mockResolvedValue( + [] as never, + ); + vi.mocked(getMedicationCategories).mockResolvedValue({} as never); + } + + function med(id: string, over: Record) { + return { + id, + userId: "user-1", + name: id, + dose: "10 mg", + treatmentClass: "GENERIC", + unitsPerDose: 1, + active: true, + trackIntake: true, + asNeeded: false, + oneShot: false, + pausedAt: null, + startsOn: new Date("2026-09-01T00:00:00Z"), + endsOn: null, + createdAt: new Date("2026-09-01T08:00:00Z"), + schedules: [ + { + id: `${id}-s`, + medicationId: id, + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00"], + daysOfWeek: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + reminderGraceMinutes: null, + cyclicOnWeeks: null, + cyclicOffWeeks: null, + scheduleType: "SCHEDULED", + unitsPerDose: null, + doseWindows: null, + label: null, + dose: null, + }, + ], + ...over, + }; + } + + it("withholds the dose actions from a course that ended yesterday and keeps them on its last day", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + // 10:00 in Berlin (the default zone) on 2026-09-27. + vi.setSystemTime(new Date("2026-09-27T08:00:00Z")); + try { + wireEmptyReads(); + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + med("ended", { endsOn: new Date("2026-09-26T00:00:00Z") }), + med("last-day", { endsOn: new Date("2026-09-27T00:00:00Z") }), + med("upcoming", { startsOn: new Date("2026-09-28T00:00:00Z") }), + med("chronic", {}), + med("record-only", { trackIntake: false }), + ] as never); + + const res = await ( + GET as unknown as (req: NextRequest) => Promise + )(new NextRequest("http://localhost/api/medications")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: Array<{ + id: string; + courseStatus: string; + intakeActionable: boolean; + }>; + }; + const byId = Object.fromEntries( + body.data.map((m) => [ + m.id, + [m.courseStatus, m.intakeActionable] as const, + ]), + ); + expect(byId).toEqual({ + ended: ["ENDED", false], + "last-day": ["CURRENT", true], + upcoming: ["UPCOMING", false], + chronic: ["CURRENT", true], + "record-only": ["CURRENT", false], + }); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/app/medications/page-client.tsx b/src/app/medications/page-client.tsx index e37e45adf..73bd791ae 100644 --- a/src/app/medications/page-client.tsx +++ b/src/app/medications/page-client.tsx @@ -112,6 +112,10 @@ interface Medication { * and left out of the take-all and log-dose flows. */ trackIntake?: boolean; + /** v1.39.4 (#1040) — server-resolved: taken / skip are offered today. */ + intakeActionable?: boolean; + /** v1.39.4 (#1040) — where today sits in the course. */ + courseStatus?: "UPCOMING" | "CURRENT" | "ENDED"; /** v1.9.0 — optional WHO ATC classification code for the FHIR export. */ atcCode?: string | null; /** v1.9.0 — optional RxNorm RxCUI (secondary FHIR coding). */ diff --git a/src/components/medications/__tests__/medication-card-next-due.test.tsx b/src/components/medications/__tests__/medication-card-next-due.test.tsx index c1dd4a350..79f1083ca 100644 --- a/src/components/medications/__tests__/medication-card-next-due.test.tsx +++ b/src/components/medications/__tests__/medication-card-next-due.test.tsx @@ -367,3 +367,62 @@ describe(" — as-needed marker (v1.16.11, #316)", () => { expect(html).toContain("Taken"); }); }); + +/** + * v1.39.4 (#1040) — a medication whose course has ended keeps its card + * (history, adherence) but offers no taken / skip and names the state. + */ +describe(" — ended course (#1040)", () => { + function makeMed(overrides: Record = {}) { + return { + id: "med-course-1", + name: "Amoxicillin", + dose: "500 mg", + category: "ANTIBIOTIC", + treatmentClass: undefined as string | undefined, + active: true, + notificationsEnabled: true, + pausedAt: null, + lastTakenAt: null, + todayEventCount: 0, + nextDueAt: null, + nextDueOverdue: false, + schedules: [{ id: "s1", ...pastWindow }], + ...overrides, + }; + } + + it("drops the action row and shows the Ended badge once the course is over", () => { + const client = makeClient(); + seedCompliance(client, "med-course-1"); + const html = render( + {}} + onOpenHistory={() => {}} + />, + client, + ); + expect(html).not.toContain('data-slot="medication-intake-take"'); + expect(html).not.toContain('data-slot="medication-intake-skip"'); + expect(html).toContain('data-slot="medication-course-ended-badge"'); + }); + + it("keeps the action row on the last course day", () => { + const client = makeClient(); + seedCompliance(client, "med-course-1"); + const html = render( + {}} + onOpenHistory={() => {}} + />, + client, + ); + expect(html).toContain('data-slot="medication-intake-take"'); + expect(html).not.toContain('data-slot="medication-course-ended-badge"'); + }); +}); diff --git a/src/components/medications/__tests__/medication-detail-summary-course.test.tsx b/src/components/medications/__tests__/medication-detail-summary-course.test.tsx new file mode 100644 index 000000000..18bbbdc0d --- /dev/null +++ b/src/components/medications/__tests__/medication-detail-summary-course.test.tsx @@ -0,0 +1,71 @@ +/** + * v1.39.4 (#1040) — the detail header's "Ended" status follows the + * server's `courseStatus`. It used to compare the end date's UTC midnight + * with the clock, which read the whole last course day as ended. + */ +import { afterEach, beforeEach, 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 { MedicationDetailSummary } from "@/components/medications/medication-detail-summary"; + +const payload = { + id: "m1", + name: "Amoxicillin", + dose: "500 mg", + category: "ANTIBIOTIC", + notificationsEnabled: true, + startsOn: new Date("2026-09-20T00:00:00Z"), + endsOn: new Date("2026-09-27T00:00:00Z"), + oneShot: false, + schedules: [ + { + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00"], + rrule: "FREQ=DAILY", + }, + ], +}; + +function render(courseStatus: "UPCOMING" | "CURRENT" | "ENDED") { + return renderToStaticMarkup( + + + + + , + ); +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + // The last course day, mid-morning in UTC. + vi.setSystemTime(new Date("2026-09-27T10:00:00Z")); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe(" — course status (#1040)", () => { + it("reads as active on the last course day", () => { + const html = render("CURRENT"); + expect(html).toContain("bg-success"); + expect(html).not.toContain("bg-muted-foreground"); + }); + + it("reads as ended once the server says the course is over", () => { + const html = render("ENDED"); + expect(html).toContain("bg-muted-foreground"); + expect(html).not.toContain("bg-success"); + }); +}); diff --git a/src/components/medications/__tests__/medication-table.test.tsx b/src/components/medications/__tests__/medication-table.test.tsx index 200f2b81b..1503d5e2c 100644 --- a/src/components/medications/__tests__/medication-table.test.tsx +++ b/src/components/medications/__tests__/medication-table.test.tsx @@ -411,6 +411,39 @@ describe(" — structure + shared payloads", () => { expect(html).toContain("4 doses"); }); + it("offers no dose actions on an ended course and names it; the last course day keeps them (#1040)", () => { + const client = makeClient(); + seedCompliance(client, "ended", 90, 88); + seedCompliance(client, "last", 90, 88); + const html = render( + , + client, + ); + + expect(html).not.toContain('aria-label="Take – Amoxicillin"'); + expect(html).not.toContain('aria-label="Skip – Amoxicillin"'); + expect(html).toContain('data-slot="medication-table-course-ended-marker"'); + expect(html).toContain(">Ended<"); + expect(html).toContain('aria-label="Take – Prednisolone"'); + expect(html).toContain('aria-label="Skip – Prednisolone"'); + }); + it("renders an as-needed row: calm marker, no compliance bars (v1.16.11 #316)", () => { const client = makeClient(); // Deliberately NOT seeding compliance for the PRN row — the batched diff --git a/src/components/medications/__tests__/take-all-due.test.tsx b/src/components/medications/__tests__/take-all-due.test.tsx index 570c52b59..f6aa792e2 100644 --- a/src/components/medications/__tests__/take-all-due.test.tsx +++ b/src/components/medications/__tests__/take-all-due.test.tsx @@ -174,6 +174,23 @@ describe("deriveDueMedications — only currently-due, active, unpaused", () => expect(due.map((d) => d.id)).toEqual(["due"]); }); + it("excludes a medication whose course has ended, even with an escalated dose (#1040)", () => { + const due = deriveDueMedications( + [ + makeMed({ id: "ended", intakeActionable: false }), + makeMed({ id: "on" }), + ], + { + ...opts, + doseStatusById: new Map([ + ["ended", "missed" as const], + ["on", "upcoming" as const], + ]), + }, + ); + expect(due.map((d) => d.id)).toEqual(["on"]); + }); + it("excludes a medication whose dose band is not currently open", () => { const due = deriveDueMedications( [ diff --git a/src/components/medications/card-parts/medication-card-body.tsx b/src/components/medications/card-parts/medication-card-body.tsx index d17b6078e..2e4469527 100644 --- a/src/components/medications/card-parts/medication-card-body.tsx +++ b/src/components/medications/card-parts/medication-card-body.tsx @@ -141,6 +141,13 @@ export interface MedicationCardBodyProps { */ recordOnly?: boolean; + /** + * v1.39.4 (#1040) — the server's `intakeActionable`: false once the course + * has ended (or before it starts), so the card offers no take / skip and + * raises no overdue escalation for a dose that is no longer part of it. + */ + intakeActionable?: boolean; + /** * v1.16.11 — projected supply runway in whole days, set ONLY while it * sits below the user's low-stock threshold (the variants gate it). @@ -191,6 +198,7 @@ export function MedicationCardBody({ currentCycle, asNeeded = false, recordOnly = false, + intakeActionable = true, lowStockRunwayDays = null, intakeLoading, onRecordIntake, @@ -203,10 +211,11 @@ export function MedicationCardBody({ // past the clinical miss cutoff) reads "Stark überfällig"; the still-takeable // `overdue` tail reads the calmer "Überfällig". Both use the destructive // tone so the urgency is unmistakable; suppressed on an inactive med. + const canAct = active && intakeActionable; const overdueLabel = - active && doseStatus === "missed" + canAct && doseStatus === "missed" ? t("medications.veryOverdue") - : active && doseStatus === "overdue" + : canAct && doseStatus === "overdue" ? t("medications.overdue") : null; @@ -352,7 +361,7 @@ export function MedicationCardBody({ {/* Quick actions — bottom-pinned so the action rows align across a grid row regardless of how much content sits above. */} - {active && !recordOnly && ( + {canAct && !recordOnly && (
)} - {!notificationsEnabled && !recordOnly && ( + {courseEnded && ( + + {t("medications.courseEndedBadge")} + + )} + {!notificationsEnabled && !recordOnly && !courseEnded && ( {t("medications.withoutNotification")} diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx index 1571fd376..d86c3c524 100644 --- a/src/components/medications/detail/medication-detail-tabs.tsx +++ b/src/components/medications/detail/medication-detail-tabs.tsx @@ -170,6 +170,8 @@ export interface MedicationDetailSnapshot { * `recordedSchedules`; the page shows (and edits) the stored rows. */ trackIntake?: boolean; + /** v1.39.4 (#1040) — where today sits in the course (server-resolved). */ + courseStatus?: "UPCOMING" | "CURRENT" | "ENDED"; recordedSchedules?: ScheduleSnapshot[]; schedules: ScheduleSnapshot[]; } @@ -501,7 +503,7 @@ export function MedicationDetailTabs({ name={medication.name} dose={formatDose(medication.dose, t)} active={medication.active} - endsOn={medication.endsOn} + courseStatus={served.courseStatus} payload={payload} oneShot={oneShot} asNeeded={asNeeded} diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 1895daa0a..eb64b07e7 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -135,6 +135,13 @@ export interface Glp1Medication { * compliance bars and the take / skip actions and shows a badge instead. */ trackIntake?: boolean; + /** + * v1.39.4 (#1040) — server-resolved: taken / skip are offered today only + * when true (active, tracked, and the course includes today). + */ + intakeActionable?: boolean; + /** v1.39.4 (#1040) — where today sits in the course. */ + courseStatus?: "UPCOMING" | "CURRENT" | "ENDED"; schedules: ScheduleLite[]; } @@ -446,6 +453,7 @@ export function Glp1MedicationCard({ active={medication.active} pausedAt={medication.pausedAt} recordOnly={medication.trackIntake === false} + courseEnded={medication.courseStatus === "ENDED"} /> ); @@ -560,6 +568,7 @@ export function Glp1MedicationCard({ complianceNotApplicable ? null : (display?.currentCycle ?? null) } recordOnly={medication.trackIntake === false} + intakeActionable={medication.intakeActionable !== false} lowStockRunwayDays={lowStockRunwayDays} intakeLoading={intakeLoading} onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)} diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index a4d1623c3..577e171a1 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -120,6 +120,13 @@ interface Medication { * compliance bars and the take / skip actions and shows a badge instead. */ trackIntake?: boolean; + /** + * v1.39.4 (#1040) — server-resolved: taken / skip are offered today only + * when true (active, tracked, and the course includes today). + */ + intakeActionable?: boolean; + /** v1.39.4 (#1040) — where today sits in the course. */ + courseStatus?: "UPCOMING" | "CURRENT" | "ENDED"; schedules: Schedule[]; } @@ -385,6 +392,7 @@ export function MedicationCard({ active={medication.active} pausedAt={medication.pausedAt} recordOnly={medication.trackIntake === false} + courseEnded={medication.courseStatus === "ENDED"} /> ); @@ -525,6 +533,7 @@ export function MedicationCard({ complianceNotApplicable ? null : (display?.currentCycle ?? null) } recordOnly={medication.trackIntake === false} + intakeActionable={medication.intakeActionable !== false} lowStockRunwayDays={lowStockRunwayDays} intakeLoading={intakeLoading} onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)} diff --git a/src/components/medications/medication-detail-summary.tsx b/src/components/medications/medication-detail-summary.tsx index 4904a1f7c..9e043be23 100644 --- a/src/components/medications/medication-detail-summary.tsx +++ b/src/components/medications/medication-detail-summary.tsx @@ -32,7 +32,12 @@ export interface MedicationDetailSummaryProps { name: string; dose: string; active: boolean; - endsOn?: string | null; + /** + * v1.39.4 (#1040) — the server's course position. "Ended" follows it, so + * the last course day reads as active on the user's clock instead of + * flipping at UTC midnight of the end date. + */ + courseStatus?: "UPCOMING" | "CURRENT" | "ENDED"; /** Wizard payload used to derive the plain-language cadence line. */ payload: MedicationPayload; oneShot: boolean; @@ -45,13 +50,11 @@ export interface MedicationDetailSummaryProps { type Status = "active" | "paused" | "ended"; -function resolveStatus(active: boolean, endsOn?: string | null): Status { - if (endsOn) { - const end = new Date(endsOn); - if (!Number.isNaN(end.getTime()) && end.getTime() <= Date.now()) { - return "ended"; - } - } +function resolveStatus( + active: boolean, + courseStatus?: "UPCOMING" | "CURRENT" | "ENDED", +): Status { + if (courseStatus === "ENDED") return "ended"; return active ? "active" : "paused"; } @@ -59,7 +62,7 @@ export function MedicationDetailSummary({ name, dose, active, - endsOn, + courseStatus, payload, oneShot, asNeeded = false, @@ -69,7 +72,7 @@ export function MedicationDetailSummary({ const { t, locale } = useTranslations(); const formatters = useFormatters(); const dateFormatPref = useDateFormatPreference(); - const status = resolveStatus(active, endsOn); + const status = resolveStatus(active, courseStatus); const statusLabel = status === "active" diff --git a/src/components/medications/medication-table.tsx b/src/components/medications/medication-table.tsx index e52c9c92a..06c07558a 100644 --- a/src/components/medications/medication-table.tsx +++ b/src/components/medications/medication-table.tsx @@ -98,6 +98,12 @@ export interface TableMedication { * cell names that, the compliance and action cells show "–". */ trackIntake?: boolean; + /** + * v1.39.4 (#1040) — server-resolved: the action cell offers taken / skip + * only when true; an ended course shows "Ended" where next-due sits. + */ + intakeActionable?: boolean; + courseStatus?: "UPCOMING" | "CURRENT" | "ENDED"; /** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */ stockDosesRemaining?: number | null; /** v1.37.19 — server-resolved slot-aware runway (days); null = off/no cadence. */ @@ -478,10 +484,11 @@ function MedicationTableRowItem({ // column with no neighbouring last-intake context. Order here: // last-dose context outranks the overdue escalation, which outranks // the window pill. + const canAct = medication.active && medication.intakeActionable !== false; const overdueLabel = - medication.active && doseStatus === "missed" + canAct && doseStatus === "missed" ? t("medications.veryOverdue") - : medication.active && doseStatus === "overdue" + : canAct && doseStatus === "overdue" ? t("medications.overdue") : null; @@ -528,6 +535,16 @@ function MedicationTableRowItem({ {t("medications.recordOnlyBadge")} ); + } else if (medication.courseStatus === "ENDED") { + // v1.39.4 (#1040) — the course is over: nothing is due any more. + nextCell = ( + + {t("medications.courseEndedBadge")} + + ); } else if (medication.asNeeded) { // v1.16.11 — a calm marker where next-due normally sits; no due // pill, no overdue escalation, ever. @@ -713,7 +730,7 @@ function MedicationTableRowItem({ {stockCell} - {medication.active && canAddIntake && !recordOnly ? ( + {canAct && canAddIntake && !recordOnly ? (