mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
114 lines
3.7 KiB
TypeScript
114 lines
3.7 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|