mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(medications): a sortable table view with an order you choose
The medications page gains a compact table beside the card view — one row per medication with the same served-due status pill, next-intake line, cadence-scaled adherence bars, dose-derived stock and the take and skip actions the cards use, all from the payloads the cards already read; the list response aggregates stock in the same round trip. Columns sort tri-state with nulls last, the default order is yours to arrange — the same drag dialog the dashboard and insights use, applied to both views — and the chosen view and order persist per user on their own settings column with field-scoped writes, so toggling the view never touches the order. Inactive medications pin to the end, the first column stays put under horizontal scroll, and the loading skeleton follows the view you left.
This commit is contained in:
1 parent
d142a6ed31
commit
e21dfd040e
31 files changed
+3382
-65
No files matched your search
@@ -17,6 +17,9 @@ vi.mock("@/lib/db", () => ({
|
||||
medicationScheduleRevision: {
|
||||
groupBy: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
medicationInventoryItem: {
|
||||
groupBy: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
medicationCategoryAssignment: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
@@ -390,6 +393,9 @@ describe("GET /api/medications — todayEventCount counts only actioned rows (v1
|
||||
vi.mocked(prisma.medicationScheduleRevision.groupBy).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationInventoryItem.groupBy).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
// The handler body takes no parameters; `apiHandler` still receives
|
||||
// the request through the wrapper's rest args.
|
||||
const res = await (
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* v1.16.10 — `/api/medications/layout` route.
|
||||
*
|
||||
* Mirrors the insights-layout test deck: GET returns the defaults for
|
||||
* users who have not saved a blob, PUT round-trips through the
|
||||
* serializer with preserve-when-absent semantics for `view` / `order`,
|
||||
* 422s carry the multi-issue envelope, DELETE returns the canonical
|
||||
* defaults.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
toJson: (v: unknown) => v,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
emitIfSampled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PUT, DELETE } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
import { __resetAuditDedupMemoForTests } from "@/lib/audit-dedup";
|
||||
import { DEFAULT_MEDICATION_LIST_LAYOUT } from "@/lib/medication-list-layout";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "tester",
|
||||
role: "USER" as const,
|
||||
displayName: null,
|
||||
},
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as () => Promise<Response>;
|
||||
const callPut = PUT as unknown as (req: NextRequest) => Promise<Response>;
|
||||
const callDelete = DELETE as unknown as () => Promise<Response>;
|
||||
|
||||
function makeReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications/layout", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
__resetAllCachesForTests();
|
||||
__resetAuditDedupMemoForTests();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/medications/layout", () => {
|
||||
it("returns the defaults when the user has no saved blob", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
medicationListLayoutJson: null,
|
||||
} as never);
|
||||
|
||||
const res = await callGet();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: typeof DEFAULT_MEDICATION_LIST_LAYOUT;
|
||||
};
|
||||
expect(body.data).toEqual(DEFAULT_MEDICATION_LIST_LAYOUT);
|
||||
});
|
||||
|
||||
it("does not lazy-write a row on GET", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
medicationListLayoutJson: null,
|
||||
} as never);
|
||||
|
||||
await callGet();
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the saved blob through the resolver", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
medicationListLayoutJson: {
|
||||
version: 1,
|
||||
view: "table",
|
||||
order: ["med-b", "med-a"],
|
||||
},
|
||||
} as never);
|
||||
|
||||
const res = await callGet();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { view: string; order: string[] };
|
||||
};
|
||||
expect(body.data.view).toBe("table");
|
||||
expect(body.data.order).toEqual(["med-b", "med-a"]);
|
||||
});
|
||||
|
||||
it("collapses a malformed stored blob onto the defaults instead of failing", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
medicationListLayoutJson: { view: "kanban", order: "nope" },
|
||||
} as never);
|
||||
|
||||
const res = await callGet();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { view: string; order: string[] };
|
||||
};
|
||||
expect(body.data.view).toBe("cards");
|
||||
expect(body.data.order).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/layout — preserve-when-absent", () => {
|
||||
it("persists a full blob and echoes the normalised result", async () => {
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await callPut(
|
||||
makeReq({ version: 1, view: "table", order: ["med-a", "med-b"] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { version: number; view: string; order: string[] };
|
||||
};
|
||||
expect(body.data).toEqual({
|
||||
version: 1,
|
||||
view: "table",
|
||||
order: ["med-a", "med-b"],
|
||||
});
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as {
|
||||
where: { id: string };
|
||||
data: { medicationListLayoutJson: unknown };
|
||||
};
|
||||
expect(call.where.id).toBe("user-1");
|
||||
expect(call.data.medicationListLayoutJson).toEqual({
|
||||
version: 1,
|
||||
view: "table",
|
||||
order: ["med-a", "med-b"],
|
||||
});
|
||||
// No stored-blob read needed — both fields were on the body.
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a view-only PUT preserves the stored manual order", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
medicationListLayoutJson: {
|
||||
version: 1,
|
||||
view: "cards",
|
||||
order: ["med-z", "med-a"],
|
||||
},
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await callPut(makeReq({ version: 1, view: "table" }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { view: string; order: string[] };
|
||||
};
|
||||
expect(body.data.view).toBe("table");
|
||||
expect(body.data.order).toEqual(["med-z", "med-a"]);
|
||||
});
|
||||
|
||||
it("an order-only PUT preserves the stored view", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
medicationListLayoutJson: { version: 1, view: "table", order: [] },
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await callPut(
|
||||
makeReq({ version: 1, order: ["med-1", "med-2"] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { view: string; order: string[] };
|
||||
};
|
||||
expect(body.data.view).toBe("table");
|
||||
expect(body.data.order).toEqual(["med-1", "med-2"]);
|
||||
});
|
||||
|
||||
it("dedupes a repeated id in the submitted order", async () => {
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await callPut(
|
||||
makeReq({ version: 1, view: "cards", order: ["a", "b", "a"] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { order: string[] } };
|
||||
expect(body.data.order).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/layout — 422 multi-issue envelope", () => {
|
||||
it("rejects an unknown view and a malformed order in one envelope", async () => {
|
||||
const res = await callPut(
|
||||
makeReq({ version: 1, view: "kanban", order: [""] }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: { issues: Array<{ path: string }> };
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
const paths = body.details.issues.map((i) => i.path);
|
||||
expect(paths.some((p) => p.startsWith("view"))).toBe(true);
|
||||
expect(paths.some((p) => p.startsWith("order"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a wrong version", async () => {
|
||||
const res = await callPut(makeReq({ version: 2, view: "table" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("writes one deduped audit breadcrumb for repeated 422s", async () => {
|
||||
const res1 = await callPut(makeReq({ version: 9 }));
|
||||
const res2 = await callPut(makeReq({ version: 9 }));
|
||||
expect(res1.status).toBe(422);
|
||||
expect(res2.status).toBe(422);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string };
|
||||
};
|
||||
expect(call.data.userId).toBe("user-1");
|
||||
expect(call.data.action).toBe("medication.layout.validation-failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/medications/layout", () => {
|
||||
it("clears the saved blob and returns the defaults", async () => {
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await callDelete();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: typeof DEFAULT_MEDICATION_LIST_LAYOUT;
|
||||
};
|
||||
expect(body.data).toEqual(DEFAULT_MEDICATION_LIST_LAYOUT);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as {
|
||||
data: { medicationListLayoutJson: unknown };
|
||||
};
|
||||
expect(call.data.medicationListLayoutJson).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* GET / PUT / DELETE medications list presentation.
|
||||
*
|
||||
* GET returns the resolved presentation (view + manual order, defaults
|
||||
* merged in if the user hasn't customised yet). PUT updates it with
|
||||
* preserve-when-absent semantics — a view-only PUT keeps the stored
|
||||
* order and vice versa, matching the `heroVisible` contract on
|
||||
* `/api/dashboard/widgets`. DELETE resets to default.
|
||||
*
|
||||
* Mirrors the shape and semantics of `/api/insights/layout`; the blob
|
||||
* lives on its own `User` column (`medication_list_layout_json`) per
|
||||
* the per-surface-column convention.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
buildPayloadDiagnostic,
|
||||
safeJson,
|
||||
returnAllZodIssues,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import {
|
||||
resolveMedicationListLayout,
|
||||
serializeMedicationListLayout,
|
||||
DEFAULT_MEDICATION_LIST_LAYOUT,
|
||||
MEDICATION_LIST_VIEWS,
|
||||
MEDICATION_ORDER_ID_MAX_LENGTH,
|
||||
MEDICATION_ORDER_MAX_ENTRIES,
|
||||
type MedicationListLayout,
|
||||
} from "@/lib/medication-list-layout";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { z } from "zod/v4";
|
||||
import { invalidateUserMedicationListLayout } from "@/lib/cache/invalidate";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
|
||||
import { shouldEmitAuditRow } from "@/lib/audit-dedup";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
const layoutSchema = z.object({
|
||||
version: z.literal(1),
|
||||
// Both fields are optional so a client can PUT exactly the field it
|
||||
// changed; the handler merges the absent one from the stored blob
|
||||
// (preserve-when-absent, like `heroVisible` on the dashboard layout).
|
||||
view: z.enum(MEDICATION_LIST_VIEWS).optional(),
|
||||
// Medication ids are opaque here — display-only ordering, unknown /
|
||||
// deleted ids are ignored at apply time, so the schema bounds size
|
||||
// and shape but does not assert ownership.
|
||||
order: z
|
||||
.array(z.string().min(1).max(MEDICATION_ORDER_ID_MAX_LENGTH))
|
||||
.max(MEDICATION_ORDER_MAX_ENTRIES)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
async function buildMedicationListLayout(
|
||||
userId: string,
|
||||
): Promise<MedicationListLayout> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { medicationListLayoutJson: true },
|
||||
});
|
||||
return resolveMedicationListLayout(row?.medicationListLayoutJson);
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// 5-minute TTL matches the dashboard-widgets / insights-layout
|
||||
// caches; the blob changes only on a view toggle or an order save,
|
||||
// which invalidates via `invalidateUserMedicationListLayout()`.
|
||||
const layout = await cached(
|
||||
caches.medicationListLayout as ServerCache<MedicationListLayout>,
|
||||
user.id,
|
||||
() => buildMedicationListLayout(user.id),
|
||||
annotate,
|
||||
);
|
||||
return apiSuccess(layout);
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = layoutSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// Multi-issue 422 envelope matches the dashboard-widgets / insights
|
||||
// layout routes; the wide-event line carries the redacted payload
|
||||
// shape for operator debugging without leaking the raw body.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
const payloadDiagnostic = buildPayloadDiagnostic(
|
||||
redactSensitiveFields(body),
|
||||
);
|
||||
annotate({
|
||||
action: { name: "medication.layout.validation-failed" },
|
||||
meta: {
|
||||
issue_count: issues.length,
|
||||
...payloadDiagnostic,
|
||||
zod_issues: issues,
|
||||
},
|
||||
});
|
||||
if (
|
||||
shouldEmitAuditRow(
|
||||
user.id,
|
||||
"medication.layout.validation-failed",
|
||||
Date.now(),
|
||||
)
|
||||
) {
|
||||
// Best-effort breadcrumb — never block the 422 on a write miss.
|
||||
// `message` strips from the audit row so a Zod code that embeds
|
||||
// the offending value cannot leak through the audit surface.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "medication.layout.validation-failed",
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — validation response is the contract, audit row is best-effort */
|
||||
});
|
||||
}
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
// Preserve-when-absent: a PUT carrying only `view` must not wipe the
|
||||
// stored manual order, and an order-only PUT must not reset the view.
|
||||
// One stored-blob read covers both fallbacks.
|
||||
let mergedView = parsed.data.view;
|
||||
let mergedOrder = parsed.data.order;
|
||||
if (mergedView === undefined || mergedOrder === undefined) {
|
||||
const existing = await buildMedicationListLayout(user.id);
|
||||
if (mergedView === undefined) mergedView = existing.view;
|
||||
if (mergedOrder === undefined) mergedOrder = existing.order;
|
||||
}
|
||||
const normalized = serializeMedicationListLayout({
|
||||
view: mergedView,
|
||||
order: mergedOrder,
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { medicationListLayoutJson: toJson(normalized) },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "medication.layout.update" },
|
||||
meta: { view: normalized.view, order_count: normalized.order.length },
|
||||
});
|
||||
|
||||
invalidateUserMedicationListLayout(user.id);
|
||||
|
||||
return apiSuccess(normalized);
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { medicationListLayoutJson: Prisma.JsonNull },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "medication.layout.reset" } });
|
||||
|
||||
invalidateUserMedicationListLayout(user.id);
|
||||
|
||||
return apiSuccess(DEFAULT_MEDICATION_LIST_LAYOUT);
|
||||
});
|
||||
@@ -54,8 +54,15 @@ async function buildMedicationsList(
|
||||
todayStartUtc.getTime() - OVERDUE_LOOKBACK_MS,
|
||||
);
|
||||
|
||||
const [medications, latestIntakes, todayEvents, resolvedEvents, eraFloors] =
|
||||
await Promise.all([
|
||||
const [
|
||||
medications,
|
||||
latestIntakes,
|
||||
todayEvents,
|
||||
resolvedEvents,
|
||||
eraFloors,
|
||||
usableStock,
|
||||
inventoryCounts,
|
||||
] = await Promise.all([
|
||||
prisma.medication.findMany({
|
||||
where: { userId },
|
||||
include: { schedules: true },
|
||||
@@ -117,6 +124,28 @@ async function buildMedicationsList(
|
||||
where: { medication: { userId }, supersededByRevisionId: null },
|
||||
_max: { validUntil: true },
|
||||
}),
|
||||
// v1.16.10 — usable stock per medication (one batched aggregate,
|
||||
// not per-row): the sum of `unitsRemaining` over ACTIVE / IN_USE
|
||||
// containers with units left — the same usable-container filter
|
||||
// the GLP-1 details endpoint applies. Feeds the list payload's
|
||||
// `stockUnitsRemaining` / `stockDosesRemaining` for the table view.
|
||||
prisma.medicationInventoryItem.groupBy({
|
||||
by: ["medicationId"],
|
||||
where: {
|
||||
userId,
|
||||
state: { in: ["ACTIVE", "IN_USE"] },
|
||||
unitsRemaining: { gt: 0 },
|
||||
},
|
||||
_sum: { unitsRemaining: true },
|
||||
}),
|
||||
// …and the any-state item count, so a medication whose containers
|
||||
// are all used up / expired reads as stock 0 (tracking is ON, the
|
||||
// supply ran out) instead of null (tracking off).
|
||||
prisma.medicationInventoryItem.groupBy({
|
||||
by: ["medicationId"],
|
||||
where: { userId },
|
||||
_count: { id: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolvedSlotsByMedId = new Map<string, ResolvedSlotMark[]>();
|
||||
@@ -152,6 +181,15 @@ async function buildMedicationsList(
|
||||
),
|
||||
);
|
||||
|
||||
const usableUnitsByMedId = new Map<string, number>();
|
||||
for (const entry of usableStock) {
|
||||
usableUnitsByMedId.set(
|
||||
entry.medicationId,
|
||||
entry._sum.unitsRemaining ?? 0,
|
||||
);
|
||||
}
|
||||
const trackedMedIds = new Set(inventoryCounts.map((e) => e.medicationId));
|
||||
|
||||
let categoryMap: Record<string, string> = {};
|
||||
try {
|
||||
categoryMap = await getMedicationCategories(medications.map((m) => m.id));
|
||||
@@ -185,6 +223,17 @@ async function buildMedicationsList(
|
||||
resolvedSlots: resolvedSlotsByMedId.get(m.id) ?? [],
|
||||
eraStart: eraStartByMedId.get(m.id) ?? null,
|
||||
});
|
||||
// v1.16.10 — dose-derived stock for the table view. NULL when the
|
||||
// medication has no inventory items at all (tracking off); 0 when
|
||||
// tracking is on but every container is used up / expired.
|
||||
const tracksInventory = trackedMedIds.has(m.id);
|
||||
const stockUnitsRemaining = tracksInventory
|
||||
? (usableUnitsByMedId.get(m.id) ?? 0)
|
||||
: null;
|
||||
const stockDosesRemaining =
|
||||
stockUnitsRemaining === null
|
||||
? null
|
||||
: Math.floor(stockUnitsRemaining / Math.max(1, m.unitsPerDose));
|
||||
return {
|
||||
...m,
|
||||
category: categoryMap[m.id] ?? "OTHER",
|
||||
@@ -192,6 +241,8 @@ async function buildMedicationsList(
|
||||
todayEventCount: todayEventCountByMedId[m.id] ?? 0,
|
||||
nextDueAt: display ? display.at.toISOString() : null,
|
||||
nextDueOverdue: display?.overdue ?? false,
|
||||
stockUnitsRemaining,
|
||||
stockDosesRemaining,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* v1.16.10 — /medications view-toggle wiring.
|
||||
*
|
||||
* Pins that the page renders the view the PERSISTED preference names
|
||||
* (cards vs table from `GET /api/medications/layout`, seeded into the
|
||||
* query cache), that the header toggle announces the active segment,
|
||||
* and that the manual medication order applies to BOTH views. The PUT
|
||||
* side of the persistence contract is pinned in
|
||||
* `src/lib/queries/__tests__/use-medication-list-layout.test.ts`.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(""),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: { timezone: "Europe/Berlin" },
|
||||
}),
|
||||
}));
|
||||
|
||||
// The create wizard and the dialogs are closed in every assertion here;
|
||||
// mock them away so the page render stays scoped to the list surface.
|
||||
vi.mock("@/components/medications/wizard/MedicationWizardDialog", () => ({
|
||||
MedicationWizardDialog: () => null,
|
||||
}));
|
||||
vi.mock("@/components/medications/log-intake-dialog", () => ({
|
||||
LogIntakeDialog: () => null,
|
||||
}));
|
||||
vi.mock("@/components/medications/medication-reorder-dialog", () => ({
|
||||
MedicationReorderDialog: () => null,
|
||||
}));
|
||||
|
||||
import MedicationsPage from "@/app/medications/page";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { MedicationListLayout } from "@/lib/medication-list-layout";
|
||||
|
||||
const pastWindow = {
|
||||
windowStart: "01:00",
|
||||
windowEnd: "02:00",
|
||||
label: null,
|
||||
daysOfWeek: null,
|
||||
dose: null,
|
||||
};
|
||||
|
||||
const MEDS = [
|
||||
{
|
||||
id: "m1",
|
||||
name: "Ramipril",
|
||||
dose: "5 mg",
|
||||
category: "OTHER",
|
||||
active: true,
|
||||
notificationsEnabled: true,
|
||||
pausedAt: null,
|
||||
lastTakenAt: null,
|
||||
todayEventCount: 0,
|
||||
nextDueAt: null,
|
||||
nextDueOverdue: false,
|
||||
stockDosesRemaining: null,
|
||||
schedules: [{ id: "s1", ...pastWindow }],
|
||||
},
|
||||
{
|
||||
id: "m2",
|
||||
name: "Aspirin",
|
||||
dose: "100 mg",
|
||||
category: "OTHER",
|
||||
active: true,
|
||||
notificationsEnabled: true,
|
||||
pausedAt: null,
|
||||
lastTakenAt: null,
|
||||
todayEventCount: 0,
|
||||
nextDueAt: null,
|
||||
nextDueOverdue: false,
|
||||
stockDosesRemaining: null,
|
||||
schedules: [{ id: "s2", ...pastWindow }],
|
||||
},
|
||||
];
|
||||
|
||||
function renderPage(layout: MedicationListLayout): string {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0, staleTime: Infinity },
|
||||
},
|
||||
});
|
||||
client.setQueryData(queryKeys.medications(), MEDS);
|
||||
client.setQueryData(queryKeys.medicationListLayout(), layout);
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<QueryClientProvider client={client}>
|
||||
<MedicationsPage />
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-06-02T12:00:00Z"));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("/medications — view renders from the persisted preference", () => {
|
||||
it("renders the card grid for view=cards and announces the active segment", () => {
|
||||
const html = renderPage({ version: 1, view: "cards", order: [] });
|
||||
|
||||
expect(html).not.toContain("<table");
|
||||
expect(html).toContain('data-slot="card"');
|
||||
// Toggle present, group named, the cards segment pressed.
|
||||
expect(html).toContain('aria-label="View"');
|
||||
expect(html).toMatch(
|
||||
/data-slot="medications-view-cards"[^>]*aria-pressed="true"|aria-pressed="true"[^>]*data-slot="medications-view-cards"/,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/data-slot="medications-view-table"[^>]*aria-pressed="false"|aria-pressed="false"[^>]*data-slot="medications-view-table"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the table for view=table", () => {
|
||||
const html = renderPage({ version: 1, view: "table", order: [] });
|
||||
|
||||
expect(html).toContain("<table");
|
||||
expect(html).toMatch(
|
||||
/data-slot="medications-view-table"[^>]*aria-pressed="true"|aria-pressed="true"[^>]*data-slot="medications-view-table"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("offers the manual-order editor from the header", () => {
|
||||
const html = renderPage({ version: 1, view: "cards", order: [] });
|
||||
expect(html).toContain('aria-label="Adjust order"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("/medications — the manual order applies to BOTH views", () => {
|
||||
// Alphabetical default would put Aspirin first; the saved order pins
|
||||
// Ramipril to the top. Both views must follow it.
|
||||
const layout: MedicationListLayout = {
|
||||
version: 1,
|
||||
view: "cards",
|
||||
order: ["m1", "m2"],
|
||||
};
|
||||
|
||||
it("orders the cards by the saved order", () => {
|
||||
const html = renderPage(layout);
|
||||
expect(html.indexOf("Ramipril")).toBeLessThan(html.indexOf("Aspirin"));
|
||||
});
|
||||
|
||||
it("orders the table rows by the saved order", () => {
|
||||
const html = renderPage({ ...layout, view: "table" });
|
||||
expect(html.indexOf("Ramipril")).toBeLessThan(html.indexOf("Aspirin"));
|
||||
});
|
||||
|
||||
it("falls back to the alphabetical default without a saved order", () => {
|
||||
const html = renderPage({ version: 1, view: "cards", order: [] });
|
||||
expect(html.indexOf("Aspirin")).toBeLessThan(html.indexOf("Ramipril"));
|
||||
});
|
||||
});
|
||||
+125
-41
@@ -10,6 +10,12 @@ import { MedicationWizardDialog } from "@/components/medications/wizard/Medicati
|
||||
import { MedicationCard } from "@/components/medications/medication-card";
|
||||
import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card";
|
||||
import { LogIntakeDialog } from "@/components/medications/log-intake-dialog";
|
||||
import {
|
||||
MedicationTable,
|
||||
MedicationTableSkeleton,
|
||||
} from "@/components/medications/medication-table";
|
||||
import { MedicationViewToggle } from "@/components/medications/medication-view-toggle";
|
||||
import { MedicationReorderDialog } from "@/components/medications/medication-reorder-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
@@ -20,8 +26,10 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { CheckCircle2, Loader2, Pill, Plus } from "lucide-react";
|
||||
import { ArrowDownUp, CheckCircle2, Loader2, Pill, Plus } from "lucide-react";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layout";
|
||||
import { applyMedicationOrder } from "@/lib/medications/medication-order";
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
@@ -75,6 +83,14 @@ interface Medication {
|
||||
atcCode?: string | null;
|
||||
/** v1.9.0 — optional RxNorm RxCUI (secondary FHIR coding). */
|
||||
rxNormCode?: string | null;
|
||||
/**
|
||||
* v1.16.10 — usable inventory units left across the medication's
|
||||
* containers (ACTIVE / IN_USE with units remaining). Null = inventory
|
||||
* tracking off (no items ever registered).
|
||||
*/
|
||||
stockUnitsRemaining?: number | null;
|
||||
/** v1.16.10 — dose-derived stock (`stockUnitsRemaining / unitsPerDose`). */
|
||||
stockDosesRemaining?: number | null;
|
||||
schedules: Schedule[];
|
||||
}
|
||||
|
||||
@@ -133,6 +149,16 @@ export default function MedicationsPage() {
|
||||
// two paths: log an intake (incl. a backdated one) against an existing
|
||||
// medication, or create a new medication (the existing wizard).
|
||||
const [logIntakeOpen, setLogIntakeOpen] = useState(false);
|
||||
// v1.16.10 — manual-order editor (shared by both views).
|
||||
const [reorderOpen, setReorderOpen] = useState(false);
|
||||
|
||||
// v1.16.10 — the persisted list presentation: cards vs table plus the
|
||||
// manual medication order, server-side per user
|
||||
// (`GET`/`PUT /api/medications/layout`). The toggle writes
|
||||
// optimistically; the reorder dialog saves explicitly.
|
||||
const { layout, isLayoutLoading, setView } = useMedicationListLayout(
|
||||
isAuthenticated,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldOpenFromUrl) {
|
||||
@@ -211,18 +237,25 @@ export default function MedicationsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const byName = (a: Medication, b: Medication) =>
|
||||
a.name.localeCompare(b.name, "de", { sensitivity: "base" });
|
||||
|
||||
// Defensive against stale service-worker responses or any future API
|
||||
// shape change: only filter when we actually have an array.
|
||||
const medsArray = Array.isArray(medications) ? medications : [];
|
||||
const activeMeds = medsArray.filter((m) => m.active).sort(byName);
|
||||
const inactiveMeds = medsArray.filter((m) => !m.active).sort(byName);
|
||||
// v1.16.10 — the user-defined manual order applies to BOTH views
|
||||
// (cards and table). Medications not in the saved order keep the
|
||||
// alphabetical default, appended after the ordered block.
|
||||
const activeMeds = applyMedicationOrder(
|
||||
medsArray.filter((m) => m.active),
|
||||
layout.order,
|
||||
);
|
||||
const inactiveMeds = applyMedicationOrder(
|
||||
medsArray.filter((m) => !m.active),
|
||||
layout.order,
|
||||
);
|
||||
const tableView = layout.view === "table";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("medications.title")}
|
||||
@@ -233,48 +266,75 @@ export default function MedicationsPage() {
|
||||
{t("medications.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
{/* v1.14.0 — the "Add" button is now a choice: log an intake
|
||||
(incl. a backdated one) against an existing medication, or
|
||||
create a new medication. v1.12.2 — match the dashboard Add
|
||||
button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so
|
||||
both primary "add" entry points clear the WCAG 2.5.5 44px mobile
|
||||
minimum identically. */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="min-h-11 sm:min-h-9">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("medications.addMedication")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setLogIntakeOpen(true)}
|
||||
disabled={activeMeds.length === 0}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* v1.16.10 — card ⇄ table view toggle, persisted server-side
|
||||
per user; plus the manual-order editor shared by both
|
||||
views. The reorder affordance only earns its slot once
|
||||
there is something to reorder. */}
|
||||
<MedicationViewToggle view={layout.view} onChange={setView} />
|
||||
{medsArray.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => setReorderOpen(true)}
|
||||
aria-label={t("medications.reorderTitle")}
|
||||
title={t("medications.reorderTitle")}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.logIntake")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={openCreate}>
|
||||
<Pill className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.newMedication")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ArrowDownUp className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* v1.14.0 — the "Add" button is now a choice: log an intake
|
||||
(incl. a backdated one) against an existing medication, or
|
||||
create a new medication. v1.12.2 — match the dashboard Add
|
||||
button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so
|
||||
both primary "add" entry points clear the WCAG 2.5.5 44px mobile
|
||||
minimum identically. */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="min-h-11 sm:min-h-9">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("medications.addMedication")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setLogIntakeOpen(true)}
|
||||
disabled={activeMeds.length === 0}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.logIntake")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={openCreate}>
|
||||
<Pill className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.newMedication")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
// v1.11.3 C4 — card skeletons instead of a bare centred spinner so
|
||||
// the page reserves the loaded layout and does not jump when the
|
||||
// medications resolve.
|
||||
{isLoading || isLayoutLoading ? (
|
||||
// v1.11.3 C4 — skeletons instead of a bare centred spinner so the
|
||||
// page reserves the loaded layout and does not jump when the
|
||||
// medications resolve. v1.16.10 — the skeleton follows the
|
||||
// persisted view (table rows vs card grid) so resolving the data
|
||||
// never swaps the footprint; while the view preference itself is
|
||||
// still loading the default cards skeleton stands in.
|
||||
<div
|
||||
className="grid gap-4 sm:grid-cols-2"
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-label={t("medications.title")}
|
||||
>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<MedicationCardSkeleton key={i} />
|
||||
))}
|
||||
{tableView && !isLayoutLoading ? (
|
||||
<MedicationTableSkeleton />
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<MedicationCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="bg-card border-border flex h-64 items-center justify-center rounded-xl border">
|
||||
@@ -307,6 +367,14 @@ export default function MedicationsPage() {
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : tableView ? (
|
||||
// v1.16.10 — the compact table view. Same payloads, same status
|
||||
// gating, same mutation hooks as the cards; one row per
|
||||
// medication, inactive rows pinned after the active block.
|
||||
<MedicationTable
|
||||
activeMedications={activeMeds}
|
||||
inactiveMedications={inactiveMeds}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Active medications */}
|
||||
@@ -391,6 +459,22 @@ export default function MedicationsPage() {
|
||||
date+time that supports backdating. Submits to the existing
|
||||
per-medication intake route (same slot upsert as a live "Taken"
|
||||
tap) so the one-row-per-dose-slot invariant holds. */}
|
||||
{/* v1.16.10 — manual-order editor. The list is the page's current
|
||||
effective order (active block first, inactive after), so the
|
||||
dialog opens showing exactly what both views render. */}
|
||||
{reorderOpen && (
|
||||
<MedicationReorderDialog
|
||||
open={reorderOpen}
|
||||
onOpenChange={setReorderOpen}
|
||||
medications={[...activeMeds, ...inactiveMeds].map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
dose: m.dose,
|
||||
active: m.active,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{logIntakeOpen && (
|
||||
<LogIntakeDialog
|
||||
open={logIntakeOpen}
|
||||
|
||||
Reference in new issue
Block a user