mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12: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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user