diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9999b8532..50bccf26d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -23985,6 +23985,7 @@ components: - SLEEP_AID - DIABETES - ANTIBIOTIC + - MENTAL_HEALTH - OTHER treatmentClass: type: string @@ -24321,6 +24322,7 @@ components: - SLEEP_AID - DIABETES - ANTIBIOTIC + - MENTAL_HEALTH - OTHER treatmentClass: type: string @@ -34171,6 +34173,9 @@ components: type: boolean trackIntake: type: boolean + category: + type: string + maxLength: 64 deliveryForm: type: string enum: @@ -43976,6 +43981,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 @@ -44046,6 +44065,8 @@ components: - schedules - category - externalSource + - courseStatus + - intakeActionable - lastTakenAt - todayEventCount - stockUnitsRemaining @@ -44073,6 +44094,7 @@ components: - SLEEP_AID - DIABETES - ANTIBIOTIC + - MENTAL_HEALTH - OTHER description: Clinical taxonomy stored in the `medication_categories` side-table. Orthogonal to `MedicationTreatmentClass`. CreateMedicationResponse: @@ -44236,6 +44258,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 @@ -44282,6 +44318,8 @@ components: - updatedAt - schedules - category + - courseStatus + - intakeActionable - stockUnitsRemaining - stockDosesRemaining - runwayDays diff --git a/messages/de.json b/messages/de.json index 90964abea..a32f0bff0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1212,6 +1212,7 @@ "categorySleepAid": "Schlafmittel", "categoryDiabetes": "Diabetes", "categoryAntibiotic": "Antibiotikum", + "categoryMentalHealth": "Psychische Gesundheit", "categoryOther": "Sonstiges", "daysSun": "So", "daysMon": "Mo", @@ -1226,6 +1227,7 @@ "tomorrow": "Morgen", "asNeededMarker": "Bei Bedarf", "recordOnlyBadge": "Nur dokumentiert", + "courseEndedBadge": "Beendet", "trackIntake": { "title": "Einnahmen erfassen", "label": "Einnahmen erfassen", @@ -1566,6 +1568,11 @@ "vitamin": "Vitamine", "supplement": "Nahrungsergänzung", "antibiotic": "Antibiotikum", + "mentalHealth": "Psychische Gesundheit", + "thyroid": "Schilddrüse", + "digestive": "Magen/Darm", + "skin": "Hautpflege", + "sleepAid": "Schlafmittel", "other": "Sonstiges" }, "cadence": { diff --git a/messages/en.json b/messages/en.json index b9eae3f92..216062068 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1212,6 +1212,7 @@ "categorySleepAid": "Sleep Aid", "categoryDiabetes": "Diabetes", "categoryAntibiotic": "Antibiotic", + "categoryMentalHealth": "Mental health", "categoryOther": "Other", "daysSun": "Sun", "daysMon": "Mon", @@ -1226,6 +1227,7 @@ "tomorrow": "Tomorrow", "asNeededMarker": "As needed", "recordOnlyBadge": "Record only", + "courseEndedBadge": "Ended", "trackIntake": { "title": "Intake tracking", "label": "Track intake", @@ -1566,6 +1568,11 @@ "vitamin": "Vitamins", "supplement": "Supplement", "antibiotic": "Antibiotic", + "mentalHealth": "Mental health", + "thyroid": "Thyroid", + "digestive": "Gastrointestinal", + "skin": "Skincare", + "sleepAid": "Sleep aid", "other": "Other" }, "cadence": { diff --git a/messages/es.json b/messages/es.json index 3dca0af66..a15519de2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1212,6 +1212,7 @@ "categorySleepAid": "Para dormir", "categoryDiabetes": "Diabetes", "categoryAntibiotic": "Antibiótico", + "categoryMentalHealth": "Salud mental", "categoryOther": "Otros", "daysSun": "Do", "daysMon": "Lu", @@ -1226,6 +1227,7 @@ "tomorrow": "Mañana", "asNeededMarker": "Según necesidad", "recordOnlyBadge": "Solo registro", + "courseEndedBadge": "Finalizado", "trackIntake": { "title": "Registro de tomas", "label": "Registrar tomas", @@ -1566,6 +1568,11 @@ "vitamin": "Vitaminas", "supplement": "Suplemento", "antibiotic": "Antibiótico", + "mentalHealth": "Salud mental", + "thyroid": "Tiroides", + "digestive": "Digestivo", + "skin": "Cuidado de la piel", + "sleepAid": "Para dormir", "other": "Otro" }, "cadence": { diff --git a/messages/fr.json b/messages/fr.json index d7e6af27c..ec8aaf4a0 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1212,6 +1212,7 @@ "categorySleepAid": "Aide au sommeil", "categoryDiabetes": "Diabetes", "categoryAntibiotic": "Antibiotique", + "categoryMentalHealth": "Santé mentale", "categoryOther": "Autres", "daysSun": "Dim", "daysMon": "Lun", @@ -1226,6 +1227,7 @@ "tomorrow": "Demain", "asNeededMarker": "Au besoin", "recordOnlyBadge": "Pour mémoire", + "courseEndedBadge": "Terminé", "trackIntake": { "title": "Suivi des prises", "label": "Suivre les prises", @@ -1566,6 +1568,11 @@ "vitamin": "Vitamines", "supplement": "Complément", "antibiotic": "Antibiotique", + "mentalHealth": "Santé mentale", + "thyroid": "Thyroïde", + "digestive": "Digestif", + "skin": "Soin de la peau", + "sleepAid": "Aide au sommeil", "other": "Autre" }, "cadence": { diff --git a/messages/it.json b/messages/it.json index aeb29b538..7b5a9dc8e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1212,6 +1212,7 @@ "categorySleepAid": "Per dormire", "categoryDiabetes": "Diabetes", "categoryAntibiotic": "Antibiotico", + "categoryMentalHealth": "Salute mentale", "categoryOther": "Altro", "daysSun": "Dom", "daysMon": "Lun", @@ -1226,6 +1227,7 @@ "tomorrow": "Domani", "asNeededMarker": "Al bisogno", "recordOnlyBadge": "Solo annotato", + "courseEndedBadge": "Terminato", "trackIntake": { "title": "Registrazione assunzioni", "label": "Registra le assunzioni", @@ -1566,6 +1568,11 @@ "vitamin": "Vitamine", "supplement": "Integratore", "antibiotic": "Antibiotico", + "mentalHealth": "Salute mentale", + "thyroid": "Tiroide", + "digestive": "Digestivo", + "skin": "Cura della pelle", + "sleepAid": "Per dormire", "other": "Altro" }, "cadence": { diff --git a/messages/ko.json b/messages/ko.json index 08a012016..eca3ce056 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -1212,6 +1212,7 @@ "categorySleepAid": "수면 보조", "categoryDiabetes": "당뇨", "categoryAntibiotic": "항생제", + "categoryMentalHealth": "정신 건강", "categoryOther": "기타", "daysSun": "일", "daysMon": "월", @@ -1226,6 +1227,7 @@ "tomorrow": "내일", "asNeededMarker": "필요시", "recordOnlyBadge": "기록만", + "courseEndedBadge": "종료됨", "trackIntake": { "title": "복용 기록", "label": "복용 기록하기", @@ -1566,6 +1568,11 @@ "vitamin": "비타민", "supplement": "보충제", "antibiotic": "항생제", + "mentalHealth": "정신 건강", + "thyroid": "갑상선", + "digestive": "소화기", + "skin": "피부", + "sleepAid": "수면 보조", "other": "기타" }, "cadence": { diff --git a/messages/pl.json b/messages/pl.json index bcf9065b9..131f96e46 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1212,6 +1212,7 @@ "categorySleepAid": "Na sen", "categoryDiabetes": "Diabetes", "categoryAntibiotic": "Antybiotyk", + "categoryMentalHealth": "Zdrowie psychiczne", "categoryOther": "Inne", "daysSun": "Nd", "daysMon": "Pn", @@ -1226,6 +1227,7 @@ "tomorrow": "Jutro", "asNeededMarker": "W razie potrzeby", "recordOnlyBadge": "Tylko zapis", + "courseEndedBadge": "Zakończony", "trackIntake": { "title": "Rejestrowanie przyjęć", "label": "Rejestruj przyjęcia", @@ -1566,6 +1568,11 @@ "vitamin": "Witaminy", "supplement": "Suplement", "antibiotic": "Antybiotyk", + "mentalHealth": "Zdrowie psychiczne", + "thyroid": "Tarczyca", + "digestive": "Trawienne", + "skin": "Pielęgnacja skóry", + "sleepAid": "Na sen", "other": "Inne" }, "cadence": { diff --git a/src/__tests__/backup-plan-classification.test.ts b/src/__tests__/backup-plan-classification.test.ts index 0da7c7f34..99b3390e7 100644 --- a/src/__tests__/backup-plan-classification.test.ts +++ b/src/__tests__/backup-plan-classification.test.ts @@ -1,5 +1,5 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { readdirSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; @@ -8,6 +8,7 @@ import { BACKED_UP_MODELS, DERIVED_MODELS, NOT_IN_BACKUP_MODELS, + RAW_SQL_TABLES, backupVerdict, } from "@/lib/export/backup-plan"; @@ -491,3 +492,62 @@ describe("every backed-up model travels both ways, or is named as debt", () => { } }); }); + +describe("tables outside the schema have a backup verdict too", () => { + const SRC = resolve(__dirname, ".."); + + function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + return entry.name === "generated" || entry.name === "__tests__" + ? [] + : sourceFiles(full); + } + return /\.tsx?$/.test(entry.name) ? [full] : []; + }); + } + + function rawTables(): string[] { + const names = new Set(); + for (const file of sourceFiles(SRC)) { + const text = readFileSync(file, "utf8"); + for (const m of text.matchAll( + /CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+"?(\w+)"?/gi, + )) { + names.add(m[1]); + } + } + return [...names]; + } + + const mappedTables = new Set( + [...readFileSync(SCHEMA_PATH, "utf8").matchAll(/@@map\("(\w+)"\)/g)].map( + (m) => m[1], + ), + ); + + it("finds the raw tables it is meant to judge (no vacuous pass)", () => { + expect(rawTables()).toContain("medication_categories"); + }); + + it("gives every raw table that is not a schema model a verdict", () => { + const unjudged = rawTables().filter( + (name) => !mappedTables.has(name) && !(name in RAW_SQL_TABLES), + ); + expect(unjudged).toEqual([]); + }); + + it("carries the medication category through both ends", () => { + const writer = readFileSync( + join(SRC, "lib/export/full-backup-payload.ts"), + "utf8", + ); + const restore = readFileSync( + join(SRC, "lib/export/restore-backup.ts"), + "utf8", + ); + expect(writer).toMatch(/getMedicationCategories\(/); + expect(restore).toMatch(/setMedicationCategory\(/); + }); +}); 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..888ef60c7 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() }), @@ -361,6 +364,18 @@ describe("POST /api/medications — v1.5.4 clinical category extension", () => { expect(res.status).toBe(201); }); + it("accepts MENTAL_HEALTH as a valid clinical category (v1.39.4, #1041)", async () => { + const res = await POST( + postReq({ + name: "Sertraline", + dose: "50 mg", + category: "MENTAL_HEALTH", + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }), + ); + expect(res.status).toBe(201); + }); + it("still 422s on an unknown category string", async () => { const res = await POST( postReq({ @@ -916,3 +931,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 ? (