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:
Marc-André Bombeck committed 2026-06-12 18:10:21 +02:00
1 parent d142a6ed31
commit e21dfd040e
31 files changed
+3382 -65

No files matched your search

@@ -0,0 +1,92 @@
/**
* v1.16.10 — medications list presentation blob (resolver + serializer).
*
* The resolver must never throw on a stored row (malformed / legacy /
* partial blobs collapse onto the defaults field-by-field); the
* serializer dedupes and caps the manual order so the persisted blob
* stays bounded and canonical.
*/
import { describe, expect, it } from "vitest";
import {
DEFAULT_MEDICATION_LIST_LAYOUT,
MEDICATION_ORDER_MAX_ENTRIES,
resolveMedicationListLayout,
serializeMedicationListLayout,
} from "@/lib/medication-list-layout";
describe("resolveMedicationListLayout", () => {
it("returns the defaults for a null / missing column", () => {
expect(resolveMedicationListLayout(null)).toEqual(
DEFAULT_MEDICATION_LIST_LAYOUT,
);
expect(resolveMedicationListLayout(undefined)).toEqual(
DEFAULT_MEDICATION_LIST_LAYOUT,
);
});
it("returns the defaults for a non-object blob", () => {
expect(resolveMedicationListLayout("cards")).toEqual(
DEFAULT_MEDICATION_LIST_LAYOUT,
);
expect(resolveMedicationListLayout(42)).toEqual(
DEFAULT_MEDICATION_LIST_LAYOUT,
);
});
it("resolves a complete stored blob verbatim", () => {
expect(
resolveMedicationListLayout({
version: 1,
view: "table",
order: ["med-b", "med-a"],
}),
).toEqual({ version: 1, view: "table", order: ["med-b", "med-a"] });
});
it("falls back per field: unknown view → cards, malformed order → empty", () => {
expect(
resolveMedicationListLayout({ view: "kanban", order: "med-a" }),
).toEqual({ version: 1, view: "cards", order: [] });
});
it("drops non-string and oversized order entries, keeping the rest", () => {
const resolved = resolveMedicationListLayout({
view: "table",
order: ["med-a", 7, null, "x".repeat(65), "med-b"],
});
expect(resolved.order).toEqual(["med-a", "med-b"]);
});
it("dedupes a stored order (first occurrence wins)", () => {
const resolved = resolveMedicationListLayout({
view: "cards",
order: ["med-a", "med-b", "med-a"],
});
expect(resolved.order).toEqual(["med-a", "med-b"]);
});
});
describe("serializeMedicationListLayout", () => {
it("emits the canonical v1 blob", () => {
expect(
serializeMedicationListLayout({ view: "table", order: ["med-a"] }),
).toEqual({ version: 1, view: "table", order: ["med-a"] });
});
it("dedupes the order before persisting", () => {
expect(
serializeMedicationListLayout({
view: "cards",
order: ["a", "b", "a", "c", "b"],
}).order,
).toEqual(["a", "b", "c"]);
});
it("caps the order at the documented maximum", () => {
const order = Array.from({ length: 500 }, (_, i) => `med-${i}`);
expect(
serializeMedicationListLayout({ view: "cards", order }).order.length,
).toBe(MEDICATION_ORDER_MAX_ENTRIES);
});
});
+11
View File
@@ -246,6 +246,16 @@ export function invalidateUserInsightsLayout(userId: string): void {
caches.insightsLayout.deleteByPrefix(userId);
}
/**
* v1.16.10 — invalidate the medications list-presentation cache for a
* single user. Called from the medications-layout PUT / DELETE
* endpoints when the user toggles the view or saves a manual order so
* the next /medications mount paints the new presentation.
*/
export function invalidateUserMedicationListLayout(userId: string): void {
caches.medicationListLayout.deleteByPrefix(userId);
}
/**
* v1.16.9 — full per-user cache eviction for the wipe / restore paths.
* A data wipe or a backup restore replaces (or removes) every row the
@@ -265,6 +275,7 @@ export function invalidateUserData(userId: string): void {
caches.workouts.deleteByPrefix(`${userId}|`);
caches.dashboardWidgets.deleteByPrefix(userId);
caches.insightsLayout.deleteByPrefix(userId);
caches.medicationListLayout.deleteByPrefix(userId);
}
/**
+12
View File
@@ -610,6 +610,18 @@ export const caches = {
maxEntries: 500,
ttlMs: 300_000,
}),
/**
* v1.16.10 — per-user medications list presentation (view choice +
* manual order). Mirrors `insightsLayout` (same 5-minute TTL, same
* per-user bucket size). Read on every /medications mount; changes
* only on a view toggle / order save, which invalidates via
* `invalidateUserMedicationListLayout()`.
*/
medicationListLayout: new ServerCache<unknown>({
name: "medicationListLayout",
maxEntries: 500,
ttlMs: 300_000,
}),
} as const;
/**
+113
View File
@@ -0,0 +1,113 @@
/**
* Medications list presentation — persisted in
* `User.medicationListLayoutJson`.
*
* Single source of truth for the /medications view choice (cards vs the
* compact table) and the user-defined manual medication order shared by
* BOTH views. Null / missing column = the defaults below; the GET
* endpoint never lazy-writes a row, so the column only carries data once
* the user has explicitly toggled the view or saved an order.
*
* Mirrors `insights-layout.ts` / `dashboard-layout.ts` — same separate
* per-surface column on `User`, same resolver / serializer semantics,
* same preserve-when-absent PUT contract (see the route).
*/
/** The two list presentations /medications can render. */
export const MEDICATION_LIST_VIEWS = ["cards", "table"] as const;
export type MedicationListView = (typeof MEDICATION_LIST_VIEWS)[number];
export interface MedicationListLayout {
version: 1;
/** Which presentation the list renders in. Default: cards. */
view: MedicationListView;
/**
* User-defined manual medication order (medication ids, first = top).
* Applied to BOTH views; medications not in the list append after the
* ordered block in the alphabetical default order. Ids that no longer
* resolve to a medication are ignored at apply time (a deleted
* medication must not 422 the stored layout), so the list is bounded
* but not ownership-validated — it is display-only.
*/
order: string[];
}
/**
* Upper bound on the persisted order list. Generous — the medications
* list itself has no hard cap, but the blob must stay small; beyond
* this many entries the tail falls back to the alphabetical default.
*/
export const MEDICATION_ORDER_MAX_ENTRIES = 200;
/** Per-entry id length bound (cuids are 25 chars; leave headroom). */
export const MEDICATION_ORDER_ID_MAX_LENGTH = 64;
export const DEFAULT_MEDICATION_LIST_LAYOUT: MedicationListLayout = {
version: 1,
view: "cards",
order: [],
};
function isMedicationListView(value: unknown): value is MedicationListView {
return (
typeof value === "string" &&
(MEDICATION_LIST_VIEWS as readonly string[]).includes(value)
);
}
/**
* Tolerant read-side resolver: any malformed / legacy / partial blob
* collapses onto the defaults field-by-field, so a GET can never fail on
* a stored row and a new field added later defaults cleanly for blobs
* persisted before it existed.
*/
export function resolveMedicationListLayout(
raw: unknown,
): MedicationListLayout {
if (raw === null || raw === undefined || typeof raw !== "object") {
return { ...DEFAULT_MEDICATION_LIST_LAYOUT, order: [] };
}
const blob = raw as { view?: unknown; order?: unknown };
const view = isMedicationListView(blob.view)
? blob.view
: DEFAULT_MEDICATION_LIST_LAYOUT.view;
const order = Array.isArray(blob.order)
? dedupeOrder(
blob.order.filter(
(id): id is string =>
typeof id === "string" &&
id.length > 0 &&
id.length <= MEDICATION_ORDER_ID_MAX_LENGTH,
),
)
: [];
return { version: 1, view, order };
}
/**
* Write-side normaliser: dedupes the order list (first occurrence wins)
* and caps it. The route merges absent fields from the stored blob
* BEFORE calling this, so the result is always a complete layout.
*/
export function serializeMedicationListLayout(input: {
view: MedicationListView;
order: string[];
}): MedicationListLayout {
return {
version: 1,
view: input.view,
order: dedupeOrder(input.order).slice(0, MEDICATION_ORDER_MAX_ENTRIES),
};
}
function dedupeOrder(order: readonly string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const id of order) {
if (seen.has(id)) continue;
seen.add(id);
out.push(id);
}
return out;
}
@@ -0,0 +1,52 @@
/**
* v1.16.10 — the manual medication order shared by BOTH /medications
* views. Ordered ids first (in saved order), everything else appends in
* the alphabetical default; unknown ids are ignored.
*/
import { describe, expect, it } from "vitest";
import { applyMedicationOrder } from "@/lib/medications/medication-order";
const meds = [
{ id: "m1", name: "Ramipril" },
{ id: "m2", name: "Aspirin" },
{ id: "m3", name: "Mounjaro" },
];
describe("applyMedicationOrder", () => {
it("returns the alphabetical default for an empty order", () => {
expect(applyMedicationOrder(meds, []).map((m) => m.name)).toEqual([
"Aspirin",
"Mounjaro",
"Ramipril",
]);
});
it("applies the saved order verbatim when every id is named", () => {
expect(
applyMedicationOrder(meds, ["m3", "m1", "m2"]).map((m) => m.id),
).toEqual(["m3", "m1", "m2"]);
});
it("appends unnamed medications alphabetically after the ordered block", () => {
// A medication created after the last order save must surface, not
// vanish — it lands after the ordered block in the default order.
expect(applyMedicationOrder(meds, ["m3"]).map((m) => m.id)).toEqual([
"m3",
"m2", // Aspirin
"m1", // Ramipril
]);
});
it("ignores ids that no longer resolve to a medication", () => {
expect(
applyMedicationOrder(meds, ["deleted-med", "m2"]).map((m) => m.id),
).toEqual(["m2", "m3", "m1"]);
});
it("does not mutate the input array", () => {
const input = [...meds];
applyMedicationOrder(input, ["m3", "m1"]);
expect(input).toEqual(meds);
});
});
+37
View File
@@ -0,0 +1,37 @@
/**
* v1.16.10 — the user-defined manual medication order, applied to BOTH
* /medications views (cards and table).
*
* The persisted layout carries an ordered list of medication ids
* (`MedicationListLayout.order`). Medications named there render first,
* in that order; every other medication (newly created since the last
* save, or never ordered) appends after the ordered block in the
* alphabetical default the list always used. Ids that no longer resolve
* to a medication are simply ignored — a deleted medication must not
* corrupt the order.
*/
/** The default tiebreak the list used before manual ordering existed. */
export function compareMedicationNames(
a: { name: string },
b: { name: string },
): number {
return a.name.localeCompare(b.name, "de", { sensitivity: "base" });
}
export function applyMedicationOrder<T extends { id: string; name: string }>(
medications: readonly T[],
order: readonly string[],
): T[] {
const position = new Map<string, number>();
order.forEach((id, index) => {
if (!position.has(id)) position.set(id, index);
});
const ordered = medications
.filter((m) => position.has(m.id))
.sort((a, b) => position.get(a.id)! - position.get(b.id)!);
const rest = medications
.filter((m) => !position.has(m.id))
.sort(compareMedicationNames);
return [...ordered, ...rest];
}
+115 -1
View File
@@ -18,6 +18,11 @@ import {
MEDICATION_TREATMENT_CLASS_VALUES,
} from "@/lib/validations/medication";
import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt";
import {
MEDICATION_LIST_VIEWS,
MEDICATION_ORDER_ID_MAX_LENGTH,
MEDICATION_ORDER_MAX_ENTRIES,
} from "@/lib/medication-list-layout";
import {
scheduleRevisionCreateSchema,
scheduleRevisionUpdateSchema,
@@ -228,11 +233,25 @@ const medicationListEntry = medicationResource
.describe(
"Number of ACTIONED intake events for today (user-local day window): rows with a recorded `takenAt` or an explicit skip. Pending projector-minted rows do not count — the card overdue-pill suppression compares this against the passed-dose count, and a pending mint must not read as covered.",
),
stockUnitsRemaining: z
.number()
.int()
.nullable()
.describe(
"v1.16.10 — usable inventory units left across the medication's containers (sum of `unitsRemaining` over ACTIVE / IN_USE items with units left). NULL = inventory tracking off (no items ever registered); 0 = tracking on, supply ran out. Read-only — aggregated, not stored.",
),
stockDosesRemaining: z
.number()
.int()
.nullable()
.describe(
"v1.16.10 — dose-derived stock: `floor(stockUnitsRemaining / max(1, unitsPerDose))`. NULL when inventory tracking is off. Drives the table view's Bestand column. Read-only — aggregated, not stored.",
),
})
.meta({
id: "MedicationListEntry",
description:
"List-row variant of the medication resource enriched with the joined `category`, `lastTakenAt`, and `todayEventCount` fields the dashboard + iOS client consume. The base medication fields (`id`, `name`, `dose`, `treatmentClass`, `dosesPerUnit`, `active`, `notificationsEnabled`, `pausedAt`, `snoozedUntil`, `startsOn`, `endsOn`, `oneShot`, `createdAt`, `updatedAt`, `schedules`) are inlined; see the `Medication` component for their semantics.",
"List-row variant of the medication resource enriched with the joined `category`, `lastTakenAt`, `todayEventCount`, and the v1.16.10 aggregated stock fields (`stockUnitsRemaining`, `stockDosesRemaining`) the dashboard + iOS client consume. The base medication fields (`id`, `name`, `dose`, `treatmentClass`, `dosesPerUnit`, `active`, `notificationsEnabled`, `pausedAt`, `snoozedUntil`, `startsOn`, `endsOn`, `oneShot`, `createdAt`, `updatedAt`, `schedules`) are inlined; see the `Medication` component for their semantics.",
});
const medicationDetailEntry = medicationResource
@@ -677,6 +696,32 @@ medicationExtractionSchema.meta({
"Citation-guarded partial extraction of medication scheduling fields. Every field is optional; the wizard merges what is present onto the form state and leaves the rest blank. `name` and `dose` are post-validated against the original free-text and dropped when not substring-matched, so the wizard cannot silently land a hallucinated brand or dose. `cadenceKind` / `doseUnit` / `weekdays` are closed enums; numeric fields are clamped to the wizard's wire bounds.",
});
// v1.16.10 — medications list presentation (cards/table view + manual
// order), persisted per user in its own `User` column following the
// dashboard-widgets / insights-layout per-surface convention.
const medicationListLayoutSchema = z
.object({
version: z.literal(1),
view: z
.enum(MEDICATION_LIST_VIEWS)
.optional()
.describe(
'Which presentation /medications renders in. Default "cards". Optional on PUT — when omitted the stored value is preserved (preserve-when-absent, like `heroVisible` on the dashboard layout). Always present on responses.',
),
order: z
.array(z.string().min(1).max(MEDICATION_ORDER_ID_MAX_LENGTH))
.max(MEDICATION_ORDER_MAX_ENTRIES)
.optional()
.describe(
"User-defined manual medication order (medication ids, first = top), shared by both views. Display-only — unknown / deleted ids are ignored at render time, never 422. Optional on PUT (preserve-when-absent); always present on responses.",
),
})
.meta({
id: "MedicationListLayout",
description:
"Per-user /medications presentation: the card/table view choice plus the manual medication order shared by both views. Mirrors the dashboard-widgets / insights-layout contract.",
});
export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/medications": {
get: {
@@ -724,6 +769,75 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/medications/layout": {
get: {
tags: ["Medications"],
summary: "Read the calling user's medications list presentation",
description:
"Returns the per-user /medications presentation (card/table view + manual order). Falls back to the defaults (cards, empty order) when the user has not customised it. Mirrors the insights-layout contract.",
responses: {
"200": {
description: "The resolved presentation (custom or default).",
content: {
"application/json": {
schema: dataEnvelope(
medicationListLayoutSchema,
"MedicationListLayoutResponse",
),
},
},
},
...stdResponses,
},
},
put: {
tags: ["Medications"],
summary: "Update the calling user's medications list presentation",
description:
"Field-scoped update: `view` and `order` are each optional, and whichever the body omits is preserved from the stored blob — a view toggle can never wipe the manual order and vice versa. The normalised presentation is returned. Invalid bodies return the multi-issue 422 envelope.",
requestBody: {
required: true,
content: {
"application/json": { schema: medicationListLayoutSchema },
},
},
responses: {
"200": {
description:
"Presentation saved; the normalised blob is echoed back.",
content: {
"application/json": {
schema: dataEnvelope(
medicationListLayoutSchema,
"MedicationListLayoutSaved",
),
},
},
},
...stdResponses,
},
},
delete: {
tags: ["Medications"],
summary: "Reset the calling user's medications list presentation",
description:
"Clears the persisted presentation and returns the defaults (cards, empty order). Idempotent.",
responses: {
"200": {
description: "Presentation reset; the defaults are returned.",
content: {
"application/json": {
schema: dataEnvelope(
medicationListLayoutSchema,
"MedicationListLayoutReset",
),
},
},
},
...stdResponses,
},
},
},
"/api/medications/{id}": {
get: {
tags: ["Medications"],
@@ -0,0 +1,150 @@
/**
* v1.16.10 — toggle / reorder persistence wiring for the medications
* list presentation. Pins the PUT payload shape (field-scoped, version
* 1), the optimistic cache flip + rollback-on-failure for the view
* toggle, and the cache update on a saved order — without a React
* render, via the dependency-injected `run*` orchestration functions
* (the `use-medication-intake` testing convention).
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: vi.fn(),
apiPut: vi.fn(),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
import { apiPut } from "@/lib/api/api-fetch";
import { toast } from "sonner";
import {
runSetMedicationListView,
runSaveMedicationListOrder,
} from "@/lib/queries/use-medication-list-layout";
import { queryKeys } from "@/lib/query-keys";
import type { MedicationListLayout } from "@/lib/medication-list-layout";
const t = (key: string) => key;
function makeClient(): QueryClient {
return new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: Infinity } },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("runSetMedicationListView", () => {
it("PUTs the field-scoped body and lands the server echo in the cache", async () => {
const queryClient = makeClient();
queryClient.setQueryData<MedicationListLayout>(
queryKeys.medicationListLayout(),
{ version: 1, view: "cards", order: ["med-a"] },
);
const saved: MedicationListLayout = {
version: 1,
view: "table",
order: ["med-a"],
};
vi.mocked(apiPut).mockResolvedValue(saved as never);
await runSetMedicationListView({ view: "table", queryClient, t });
// Field-scoped: only `view` rides the body — the server preserves
// the stored order (preserve-when-absent), so the client must not
// resend (and thereby race) it.
expect(apiPut).toHaveBeenCalledWith("/api/medications/layout", {
version: 1,
view: "table",
});
expect(
queryClient.getQueryData(queryKeys.medicationListLayout()),
).toEqual(saved);
expect(toast.error).not.toHaveBeenCalled();
});
it("flips the cache optimistically before the PUT resolves", async () => {
const queryClient = makeClient();
queryClient.setQueryData<MedicationListLayout>(
queryKeys.medicationListLayout(),
{ version: 1, view: "cards", order: [] },
);
let observedDuringFlight: MedicationListLayout | undefined;
vi.mocked(apiPut).mockImplementation((async () => {
observedDuringFlight = queryClient.getQueryData(
queryKeys.medicationListLayout(),
);
return { version: 1, view: "table", order: [] };
}) as never);
await runSetMedicationListView({ view: "table", queryClient, t });
expect(observedDuringFlight?.view).toBe("table");
});
it("rolls the cache back and surfaces a toast when the PUT fails", async () => {
const queryClient = makeClient();
const previous: MedicationListLayout = {
version: 1,
view: "cards",
order: ["med-a"],
};
queryClient.setQueryData(queryKeys.medicationListLayout(), previous);
vi.mocked(apiPut).mockRejectedValue(new Error("boom"));
await runSetMedicationListView({ view: "table", queryClient, t });
expect(
queryClient.getQueryData(queryKeys.medicationListLayout()),
).toEqual(previous);
expect(toast.error).toHaveBeenCalledWith("medications.viewSaveFailed");
});
});
describe("runSaveMedicationListOrder", () => {
it("PUTs the order-only body, caches the echo, and reports success", async () => {
const queryClient = makeClient();
const saved: MedicationListLayout = {
version: 1,
view: "table",
order: ["med-b", "med-a"],
};
vi.mocked(apiPut).mockResolvedValue(saved as never);
const ok = await runSaveMedicationListOrder({
order: ["med-b", "med-a"],
queryClient,
t,
});
expect(ok).toBe(true);
expect(apiPut).toHaveBeenCalledWith("/api/medications/layout", {
version: 1,
order: ["med-b", "med-a"],
});
expect(
queryClient.getQueryData(queryKeys.medicationListLayout()),
).toEqual(saved);
expect(toast.success).toHaveBeenCalledWith("medications.reorderSaved");
});
it("returns false and surfaces a toast when the PUT fails", async () => {
const queryClient = makeClient();
vi.mocked(apiPut).mockRejectedValue(new Error("boom"));
const ok = await runSaveMedicationListOrder({
order: ["med-a"],
queryClient,
t,
});
expect(ok).toBe(false);
expect(toast.error).toHaveBeenCalledWith(
"medications.reorderSaveFailed",
);
});
});
@@ -43,6 +43,19 @@ export interface MedicationComplianceSummaryEntry {
* lets the cards swap the skeleton for a quiet same-footprint fallback
* with a manual retry.
*/
/**
* The ONE fetcher behind the shared summary key. Both hooks below (and
* any future consumer) must ride this exact function so the key can
* never be registered with diverging fetch shapes.
*/
function fetchComplianceSummary(): Promise<
MedicationComplianceSummaryEntry[]
> {
return apiGet<MedicationComplianceSummaryEntry[]>(
"/api/medications/compliance",
);
}
export function useMedicationComplianceSummary(medicationId: string): {
data: MedicationComplianceSummaryEntry | null | undefined;
isError: boolean;
@@ -50,8 +63,7 @@ export function useMedicationComplianceSummary(medicationId: string): {
} {
const { data, isError, refetch } = useQuery({
queryKey: queryKeys.medicationComplianceSummary(),
queryFn: () =>
apiGet<MedicationComplianceSummaryEntry[]>("/api/medications/compliance"),
queryFn: fetchComplianceSummary,
// Dose actions invalidate the key explicitly through
// `medicationDependentKeys`; a shorter window would only re-fire the
// batch on every list visit. Matches the reminder-thresholds query
@@ -62,3 +74,20 @@ export function useMedicationComplianceSummary(medicationId: string): {
});
return { data, isError, refetch };
}
/**
* v1.16.10 — the whole batched array, for consumers that need every
* row at once (the table view's Therapietreue column sort). Same key,
* same fetcher, no `select` — the per-row hook and this one share one
* cache entry by construction.
*/
export function useMedicationComplianceSummaryAll(): {
data: MedicationComplianceSummaryEntry[] | undefined;
} {
const { data } = useQuery({
queryKey: queryKeys.medicationComplianceSummary(),
queryFn: fetchComplianceSummary,
staleTime: 5 * 60 * 1000,
});
return { data };
}
@@ -0,0 +1,116 @@
"use client";
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import {
DEFAULT_MEDICATION_LIST_LAYOUT,
type MedicationListLayout,
type MedicationListView,
} from "@/lib/medication-list-layout";
type Translator = (
key: string,
params?: Record<string, string | number>,
) => string;
/**
* v1.16.10 — the /medications presentation preference (cards/table view
* + manual order), persisted per user through
* `GET`/`PUT /api/medications/layout`.
*
* The PUT is field-scoped: the server preserves whichever of `view` /
* `order` the body omits, so the two writers below each send only the
* field they own and can never wipe the other one.
*
* The orchestration lives in dependency-injected `run*` functions (the
* repo convention from `use-medication-intake.ts`) so the optimistic
* write + rollback contract is unit-testable without a React render.
*/
/**
* Flip the view optimistically (the toggle must feel instant), then
* persist. On failure the cache rolls back to the previous layout and
* a calm toast surfaces — the user is back on the view they came from.
*/
export async function runSetMedicationListView(deps: {
view: MedicationListView;
queryClient: QueryClient;
t: Translator;
}): Promise<void> {
const { view, queryClient, t } = deps;
const key = queryKeys.medicationListLayout();
const previous = queryClient.getQueryData<MedicationListLayout>(key);
queryClient.setQueryData<MedicationListLayout>(key, {
...(previous ?? DEFAULT_MEDICATION_LIST_LAYOUT),
view,
});
try {
const saved = await apiPut<MedicationListLayout>(
"/api/medications/layout",
{ version: 1, view },
);
if (saved) queryClient.setQueryData(key, saved);
} catch {
queryClient.setQueryData(key, previous);
toast.error(t("medications.viewSaveFailed"));
}
}
/**
* Persist a manual medication order (the reorder dialog's Save). Not
* optimistic — the dialog stays open until the PUT resolves, mirroring
* the dashboard layout section's explicit Save. Returns whether the
* save landed so the dialog knows to close.
*/
export async function runSaveMedicationListOrder(deps: {
order: string[];
queryClient: QueryClient;
t: Translator;
}): Promise<boolean> {
const { order, queryClient, t } = deps;
try {
const saved = await apiPut<MedicationListLayout>(
"/api/medications/layout",
{ version: 1, order },
);
if (saved) {
queryClient.setQueryData(queryKeys.medicationListLayout(), saved);
}
toast.success(t("medications.reorderSaved"));
return true;
} catch {
toast.error(t("medications.reorderSaveFailed"));
return false;
}
}
export function useMedicationListLayout(enabled: boolean = true): {
/** Resolved layout — defaults until the GET lands. */
layout: MedicationListLayout;
/** True while the preference GET is in flight (first load only). */
isLayoutLoading: boolean;
setView: (view: MedicationListView) => Promise<void>;
saveOrder: (order: string[]) => Promise<boolean>;
} {
const queryClient = useQueryClient();
const { t } = useTranslations();
const { data, isLoading } = useQuery({
queryKey: queryKeys.medicationListLayout(),
queryFn: () => apiGet<MedicationListLayout>("/api/medications/layout"),
staleTime: 5 * 60 * 1000,
enabled,
});
return {
layout: data ?? DEFAULT_MEDICATION_LIST_LAYOUT,
isLayoutLoading: enabled && isLoading,
setView: (view) => runSetMedicationListView({ view, queryClient, t }),
saveOrder: (order) =>
runSaveMedicationListOrder({ order, queryClient, t }),
};
}
+8
View File
@@ -28,6 +28,14 @@ export const medicationKeys = {
*/
medicationComplianceSummary: () =>
["medications", "compliance-summary"] as const,
/**
* v1.16.10 — the medications list presentation (cards/table view +
* manual order, `GET /api/medications/layout`). Deliberately OUTSIDE
* the `["medications"]` prefix: an intake mutation must not refetch a
* pure UI preference. Writes update the slot directly through the
* layout PUT's response.
*/
medicationListLayout: () => ["medication-list-layout"] as const,
medicationCadence: (medicationId: string) =>
["medications", medicationId, "cadence"] as const,
medicationGlp1Details: (medicationId: string) =>