Merge branch 'fix/meds-ended-and-mental-health' into release/v1.39.4

This commit is contained in:
Marc-André Bombeck committed 2026-09-27 14:18:09 +02:00
commit 206da07e8c
42 files changed
+1075 -34

No files matched your search

+38
View File
@@ -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
+7
View File
@@ -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": {
+7
View File
@@ -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": {
+7
View File
@@ -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": {
+7
View File
@@ -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": {
+7
View File
@@ -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": {
+7
View File
@@ -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": {
+7
View File
@@ -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": {
@@ -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<string>();
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\(/);
});
});
+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,
+126 -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() }),
@@ -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<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;
}
@@ -21,6 +21,7 @@ import {
type WizardTreatmentRow,
} from "@/components/medications/wizard/wizard-payload";
import { encodeCadence } from "@/components/medications/scheduling/cadence-picker";
import { MEDICATION_CATEGORY_VALUES } from "@/lib/validations/medication";
import {
type CadenceKind,
DEFAULT_SUB_CONTROLS,
@@ -310,6 +311,13 @@ describe("WIZARD_TREATMENT_MAPPING — Step 2 row → request body", () => {
});
});
it("maps Psychische Gesundheit → (GENERIC, MENTAL_HEALTH) — new in v1.39.4", () => {
expect(WIZARD_TREATMENT_MAPPING.mentalHealth).toEqual({
treatmentClass: "GENERIC",
category: "MENTAL_HEALTH",
});
});
it("maps GLP-1-Injektion → (GLP1, OTHER) — only row with GLP1", () => {
expect(WIZARD_TREATMENT_MAPPING.glp1).toEqual({
treatmentClass: "GLP1",
@@ -327,6 +335,11 @@ describe("WIZARD_TREATMENT_MAPPING — Step 2 row → request body", () => {
"vitamin",
"supplement",
"antibiotic",
"mentalHealth",
"thyroid",
"digestive",
"skin",
"sleepAid",
"other",
];
for (const row of rows) {
@@ -347,6 +360,11 @@ describe("rowFromTreatment — reverse mapping for edit-hydration", () => {
it("ANTIBIOTIC category maps to the antibiotic row", () => {
expect(rowFromTreatment("GENERIC", "ANTIBIOTIC")).toBe("antibiotic");
expect(rowFromTreatment("GENERIC", "MENTAL_HEALTH")).toBe("mentalHealth");
expect(rowFromTreatment("GENERIC", "THYROID")).toBe("thyroid");
expect(rowFromTreatment("GENERIC", "DIGESTIVE")).toBe("digestive");
expect(rowFromTreatment("GENERIC", "SKIN")).toBe("skin");
expect(rowFromTreatment("GENERIC", "SLEEP_AID")).toBe("sleepAid");
});
it("unknown category falls back to 'other'", () => {
@@ -1240,3 +1258,75 @@ describe("as-needed mode (v1.16.11, #316)", () => {
expect(validateStep({ ...p, mode: null }, 5)).toBe(false);
});
});
describe("category on edit (v1.39.4)", () => {
function hydrate(category: string, treatmentClass = "GENERIC") {
return hydrateWizardPayload({
id: "m1",
name: "Levothyroxine",
dose: "50 µg",
category,
treatmentClass,
notificationsEnabled: true,
startsOn: null,
endsOn: null,
oneShot: false,
schedules: [
{
windowStart: "07:00",
windowEnd: "08:00",
timesOfDay: ["07:00"],
rrule: "FREQ=DAILY",
},
],
});
}
it("gives every stored category a wizard row of its own", () => {
const covered = new Set(
Object.values(WIZARD_TREATMENT_MAPPING).map((m) => m.category),
);
for (const value of MEDICATION_CATEGORY_VALUES) {
expect(covered.has(value), value).toBe(true);
}
});
it.each(["THYROID", "SKIN", "SLEEP_AID", "MENTAL_HEALTH"])(
"an edit that leaves the row alone keeps %s",
(category) => {
const body = buildCreateBody(
{ ...hydrate(category), name: "Renamed" },
"edit",
);
expect("category" in body).toBe(false);
expect("treatmentClass" in body).toBe(false);
},
);
it("never overwrites a category the wizard has no row for", () => {
const payload = hydrate("SOMETHING_NEWER");
expect(payload.treatmentRow).toBe("other");
const body = buildCreateBody(payload, "edit");
expect("category" in body).toBe(false);
});
it("keeps a GLP-1 medication's clinical category on an untouched edit", () => {
const body = buildCreateBody(hydrate("BLOOD_PRESSURE", "GLP1"), "edit");
expect("category" in body).toBe(false);
expect("treatmentClass" in body).toBe(false);
});
it("sends the new category when the row changed", () => {
const body = buildCreateBody(
{ ...hydrate("THYROID"), treatmentRow: "sleepAid" },
"edit",
);
expect(body.category).toBe("SLEEP_AID");
expect(body.treatmentClass).toBe("GENERIC");
});
it("always sends the category on create", () => {
const body = buildCreateBody({ ...hydrate("SKIN") }, "create");
expect(body.category).toBe("SKIN");
});
});
@@ -3,8 +3,12 @@
import {
Activity,
Apple,
BedDouble,
Brain,
Droplet,
Flame,
Gauge,
Hand,
Leaf,
type LucideIcon,
type LucideProps,
@@ -12,6 +16,7 @@ import {
Stethoscope,
Syringe,
Tag,
Utensils,
Wind,
} from "lucide-react";
import type { ComponentType } from "react";
@@ -35,6 +40,11 @@ const ROW_ICONS: Record<WizardTreatmentRow, ComponentType<LucideProps>> = {
vitamin: Apple,
supplement: Leaf,
antibiotic: ShieldCheck,
mentalHealth: Brain,
thyroid: Gauge,
digestive: Utensils,
skin: Hand,
sleepAid: BedDouble,
other: Tag as unknown as LucideIcon,
};
@@ -86,13 +86,21 @@ export type WizardTreatmentRow =
| "vitamin"
| "supplement"
| "antibiotic"
| "mentalHealth"
| "thyroid"
| "digestive"
| "skin"
| "sleepAid"
| "other";
/**
* The ten taxonomy rows in their visible order. maintainer-confirmed (D-1
* §3 Step 2): Blutdruck · Diabetes · Hormone · GLP-1-Injektion ·
* Schmerz · Allergie · Vitamine · Nahrungsergänzung · Antibiotikum ·
* Sonstiges.
* The taxonomy rows in their visible order (D-1 §3 Step 2): Blutdruck ·
* Diabetes · Hormone · GLP-1-Injektion · Schmerz · Allergie · Vitamine ·
* Nahrungsergänzung · Antibiotikum · Psychische Gesundheit · Schilddrüse ·
* Verdauung · Haut · Schlafmittel · Sonstiges. v1.39.4 gave every stored
* category a row; before, a thyroid, digestive, skin or sleep-aid
* medication hydrated as
* "Sonstiges" and an edit saved it back as OTHER.
*/
export const WIZARD_TREATMENT_ROWS: readonly WizardTreatmentRow[] = [
"bloodPressure",
@@ -104,6 +112,11 @@ export const WIZARD_TREATMENT_ROWS: readonly WizardTreatmentRow[] = [
"vitamin",
"supplement",
"antibiotic",
"mentalHealth",
"thyroid",
"digestive",
"skin",
"sleepAid",
"other",
];
@@ -128,6 +141,11 @@ export const WIZARD_TREATMENT_MAPPING: Record<
vitamin: { treatmentClass: "GENERIC", category: "VITAMIN" },
supplement: { treatmentClass: "GENERIC", category: "SUPPLEMENT" },
antibiotic: { treatmentClass: "GENERIC", category: "ANTIBIOTIC" },
mentalHealth: { treatmentClass: "GENERIC", category: "MENTAL_HEALTH" },
thyroid: { treatmentClass: "GENERIC", category: "THYROID" },
digestive: { treatmentClass: "GENERIC", category: "DIGESTIVE" },
skin: { treatmentClass: "GENERIC", category: "SKIN" },
sleepAid: { treatmentClass: "GENERIC", category: "SLEEP_AID" },
other: { treatmentClass: "GENERIC", category: "OTHER" },
};
@@ -159,6 +177,16 @@ export function rowFromTreatment(
return "supplement";
case "ANTIBIOTIC":
return "antibiotic";
case "MENTAL_HEALTH":
return "mentalHealth";
case "THYROID":
return "thyroid";
case "DIGESTIVE":
return "digestive";
case "SKIN":
return "skin";
case "SLEEP_AID":
return "sleepAid";
default:
return "other";
}
@@ -221,6 +249,14 @@ export interface WizardPayload {
doseAmount: string;
doseUnit: string;
treatmentRow: WizardTreatmentRow | null;
/**
* v1.39.4 — the row the edit hydrated to. An edit sends `category` and
* `treatmentClass` only when `treatmentRow` differs from it, so a stored
* category the wizard has no row for (a value a newer server or another
* client wrote) is never overwritten by saving an unrelated change.
* Absent on create.
*/
initialTreatmentRow?: WizardTreatmentRow | null;
/**
* v1.6.0 — route of administration. Drives the injection-site
* rotation preview (shown when `INJECTION`) and, paired with
@@ -592,8 +628,9 @@ export function firstInvalidIndex(
export interface CreateMedicationBody {
name: string;
dose: string;
category: MedicationCategoryValue;
treatmentClass: MedicationTreatmentClass;
/** Omitted on an edit that left the category row untouched (v1.39.4). */
category?: MedicationCategoryValue;
treatmentClass?: MedicationTreatmentClass;
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
deliveryForm: MedicationDeliveryForm;
/** v1.6.0 — doses per pen / vial. Omitted when inventory is off. */
@@ -803,11 +840,20 @@ export function buildCreateBody(
// reads 1), "1 1/2" and "1½" are 1.5, and a value the server would
// refuse is null. Step 3 cannot be left while it is null.
const parsedUnitsPerDose = parseUnitsPerDoseInput(committed.unitsPerDose);
// v1.39.4 — an edit that did not touch the category row leaves the stored
// category (and treatment class) alone rather than re-deriving them from
// the row, which cannot represent every stored value.
const keepStoredCategory =
forMode === "edit" &&
committed.initialTreatmentRow !== undefined &&
committed.treatmentRow === committed.initialTreatmentRow;
const body: CreateMedicationBody = {
name: committed.name.trim(),
dose,
category: mapping.category,
treatmentClass: mapping.treatmentClass,
...(!keepStoredCategory && {
category: mapping.category,
treatmentClass: mapping.treatmentClass,
}),
deliveryForm: committed.deliveryForm,
...(Number.isFinite(parsedDosesPerUnit) &&
parsedDosesPerUnit >= 1 && {
@@ -1189,6 +1235,10 @@ export function hydrateWizardPayload(
doseAmount: parsedDose.amount,
doseUnit: parsedDose.unit || base.doseUnit,
treatmentRow: rowFromTreatment(initial.treatmentClass, initial.category),
initialTreatmentRow: rowFromTreatment(
initial.treatmentClass,
initial.category,
),
deliveryForm: normaliseDeliveryForm(initial.deliveryForm),
dosesPerUnit:
typeof initial.dosesPerUnit === "number" && initial.dosesPerUnit >= 1
@@ -12,6 +12,13 @@ const mocks = vi.hoisted(() => ({
buildRecordsBackupSection: vi.fn(),
}));
// The category side table is raw SQL on the shared client; the round trip
// through it is proven in tests/integration/backup-round-trip.test.ts.
vi.mock("@/lib/medication-category", () => ({
ensureMedicationCategoryTable: vi.fn(async () => {}),
getMedicationCategories: vi.fn(async () => ({})),
setMedicationCategory: vi.fn(async () => "OTHER"),
}));
vi.mock("@/lib/export/paged-measurements", () => ({
iterateMeasurementPages: mocks.iterateMeasurementPages,
}));
@@ -31,6 +31,13 @@ const mocks = vi.hoisted(() => ({
transaction: vi.fn(),
}));
// The category side table is raw SQL on the shared client; the round trip
// through it is proven in tests/integration/backup-round-trip.test.ts.
vi.mock("@/lib/medication-category", () => ({
ensureMedicationCategoryTable: vi.fn(async () => {}),
getMedicationCategories: vi.fn(async () => ({})),
setMedicationCategory: vi.fn(async () => "OTHER"),
}));
vi.mock("@/lib/db", () => ({
prisma: {
dataBackup: { findUnique: vi.fn() },
+18
View File
@@ -706,6 +706,24 @@ export const NOT_IN_BACKUP_MODELS: Readonly<Record<string, string>> = {
"Feedback on generated recommendations that are themselves DERIVED. Restoring the feedback without the item it judged leaves an orphan.",
};
/**
* Tables created with raw SQL at runtime rather than declared in the schema.
* Every check above walks `schema.prisma`, so a table outside it is invisible
* to all of them — which is how the medication category went missing from
* every backup until v1.39.4. The guard beside this file finds each
* `CREATE TABLE IF NOT EXISTS` in the source and demands a verdict here, or a
* schema model mapped to the same name.
*/
export const RAW_SQL_TABLES: Readonly<
Record<string, { verdict: "BACKED_UP" | "NOT_IN_BACKUP"; reason: string }>
> = {
medication_categories: {
verdict: "BACKED_UP",
reason:
"The clinical category the person chose for each medication. Carried inside its medication in both backup formats and written back in the restore transaction; without it every restored medication reads as Other.",
},
};
/** Auth material: not user data, and excluded from the wipe for the same reason. */
export const AUTH_MODELS_OUT_OF_SCOPE: readonly string[] = [
"Session",
+11
View File
@@ -118,6 +118,7 @@ import {
type OnboardingBackupCounts,
type OnboardingBackupSection,
} from "@/lib/export/onboarding-backup";
import { getMedicationCategories } from "@/lib/medication-category";
export interface FullBackupCounts
extends
@@ -943,6 +944,14 @@ export async function buildFullBackupPayload(
: moodEntries!,
};
// The clinical category lives in a raw side table rather than on the
// medication row, so no Prisma include reaches it. Before v1.39.4 no backup
// carried it and every restored medication came back as "Other".
const categoryByMedication = await getMedicationCategories(
medications.map((m) => m.id),
prisma,
);
// Where each archived era sits in its OWN drug's ordered list. Built once
// here rather than per row, and scoped per medication because the supersede
// pointer never crosses a drug — every route that writes it scopes the
@@ -1008,6 +1017,8 @@ export async function buildFullBackupPayload(
// record only. Both purposes carry it: a restore that dropped it would
// start reminding them about a medication they chose not to track.
trackIntake: m.trackIntake,
// Both purposes: the category is something the person chose.
category: categoryByMedication[m.id] ?? "OTHER",
// Both purposes carry the creation instant: it is the floor of the
// medication's expected slots, so a restore stamped with the restore
// time would read the whole restored history as predating the
+14
View File
@@ -101,6 +101,10 @@ import { restoreAwardsData } from "@/lib/export/awards-backup";
import { restoreEnvironmentData } from "@/lib/export/environment-backup";
import { restoreEcgData } from "@/lib/export/ecg-backup";
import { restoredMedicationCreatedAt } from "@/lib/export/medication-created-at";
import {
ensureMedicationCategoryTable,
setMedicationCategory,
} from "@/lib/medication-category";
import { invalidateUserData } from "@/lib/cache/invalidate";
import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client";
import { stampSyncReset } from "@/lib/sync/reset";
@@ -671,6 +675,10 @@ export async function restoreBackup(
cleared: RestoreResponse["cleared"];
skipped: RestoreSkipSummary;
};
// The category side table is created lazily. Doing that here, before the
// transaction, keeps its DDL (which takes a table lock) out of a
// transaction that already holds row locks on the same table.
await ensureMedicationCategoryTable();
try {
report("clearing");
outcome = await prisma.$transaction(
@@ -1245,6 +1253,12 @@ export async function restoreBackup(
});
restoredMedicationIds.add(created.id);
if (!medByName.has(m.name)) medByName.set(m.name, created.id);
// v1.39.4 — the clinical category lives in a side table keyed on the
// medication id, so it is written after the row exists and inside the
// same transaction. OTHER is what a missing row already reads as.
if (m.category && m.category !== "OTHER") {
await setMedicationCategory(created.id, m.category, tx);
}
// ── The archived schedule eras, in two passes ────────────────
//
+16 -3
View File
@@ -7,6 +7,17 @@ export type MedicationCategory = (typeof MEDICATION_CATEGORIES)[number];
const DEFAULT_CATEGORY: MedicationCategory = "OTHER";
/**
* The raw-SQL surface the helpers below need. The default is the shared
* client; the backup builder and the restore pass their own (the restore's
* transaction client, so a category row lands in the same transaction as
* the medication it points at and its foreign key can see that row).
*/
type RawSqlClient = Pick<
typeof prisma,
"$queryRawUnsafe" | "$executeRawUnsafe"
>;
let ensureTablePromise: Promise<void> | null = null;
function normalizeCategory(input: unknown): MedicationCategory {
@@ -16,7 +27,7 @@ function normalizeCategory(input: unknown): MedicationCategory {
: DEFAULT_CATEGORY;
}
async function ensureMedicationCategoryTable() {
export async function ensureMedicationCategoryTable() {
if (!ensureTablePromise) {
ensureTablePromise = (async () => {
await prisma.$executeRawUnsafe(`
@@ -42,11 +53,12 @@ async function ensureMedicationCategoryTable() {
export async function getMedicationCategories(
medicationIds: string[],
client: RawSqlClient = prisma,
): Promise<Record<string, MedicationCategory>> {
if (medicationIds.length === 0) return {};
await ensureMedicationCategoryTable();
const rows = await prisma.$queryRawUnsafe<
const rows = await client.$queryRawUnsafe<
Array<{ medication_id: string; category: string }>
>(
`
@@ -70,11 +82,12 @@ export async function getMedicationCategories(
export async function setMedicationCategory(
medicationId: string,
category: unknown,
client: RawSqlClient = prisma,
) {
await ensureMedicationCategoryTable();
const normalized = normalizeCategory(category);
await prisma.$executeRawUnsafe(
await client.$executeRawUnsafe(
`
INSERT INTO medication_categories (medication_id, category, updated_at)
VALUES ($1, $2, NOW())
@@ -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");
});
});
+1
View File
@@ -34,6 +34,7 @@ const MEDICATION_CATEGORY_KEYS: Record<string, string> = {
SLEEP_AID: "medications.categorySleepAid",
DIABETES: "medications.categoryDiabetes",
ANTIBIOTIC: "medications.categoryAntibiotic",
MENTAL_HEALTH: "medications.categoryMentalHealth",
OTHER: "medications.categoryOther",
};
+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()
+5
View File
@@ -358,6 +358,11 @@ const medicationSchema = z
// v1.39.1 (#1033) — absent in a backup written before the field existed;
// the restore defaults it to tracking on, the behaviour those had.
trackIntake: z.boolean().optional(),
// v1.39.4 — the clinical category. Absent in older files. A string, not
// the enum: a file from a newer server may name a category this one does
// not know, and the restore reads that as OTHER rather than refusing the
// whole file.
category: z.string().max(64).optional(),
deliveryForm: z.enum(MedicationDeliveryForm).optional(),
trackInjectionSites: z.boolean().optional(),
allowedInjectionSites: z.array(z.enum(InjectionSite)).optional(),
+4 -1
View File
@@ -68,7 +68,9 @@ export const rxNormCodeField = z
* wizard's Step 2 taxonomy can write a first-class bucket for those
* two rows instead of collapsing them into `OTHER`. The column is a
* plain TEXT field — no Prisma enum exists to migrate; the Zod values
* list is the only enforcement layer.
* list is the only enforcement layer. v1.39.4 adds `MENTAL_HEALTH`
* (antidepressants, anxiolytics, mood stabilisers, ADHD medication), again
* without a migration.
*/
export const MEDICATION_CATEGORY_VALUES = [
"BLOOD_PRESSURE",
@@ -83,6 +85,7 @@ export const MEDICATION_CATEGORY_VALUES = [
"SLEEP_AID",
"DIABETES",
"ANTIBIOTIC",
"MENTAL_HEALTH",
"OTHER",
] as const;
export type MedicationCategoryValue =
@@ -63,6 +63,10 @@ import {
encryptWaveformToBytes,
} from "@/lib/withings/ecg-waveform-codec";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import {
getMedicationCategories,
setMedicationCategory,
} from "@/lib/medication-category";
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
import { decryptNoteFromBytes } from "@/lib/labs/store";
import { decryptContextFromBytes } from "@/lib/labs/biomarker-store";
@@ -2755,6 +2759,74 @@ describe("every model the plan claims two-ended survives a real restore", () =>
* the target's key. A restore that dropped the readable JSON would bring
* the thread back with every answer's table missing and nothing to say so.
*/
it.each(["disaster-recovery", "portable-export"] as const)(
"carries each medication's category through a %s round trip",
async (purpose) => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await createOwner(prisma);
const thyroid = await prisma.medication.create({
data: { userId: OWNER_ID, name: "Levothyroxine", dose: "50 µg" },
});
const mood = await prisma.medication.create({
data: { userId: OWNER_ID, name: "Sertraline", dose: "50 mg" },
});
await prisma.medication.create({
data: { userId: OWNER_ID, name: "Unfiled", dose: "1 tab" },
});
await setMedicationCategory(thyroid.id, "THYROID");
await setMedicationCategory(mood.id, "MENTAL_HEALTH");
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose,
});
const exported = Object.fromEntries(
(payload.medications as Array<{ name: string; category?: string }>).map(
(m) => [m.name, m.category],
),
);
expect(exported).toEqual({
Levothyroxine: "THYROID",
Sertraline: "MENTAL_HEALTH",
Unfiled: "OTHER",
});
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
const backup = await prisma.dataBackup.create({
data: {
userId: OWNER_ID,
type: "TWO_ENDED_ROUND_TRIP",
data: encrypt(JSON.stringify(payload)),
},
});
const response = await POST(
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
}) as never,
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status, JSON.stringify(await response.json())).toBe(200);
const restored = await prisma.medication.findMany({
where: { userId: OWNER_ID },
select: { id: true, name: true },
});
const categories = await getMedicationCategories(
restored.map((m) => m.id),
);
expect(
Object.fromEntries(restored.map((m) => [m.name, categories[m.id]])),
).toEqual({
Levothyroxine: "THYROID",
Sertraline: "MENTAL_HEALTH",
Unfiled: "OTHER",
});
},
);
it("carries a turn's tables readable in a portable file and seals them again on restore", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);