mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge branch 'fix/meds-ended-and-mental-health' into release/v1.39.4
This commit is contained in:
commit
206da07e8c
42 files changed
+1075
-34
No files matched your search
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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\(/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ────────────────
|
||||
//
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user