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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 13:49:21 +02:00
1 parent 419ff04765
commit 8295dfe9b6
28 files changed
+640 -20

No files matched your search

+32
View File
@@ -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
+1
View File
@@ -1226,6 +1226,7 @@
"tomorrow": "Morgen",
"asNeededMarker": "Bei Bedarf",
"recordOnlyBadge": "Nur dokumentiert",
"courseEndedBadge": "Beendet",
"trackIntake": {
"title": "Einnahmen erfassen",
"label": "Einnahmen erfassen",
+1
View File
@@ -1226,6 +1226,7 @@
"tomorrow": "Tomorrow",
"asNeededMarker": "As needed",
"recordOnlyBadge": "Record only",
"courseEndedBadge": "Ended",
"trackIntake": {
"title": "Intake tracking",
"label": "Track intake",
+1
View File
@@ -1226,6 +1226,7 @@
"tomorrow": "Mañana",
"asNeededMarker": "Según necesidad",
"recordOnlyBadge": "Solo registro",
"courseEndedBadge": "Finalizado",
"trackIntake": {
"title": "Registro de tomas",
"label": "Registrar tomas",
+1
View File
@@ -1226,6 +1226,7 @@
"tomorrow": "Demain",
"asNeededMarker": "Au besoin",
"recordOnlyBadge": "Pour mémoire",
"courseEndedBadge": "Terminé",
"trackIntake": {
"title": "Suivi des prises",
"label": "Suivre les prises",
+1
View File
@@ -1226,6 +1226,7 @@
"tomorrow": "Domani",
"asNeededMarker": "Al bisogno",
"recordOnlyBadge": "Solo annotato",
"courseEndedBadge": "Terminato",
"trackIntake": {
"title": "Registrazione assunzioni",
"label": "Registra le assunzioni",
+1
View File
@@ -1226,6 +1226,7 @@
"tomorrow": "내일",
"asNeededMarker": "필요시",
"recordOnlyBadge": "기록만",
"courseEndedBadge": "종료됨",
"trackIntake": {
"title": "복용 기록",
"label": "복용 기록하기",
+1
View File
@@ -1226,6 +1226,7 @@
"tomorrow": "Jutro",
"asNeededMarker": "W razie potrzeby",
"recordOnlyBadge": "Tylko zapis",
"courseEndedBadge": "Zakończony",
"trackIntake": {
"title": "Rejestrowanie przyjęć",
"label": "Rejestruj przyjęcia",
+7
View File
@@ -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,
+114 -1
View File
@@ -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<typeof import("@/lib/tz/local-day")>()),
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<unknown>,
) => 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<string, unknown>) {
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<Response>
)(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();
}
});
});
+4
View File
@@ -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). */
@@ -367,3 +367,62 @@ describe("<MedicationCard> — 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("<MedicationCard> — ended course (#1040)", () => {
function makeMed(overrides: Record<string, unknown> = {}) {
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(
<MedicationCard
medication={makeMed({ courseStatus: "ENDED", intakeActionable: false })}
onEdit={() => {}}
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(
<MedicationCard
medication={makeMed({
courseStatus: "CURRENT",
intakeActionable: true,
})}
onEdit={() => {}}
onOpenHistory={() => {}}
/>,
client,
);
expect(html).toContain('data-slot="medication-intake-take"');
expect(html).not.toContain('data-slot="medication-course-ended-badge"');
});
});
@@ -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(
<I18nProvider initialLocale="en">
<QueryClientProvider client={new QueryClient()}>
<MedicationDetailSummary
name="Amoxicillin"
dose="500 mg"
active
courseStatus={courseStatus}
payload={payload}
oneShot={false}
startsOn="2026-09-20T00:00:00.000Z"
/>
</QueryClientProvider>
</I18nProvider>,
);
}
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("<MedicationDetailSummary> — 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");
});
});
@@ -411,6 +411,39 @@ describe("<MedicationTable> — 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(
<MedicationTable
activeMedications={[
med({
id: "ended",
name: "Amoxicillin",
courseStatus: "ENDED",
intakeActionable: false,
}),
med({
id: "last",
name: "Prednisolone",
courseStatus: "CURRENT",
intakeActionable: true,
}),
]}
inactiveMedications={[]}
/>,
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
@@ -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(
[
@@ -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 && (
<div className="mt-auto pt-0">
<MedicationIntakeActions
intakeLoading={intakeLoading}
@@ -8,6 +8,8 @@ interface MedicationStateBadgesProps {
pausedAt: string | null;
/** v1.39.1 (#1033) — intake tracking off: kept as a record only. */
recordOnly?: boolean;
/** v1.39.4 (#1040) — the course's end date has passed. */
courseEnded?: boolean;
}
/**
@@ -21,6 +23,7 @@ export function MedicationStateBadges({
active,
pausedAt,
recordOnly = false,
courseEnded = false,
}: MedicationStateBadgesProps) {
const { t } = useTranslations();
@@ -35,7 +38,16 @@ export function MedicationStateBadges({
{t("medications.recordOnlyBadge")}
</Badge>
)}
{!notificationsEnabled && !recordOnly && (
{courseEnded && (
<Badge
variant="secondary"
className="text-xs"
data-slot="medication-course-ended-badge"
>
{t("medications.courseEndedBadge")}
</Badge>
)}
{!notificationsEnabled && !recordOnly && !courseEnded && (
<Badge variant="secondary" className="text-xs">
{t("medications.withoutNotification")}
</Badge>
@@ -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}
@@ -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)}
@@ -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)}
@@ -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"
@@ -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")}
</span>
);
} else if (medication.courseStatus === "ENDED") {
// v1.39.4 (#1040) — the course is over: nothing is due any more.
nextCell = (
<span
className="text-muted-foreground"
data-slot="medication-table-course-ended-marker"
>
{t("medications.courseEndedBadge")}
</span>
);
} 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({
</TableCell>
<TableCell className="text-sm">{stockCell}</TableCell>
<TableCell>
{medication.active && canAddIntake && !recordOnly ? (
{canAct && canAddIntake && !recordOnly ? (
<div className="flex gap-1.5">
<Button
size="icon"
+9 -1
View File
@@ -85,6 +85,8 @@ export interface DueDerivationMedication {
asNeeded?: boolean;
/** v1.39.1 (#1033) — false: kept as a record, never due, never in the set. */
trackIntake?: boolean;
/** v1.39.4 (#1040) — false: no dose actions today (e.g. the course ended). */
intakeActionable?: boolean;
schedules: DueDerivationSchedule[];
}
@@ -155,7 +157,13 @@ export function deriveDueMedications(
// compliance row for one must not pull it into the set.
// A medication with intake tracking off is never due either; the list
// already serves it with no schedules, this makes the rule explicit.
if (!m.active || m.pausedAt || m.asNeeded || m.trackIntake === false) {
if (
!m.active ||
m.pausedAt ||
m.asNeeded ||
m.trackIntake === false ||
m.intakeActionable === false
) {
continue;
}
@@ -0,0 +1,103 @@
/**
* #1040 — a medication whose course has ended must not offer a dose.
* The course bounds are calendar dates (UTC midnight of the date); only
* `now` is read on the user's clock. A course that ends today stays
* actionable for the whole local day, east and west of UTC.
*/
import { describe, expect, it } from "vitest";
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
import { resolveIntakeActionability } from "../intake-actionable";
const ZONES = [
"America/Los_Angeles",
"America/New_York",
"Europe/Berlin",
"Asia/Tokyo",
"Pacific/Auckland",
] as const;
const day = (iso: string) => new Date(`${iso}T00:00:00.000Z`);
function at(tz: string, d: number, hour: number, minute = 0): Date {
return zonedWallClockToUtc(
{ year: 2026, month: 9, day: d, hour, minute },
tz,
);
}
const base = {
active: true,
trackIntake: true,
startsOn: day("2026-09-01"),
endsOn: day("2026-09-20"),
};
describe.each(ZONES)("course window in %s", (tz) => {
it("is actionable on the last course day, from local midnight to 23:59", () => {
for (const [h, m] of [
[0, 0],
[8, 0],
[23, 59],
] as const) {
const r = resolveIntakeActionability(base, at(tz, 20, h, m), tz);
expect(r).toEqual({ courseStatus: "CURRENT", intakeActionable: true });
}
});
it("is ended from local midnight of the day after endsOn", () => {
const r = resolveIntakeActionability(base, at(tz, 21, 0, 0), tz);
expect(r).toEqual({ courseStatus: "ENDED", intakeActionable: false });
});
it("is upcoming until local midnight of startsOn", () => {
const med = { ...base, startsOn: day("2026-09-25"), endsOn: null };
expect(resolveIntakeActionability(med, at(tz, 24, 23, 59), tz)).toEqual({
courseStatus: "UPCOMING",
intakeActionable: false,
});
expect(resolveIntakeActionability(med, at(tz, 25, 0, 0), tz)).toEqual({
courseStatus: "CURRENT",
intakeActionable: true,
});
});
});
describe("resolveIntakeActionability", () => {
const tz = "Europe/Berlin";
const now = at(tz, 10, 9);
it("treats an open-ended course as current", () => {
expect(
resolveIntakeActionability(
{ ...base, startsOn: null, endsOn: null },
now,
tz,
).intakeActionable,
).toBe(true);
});
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 });
});
it("refuses a record-only medication inside its course", () => {
const r = resolveIntakeActionability(
{ ...base, trackIntake: false },
now,
tz,
);
expect(r).toEqual({ courseStatus: "CURRENT", intakeActionable: false });
});
it("reports ENDED for a course that is also paused", () => {
const r = resolveIntakeActionability(
{ ...base, active: false },
at(tz, 22, 9),
tz,
);
expect(r.courseStatus).toBe("ENDED");
});
});
+55
View File
@@ -0,0 +1,55 @@
/**
* v1.39.4 (#1040) — whether a medication offers "taken" / "skip" today.
*
* One rule, resolved on the server and published on the medication read
* wire as `intakeActionable` (with the `courseStatus` it derives from), so
* the web cards, the table, the take-all flow and the iOS app all answer
* the same question the same way instead of each re-deriving it:
*
* - the medication is active (not paused or archived),
* - its intake is tracked (`trackIntake`, see `intake-tracking.ts`), and
* - its course includes today on the user's clock (`courseStatusAt`:
* `startsOn <= today <= endsOn`, calendar days, both ends inclusive).
*
* The due-slot question is already the schedule engine's: it caps every
* slot at `endsOn` and floors it at `startsOn`, so the reminder worker, the
* dashboard doses card and the today list never mint a dose outside the
* course. What was missing was the action row on the medication itself,
* which only looked at `active` and so kept offering a dose for a course
* that had ended. Within the course the row stays available between slots
* on purpose: taking a dose early is a supported flow with its own
* double-dose guard.
*
* 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.
*/
import {
courseStatusAt,
type CourseStatus,
} from "@/lib/medications/scheduling/recurrence";
import { isRecordOnly } from "@/lib/medications/intake-tracking";
export interface IntakeActionability {
courseStatus: CourseStatus;
intakeActionable: boolean;
}
export function resolveIntakeActionability(
medication: {
active: boolean;
trackIntake: boolean;
startsOn: Date | null;
endsOn: Date | null;
},
now: Date,
timeZone: string,
): IntakeActionability {
const courseStatus = courseStatusAt(medication, now, timeZone);
return {
courseStatus,
intakeActionable:
medication.active &&
!isRecordOnly(medication) &&
courseStatus === "CURRENT",
};
}
+5
View File
@@ -35,6 +35,7 @@ import {
} from "@/lib/medications/intake-tracking";
import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era";
import { floorWhole } from "@/lib/medications/units-per-dose";
import { resolveIntakeActionability } from "@/lib/medications/intake-actionable";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
export type MedicationsListResult = Array<Record<string, unknown>>;
@@ -312,6 +313,10 @@ export async function buildMedicationsList(
nextDueAt: display ? display.at.toISOString() : null,
nextDueOverdue: display?.overdue ?? false,
nextDueScheduleId: display?.scheduleId ?? null,
// v1.39.4 (#1040) — whether the card offers taken / skip today, and
// the course position it derives from. Resolved here so no client
// re-derives the calendar-day rule.
...resolveIntakeActionability(m, now, userTz),
stockUnitsRemaining,
stockDosesRemaining,
runwayDays,
@@ -182,6 +182,31 @@ 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.
@@ -262,6 +262,16 @@ export const medicationListEntry = medicationResource
// even though the shared base leaves them optional for the write paths.
nextDueAt: z.iso.datetime({ offset: true }).nullable(),
nextDueOverdue: z.boolean(),
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.",
),
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.",
),
lastTakenAt: z.iso
.datetime({ offset: true })
.nullable()
@@ -305,6 +315,16 @@ export const medicationListEntry = medicationResource
export const medicationDetailEntry = medicationResource
.extend({
category: medicationCategoryEnum,
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.",
),
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.",
),
stockUnitsRemaining: z
.number()
.nullable()