mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge pull request #319 from MBombeck/release/v1.16.12
release: v1.16.12 — split doses, steady cards, and a read that stays warm
This commit is contained in:
39 files changed
+747
-201
No files matched your search
@@ -2,6 +2,21 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.16.12] — 2026-06-13 — split doses, steady cards, and a read that stays warm
|
||||
|
||||
### Added
|
||||
|
||||
- **A dose can consume a fraction of a unit.** Split-pill medications now declare ½, ⅓ or ¼ of a tablet per dose — the wizard offers a curated set of fractions alongside the whole numbers, the most error-resistant input. The supply tracks the fractional remainder (30 tablets dosed at a half each reads 29.5 after one dose), the days-left projection follows, and undoing a dose refunds exactly what it consumed. Existing whole-number doses are untouched; the inventory unit counts widen to decimals across the API contract. (#316)
|
||||
|
||||
### Changed
|
||||
|
||||
- **The medication cards hold one shape.** The low-supply notice moves onto the adherence row beside the streak — a slot every card already reserves — so a card that is low on stock no longer pushes its bars out of line with the card beside it. The gap between the drug class and the first line tightens at the same time.
|
||||
- **The dashboard and insights stay warm between visits.** The analytics and derived-insight reads keep a served-stale snapshot for an hour rather than ten minutes, so returning after a normal break paints instantly and refreshes in the background instead of paying the full cold rebuild — which a visit spaced more than ten minutes apart hit almost every time.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The medications list refreshes when you return to it.** Navigating back to the page — or reopening the supply tab — now refetches the list, compliance and stock instead of serving a client cache that could be minutes stale, so a dose logged on another device shows up without a manual reload.
|
||||
|
||||
## [1.16.11] — 2026-06-12 — the supply speaks up, tags find their groups, and nothing asks twice
|
||||
|
||||
### Added
|
||||
|
||||
+37
-47
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.16.11
|
||||
version: 1.16.12
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -5141,11 +5141,10 @@ components:
|
||||
minimum: 1
|
||||
maximum: 1000
|
||||
unitsPerDose:
|
||||
description: Inventory units consumed per dose (e.g. 2 tablets of 2 mg for a 4 mg dose). Default 1. The intake
|
||||
consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
description: Inventory units consumed per dose. A whole number 1–100 (e.g. 2 tablets of 2 mg for a 4 mg dose) or a
|
||||
supported fraction for a split pill (¼ / ⅓ / ½ / ⅔ / ¾). Default 1. The intake consumption hook decrements
|
||||
this many units per taken dose; dose-derived readouts divide unit counts by it.
|
||||
type: number
|
||||
deliveryForm:
|
||||
type: string
|
||||
enum:
|
||||
@@ -5429,11 +5428,10 @@ components:
|
||||
maximum: 1000
|
||||
- type: "null"
|
||||
unitsPerDose:
|
||||
description: Inventory units consumed per dose (e.g. 2 tablets of 2 mg for a 4 mg dose). The intake consumption hook
|
||||
decrements this many units per taken dose; already-stamped intake events keep their recorded consumption.
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
description: Inventory units consumed per dose — a whole number 1–100 or a supported split-pill fraction (¼ / ⅓ / ½ / ⅔
|
||||
/ ¾). The intake consumption hook decrements this many units per taken dose; already-stamped intake events
|
||||
keep their recorded consumption.
|
||||
type: number
|
||||
deliveryForm:
|
||||
type: string
|
||||
enum:
|
||||
@@ -5605,11 +5603,11 @@ components:
|
||||
type: object
|
||||
properties:
|
||||
unitsTotal:
|
||||
type: integer
|
||||
type: number
|
||||
minimum: 1
|
||||
maximum: 1000
|
||||
description: Units the container ships with (tablets / ampoules / puffs; 1–1000). Dose-derived readouts divide by the
|
||||
medication's `unitsPerDose`.
|
||||
description: Units the container ships with (tablets / ampoules / puffs; 1–1000, fractional allowed for split-pill
|
||||
packs). Dose-derived readouts divide by the medication's `unitsPerDose`.
|
||||
containerType:
|
||||
description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level only;
|
||||
defaults to OTHER.
|
||||
@@ -5661,9 +5659,9 @@ components:
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
unitsRemaining:
|
||||
description: Absolute remaining-unit correction (0–1000). Clamped server-side to the item's `unitsTotal`; the canonical
|
||||
state machine re-derives the state (0 ⇒ USED_UP).
|
||||
type: integer
|
||||
description: Absolute remaining-unit correction (0–1000, fractional allowed). Clamped server-side to the item's
|
||||
`unitsTotal`; the canonical state machine re-derives the state (0 ⇒ USED_UP).
|
||||
type: number
|
||||
minimum: 0
|
||||
maximum: 1000
|
||||
notes:
|
||||
@@ -9049,12 +9047,10 @@ components:
|
||||
- type: "null"
|
||||
description: Doses per pen / vial for inventory tracking. NULL = inventory tracking off.
|
||||
unitsPerDose:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). Default 1. The
|
||||
intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts
|
||||
by it.
|
||||
type: number
|
||||
description: v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). v1.16.12 — may be a
|
||||
split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾); thirds carry as ≈0.3333 / 0.6667. Default 1. The intake consumption
|
||||
hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.
|
||||
active:
|
||||
type: boolean
|
||||
notificationsEnabled:
|
||||
@@ -9178,8 +9174,9 @@ components:
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
description: "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."
|
||||
description: "v1.16.10 — dose-derived stock: `floor(stockUnitsRemaining / unitsPerDose)`, where `unitsPerDose` may be a
|
||||
fraction (½ tablet ⇒ twice the doses). Stays a whole-dose count. NULL when inventory tracking is off. Drives
|
||||
the table view's Bestand column. Read-only — aggregated, not stored."
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
@@ -9268,12 +9265,10 @@ components:
|
||||
- type: "null"
|
||||
description: Doses per pen / vial for inventory tracking. NULL = inventory tracking off.
|
||||
unitsPerDose:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). Default 1. The
|
||||
intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts
|
||||
by it.
|
||||
type: number
|
||||
description: v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). v1.16.12 — may be a
|
||||
split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾); thirds carry as ≈0.3333 / 0.6667. Default 1. The intake consumption
|
||||
hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.
|
||||
active:
|
||||
type: boolean
|
||||
notificationsEnabled:
|
||||
@@ -9652,17 +9647,14 @@ components:
|
||||
description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level
|
||||
classification; defaults to OTHER.
|
||||
unitsTotal:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Units the container shipped with (tablets / ampoules / puffs; 1–1000). Dose-derived readouts divide by the
|
||||
medication's `unitsPerDose`.
|
||||
type: number
|
||||
description: Units the container shipped with (tablets / ampoules / puffs; 1–1000). v1.16.12 — fractional, so a
|
||||
split-pill remainder reads cleanly. Dose-derived readouts divide by the medication's `unitsPerDose`.
|
||||
unitsRemaining:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Units left in the container. Decremented by the intake consumption hook (FEFO with spillover across
|
||||
containers); refunded when a taken dose is skipped, edited away, or deleted.
|
||||
type: number
|
||||
description: Units left in the container. v1.16.12 — fractional (a ½-tablet dose leaves 29.5 of 30). Decremented by the
|
||||
intake consumption hook (FEFO with spillover across containers); refunded when a taken dose is skipped,
|
||||
edited away, or deleted.
|
||||
firstUseAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -14615,12 +14607,10 @@ components:
|
||||
- type: "null"
|
||||
description: Doses per pen / vial for inventory tracking. NULL = inventory tracking off.
|
||||
unitsPerDose:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). Default 1. The
|
||||
intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts
|
||||
by it.
|
||||
type: number
|
||||
description: v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). v1.16.12 — may be a
|
||||
split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾); thirds carry as ≈0.3333 / 0.6667. Default 1. The intake consumption
|
||||
hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.
|
||||
active:
|
||||
type: boolean
|
||||
notificationsEnabled:
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* #316 follow-up — does the medications list refetch when you navigate
|
||||
* back to the page?
|
||||
*
|
||||
* Reporter: "TanStack Query is not fetching data on the medications page
|
||||
* or other areas when the data is stale, even when I navigate between
|
||||
* pages." In Next.js App Router a route-level page UNMOUNTS on navigation
|
||||
* away and remounts on return, so the query observer is recreated — but
|
||||
* the global `staleTime: 5min` makes `refetchOnMount: true` skip the
|
||||
* fetch while the cached data is still "fresh", so the page shows a
|
||||
* potentially stale list with no network round-trip.
|
||||
*
|
||||
* This spec counts `/api/medications` requests across a client-side
|
||||
* round-trip (medications → dashboard → medications). The DESIRED
|
||||
* (post-fix) behaviour is a fresh fetch on return; pre-fix the count
|
||||
* stays at 1, which is the reproduction.
|
||||
*
|
||||
* Desktop-only: the assertion is about query behaviour, and the sidebar
|
||||
* nav links it clicks live behind a drawer on the mobile project.
|
||||
*/
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test("medications list refetches on client-side navigation back to the page", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.includes("mobile"),
|
||||
"sidebar nav links are drawer-gated on mobile; query behaviour is project-independent",
|
||||
);
|
||||
let medsRequests = 0;
|
||||
page.on("request", (req) => {
|
||||
try {
|
||||
const u = new URL(req.url());
|
||||
if (u.pathname === "/api/medications") medsRequests += 1;
|
||||
} catch {
|
||||
/* non-URL request — ignore */
|
||||
}
|
||||
});
|
||||
|
||||
// Initial load — the list query mounts and fetches once.
|
||||
await page.goto("/medications");
|
||||
await page.waitForResponse(
|
||||
(r) => new URL(r.url()).pathname === "/api/medications",
|
||||
);
|
||||
expect(medsRequests).toBe(1);
|
||||
|
||||
// Client-side nav AWAY (dashboard) then BACK — App Router unmounts and
|
||||
// remounts the medications page. A full page.goto() would reset the
|
||||
// client cache and always refetch, so we click the sidebar links.
|
||||
await page.locator('a[href="/"]').first().click();
|
||||
await page.waitForURL("**/");
|
||||
await page.locator('a[href="/medications"]').first().click();
|
||||
await page.waitForURL("**/medications");
|
||||
|
||||
// Give a returning refetch time to fire.
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Desired: the return mounts a fresh observer that refetches. Pre-fix
|
||||
// the 5-min staleTime masks it and this stays at 1 (the bug).
|
||||
expect(medsRequests).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
+1
-1
@@ -1120,7 +1120,7 @@
|
||||
"injectionSiteHint": "Optionaler Rotationshelfer. Die Stelle jeder Dosis wird beim Eintragen der Einnahme erfasst.",
|
||||
"short": "Dosis",
|
||||
"unitsPerDoseLabel": "Einheiten pro Dosis",
|
||||
"unitsPerDoseHint": "z. B. 2 Tabletten à 2 mg für eine 4-mg-Dosis"
|
||||
"unitsPerDoseHint": "z. B. 2 Tabletten à 2 mg für eine 4-mg-Dosis – oder ½ Tablette bei geteilter Dosis"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Über welchen Zeitraum nimmst du es?",
|
||||
|
||||
+1
-1
@@ -1120,7 +1120,7 @@
|
||||
"injectionSiteHint": "Optional rotation guide. The site for each dose is recorded when you log the intake.",
|
||||
"short": "Dose",
|
||||
"unitsPerDoseLabel": "Units per dose",
|
||||
"unitsPerDoseHint": "e.g. 2 tablets of 2 mg for a 4 mg dose"
|
||||
"unitsPerDoseHint": "e.g. 2 tablets of 2 mg for a 4 mg dose, or ½ a tablet for a split dose"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Over what period are you taking it?",
|
||||
|
||||
+1
-1
@@ -1120,7 +1120,7 @@
|
||||
"injectionSiteHint": "Guía de rotación opcional. El sitio de cada dosis se registra al anotar la toma.",
|
||||
"short": "Dosis",
|
||||
"unitsPerDoseLabel": "Unidades por dosis",
|
||||
"unitsPerDoseHint": "p. ej., 2 comprimidos de 2 mg para una dosis de 4 mg"
|
||||
"unitsPerDoseHint": "p. ej., 2 comprimidos de 2 mg para una dosis de 4 mg, o ½ comprimido para una dosis partida"
|
||||
},
|
||||
"step4": {
|
||||
"title": "¿Durante qué periodo lo tomas?",
|
||||
|
||||
+1
-1
@@ -1120,7 +1120,7 @@
|
||||
"injectionSiteHint": "Guide de rotation facultatif. Le site de chaque dose est enregistré lors de la saisie de la prise.",
|
||||
"short": "Dose",
|
||||
"unitsPerDoseLabel": "Unités par dose",
|
||||
"unitsPerDoseHint": "p. ex. 2 comprimés de 2 mg pour une dose de 4 mg"
|
||||
"unitsPerDoseHint": "p. ex. 2 comprimés de 2 mg pour une dose de 4 mg, ou ½ comprimé pour une dose fractionnée"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Sur quelle période le prenez-vous ?",
|
||||
|
||||
+1
-1
@@ -1120,7 +1120,7 @@
|
||||
"injectionSiteHint": "Guida alla rotazione facoltativa. Il sito di ogni dose viene registrato al momento della registrazione dell'assunzione.",
|
||||
"short": "Dose",
|
||||
"unitsPerDoseLabel": "Unità per dose",
|
||||
"unitsPerDoseHint": "ad es. 2 compresse da 2 mg per una dose da 4 mg"
|
||||
"unitsPerDoseHint": "ad es. 2 compresse da 2 mg per una dose da 4 mg, oppure ½ compressa per una dose divisa"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Per quanto tempo lo assumi?",
|
||||
|
||||
+1
-1
@@ -1120,7 +1120,7 @@
|
||||
"injectionSiteHint": "Opcjonalny przewodnik rotacji. Miejsce każdej dawki zapisywane jest przy rejestrowaniu przyjęcia.",
|
||||
"short": "Dawka",
|
||||
"unitsPerDoseLabel": "Jednostki na dawkę",
|
||||
"unitsPerDoseHint": "np. 2 tabletki po 2 mg na dawkę 4 mg"
|
||||
"unitsPerDoseHint": "np. 2 tabletki po 2 mg na dawkę 4 mg lub ½ tabletki przy dzielonej dawce"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Przez jaki okres go przyjmujesz?",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.16.11",
|
||||
"version": "1.16.12",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- v1.16.12 (#316) — fractional dosing.
|
||||
--
|
||||
-- A dose can consume a FRACTION of a unit (½ / ⅓ / ¼ of a tablet for a
|
||||
-- split pill), so the unit↔dose factor and the per-container stock must
|
||||
-- hold non-integer values: 30 tablets dosed at ½ each reads 29.5 after
|
||||
-- one dose. Widen the three integer unit columns to NUMERIC; every
|
||||
-- existing integer value casts losslessly. Additive and reversible in
|
||||
-- value terms (all current rows are whole numbers).
|
||||
|
||||
ALTER TABLE "medications"
|
||||
ALTER COLUMN "units_per_dose" TYPE numeric(10,4) USING "units_per_dose"::numeric(10,4);
|
||||
|
||||
ALTER TABLE "medication_inventory_items"
|
||||
ALTER COLUMN "doses_total" TYPE numeric(12,4) USING "doses_total"::numeric(12,4),
|
||||
ALTER COLUMN "doses_remaining" TYPE numeric(12,4) USING "doses_remaining"::numeric(12,4);
|
||||
+12
-4
@@ -1282,7 +1282,12 @@ model Medication {
|
||||
/// them (2 × 2 mg tablets for a 4 mg dose). The intake consumption
|
||||
/// hook multiplies by this factor; dose-level readouts divide by it.
|
||||
/// Default 1 = one unit per dose (the overwhelmingly common case).
|
||||
unitsPerDose Int @default(1) @map("units_per_dose")
|
||||
/// v1.16.12 (#316) — Decimal, so a dose can consume a FRACTION of a
|
||||
/// unit (½ / ⅓ / ¼ tablet for a split pill). The UI offers a curated
|
||||
/// set of common fractions; the column stores their decimal value
|
||||
/// (¼=0.25, ⅓≈0.3333, ½=0.5, ⅔≈0.6667, ¾=0.75) plus whole numbers.
|
||||
/// Existing integer rows cast losslessly.
|
||||
unitsPerDose Decimal @default(1) @map("units_per_dose") @db.Decimal(10, 4)
|
||||
active Boolean @default(true)
|
||||
notificationsEnabled Boolean @default(true) @map("notifications_enabled")
|
||||
pausedAt DateTime? @map("paused_at")
|
||||
@@ -1783,11 +1788,14 @@ model MedicationInventoryItem {
|
||||
/// `dosesTotal`; the DB column stays `doses_total`). A unit is one
|
||||
/// tablet / ampoule / puff; `Medication.unitsPerDose` maps units to
|
||||
/// doses. Mounjaro KwikPen = 4, Trulicity single-dose = 1.
|
||||
unitsTotal Int @map("doses_total")
|
||||
/// v1.16.12 (#316) — Decimal so a split-pill consumption can leave a
|
||||
/// fractional remainder (30 tablets dosed at ½ each reads 29.5 after
|
||||
/// one dose). Existing integer rows cast losslessly.
|
||||
unitsTotal Decimal @map("doses_total") @db.Decimal(12, 4)
|
||||
/// Remaining units; decremented by the intake consumption hook
|
||||
/// (v1.16.10 — renamed from `dosesRemaining`; column stays
|
||||
/// `doses_remaining`).
|
||||
unitsRemaining Int @map("doses_remaining")
|
||||
/// `doses_remaining`). v1.16.12 (#316) — Decimal for fractional dosing.
|
||||
unitsRemaining Decimal @map("doses_remaining") @db.Decimal(12, 4)
|
||||
/// NULL until the pen is first opened. Sets the 30-day in-use
|
||||
/// clock per EMA EPAR §6.3.
|
||||
firstUseAt DateTime? @map("first_use_at")
|
||||
|
||||
@@ -92,15 +92,15 @@ export const GET = apiHandler(
|
||||
const usable = medication.inventoryItems.filter(
|
||||
(item) =>
|
||||
(item.state === "ACTIVE" || item.state === "IN_USE") &&
|
||||
item.unitsRemaining > 0,
|
||||
Number(item.unitsRemaining) > 0,
|
||||
);
|
||||
const unitsRemaining = usable.reduce(
|
||||
(sum, item) => sum + item.unitsRemaining,
|
||||
(sum, item) => sum + Number(item.unitsRemaining),
|
||||
0,
|
||||
);
|
||||
const pensRemaining = usable.length;
|
||||
const dosesRemaining = Math.floor(
|
||||
unitsRemaining / Math.max(1, medication.unitsPerDose),
|
||||
unitsRemaining / (Number(medication.unitsPerDose) || 1),
|
||||
);
|
||||
const weeksOfSupply = dosesRemaining;
|
||||
const lowStock = dosesRemaining < LOW_STOCK_DOSE_THRESHOLD;
|
||||
|
||||
@@ -27,6 +27,10 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
vi.mock("@/lib/medications/inventory/service", () => ({
|
||||
computeExpiresAt: vi.fn().mockReturnValue(null),
|
||||
buildPatchInventoryUpdate: vi.fn().mockReturnValue({}),
|
||||
// v1.16.12 — the route serialises its Decimal unit columns to numbers
|
||||
// on the way out; a passthrough keeps these update-logic assertions
|
||||
// focused on the Prisma call, not the response shape.
|
||||
serializeInventoryItem: <T,>(item: T) => item,
|
||||
}));
|
||||
vi.mock("@/lib/medications/route-guards", () => ({
|
||||
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
computeExpiresAt,
|
||||
computeInventoryState,
|
||||
} from "@/lib/medications/inventory/state-machine";
|
||||
import { serializeInventoryItem } from "@/lib/medications/inventory/service";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string; itemId: string }> };
|
||||
|
||||
@@ -83,7 +84,10 @@ export const PATCH = apiHandler(
|
||||
// commutative — applying them in any order produces the same row.
|
||||
let nextFirstUseAt = existing.firstUseAt;
|
||||
let nextState = existing.state;
|
||||
let nextUnitsRemaining = existing.unitsRemaining;
|
||||
// v1.16.12 — Decimal column; work in JS numbers locally (the
|
||||
// arithmetic below stays well within double precision) and let
|
||||
// Prisma re-widen on write.
|
||||
let nextUnitsRemaining: number = Number(existing.unitsRemaining);
|
||||
let nextPrintedExpiry = existing.printedExpiry;
|
||||
|
||||
if (markAsFirstUseAt) {
|
||||
@@ -103,7 +107,7 @@ export const PATCH = apiHandler(
|
||||
// state-machine re-run below owns every state consequence (0 ⇒
|
||||
// USED_UP, a raise out of 0 re-evaluates against the expiry
|
||||
// clocks).
|
||||
nextUnitsRemaining = Math.min(unitsRemaining, existing.unitsTotal);
|
||||
nextUnitsRemaining = Math.min(unitsRemaining, Number(existing.unitsTotal));
|
||||
}
|
||||
|
||||
if (markAsUsedUp === true) {
|
||||
@@ -126,7 +130,7 @@ export const PATCH = apiHandler(
|
||||
nextState = computeInventoryState(
|
||||
{
|
||||
state: nextState,
|
||||
unitsTotal: existing.unitsTotal,
|
||||
unitsTotal: Number(existing.unitsTotal),
|
||||
unitsRemaining: nextUnitsRemaining,
|
||||
firstUseAt: nextFirstUseAt,
|
||||
printedExpiry: nextPrintedExpiry,
|
||||
@@ -166,7 +170,7 @@ export const PATCH = apiHandler(
|
||||
meta: { medication_id: id, prev: existing.state, next: updated.state },
|
||||
});
|
||||
|
||||
return apiSuccess(updated);
|
||||
return apiSuccess(serializeInventoryItem(updated));
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -220,7 +220,11 @@ describe("PATCH /api/medications/[id]/inventory/[itemId]", () => {
|
||||
state: "IN_USE",
|
||||
} as never);
|
||||
|
||||
const intake = "2026-05-14T12:00:00Z";
|
||||
// Anchor first-use to one day ago so the 30-day in-use window is
|
||||
// always open relative to "now" — a previously hardcoded date
|
||||
// (2026-05-14) silently aged past its window and flipped the expected
|
||||
// state to EXPIRED once the wall clock crossed firstUseAt + 30 days.
|
||||
const intake = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
const res = await PATCH(
|
||||
jsonReq(
|
||||
"http://localhost/api/medications/med-1/inventory/inv-1",
|
||||
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { createInventoryItemSchema } from "@/lib/validations/medication";
|
||||
import { buildCreateInventoryInput } from "@/lib/medications/inventory/service";
|
||||
import {
|
||||
buildCreateInventoryInput,
|
||||
serializeInventoryItem,
|
||||
} from "@/lib/medications/inventory/service";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -54,7 +57,10 @@ export const GET = apiHandler(
|
||||
meta: { medication_id: id, total: items.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ items, meta: { total: items.length } });
|
||||
return apiSuccess({
|
||||
items: items.map(serializeInventoryItem),
|
||||
meta: { total: items.length },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -128,6 +134,6 @@ export const POST = apiHandler(
|
||||
meta: { medication_id: id },
|
||||
});
|
||||
|
||||
return apiSuccess(created, 201);
|
||||
return apiSuccess(serializeInventoryItem(created), 201);
|
||||
},
|
||||
);
|
||||
@@ -178,6 +178,7 @@ export const GET = apiHandler(
|
||||
|
||||
return apiSuccess({
|
||||
...medication,
|
||||
unitsPerDose: Number(medication.unitsPerDose),
|
||||
category,
|
||||
nextDueAt: display ? display.at.toISOString() : null,
|
||||
nextDueOverdue: display?.overdue ?? false,
|
||||
@@ -710,6 +711,7 @@ export const PUT = apiHandler(
|
||||
|
||||
return apiSuccess({
|
||||
...medication,
|
||||
unitsPerDose: Number(medication.unitsPerDose),
|
||||
category: normalizedCategory,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -185,7 +185,7 @@ async function buildMedicationsList(
|
||||
for (const entry of usableStock) {
|
||||
usableUnitsByMedId.set(
|
||||
entry.medicationId,
|
||||
entry._sum.unitsRemaining ?? 0,
|
||||
Number(entry._sum.unitsRemaining ?? 0),
|
||||
);
|
||||
}
|
||||
const trackedMedIds = new Set(inventoryCounts.map((e) => e.medicationId));
|
||||
@@ -233,9 +233,12 @@ async function buildMedicationsList(
|
||||
const stockDosesRemaining =
|
||||
stockUnitsRemaining === null
|
||||
? null
|
||||
: Math.floor(stockUnitsRemaining / Math.max(1, m.unitsPerDose));
|
||||
: Math.floor(stockUnitsRemaining / (Number(m.unitsPerDose) || 1));
|
||||
return {
|
||||
...m,
|
||||
// v1.16.12 — Decimal → number so the wire stays a JSON number, not
|
||||
// the string Prisma would otherwise serialise a Decimal to.
|
||||
unitsPerDose: Number(m.unitsPerDose),
|
||||
category: categoryMap[m.id] ?? "OTHER",
|
||||
lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null,
|
||||
todayEventCount: todayEventCountByMedId[m.id] ?? 0,
|
||||
@@ -489,6 +492,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiSuccess(
|
||||
{
|
||||
...medication,
|
||||
unitsPerDose: Number(medication.unitsPerDose),
|
||||
category: normalizedCategory,
|
||||
},
|
||||
201,
|
||||
|
||||
@@ -195,6 +195,15 @@ export default function MedicationsPage() {
|
||||
return apiGet<Medication[]>("/api/medications");
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
// v1.16.12 (#316) — refetch on every mount, even inside the global
|
||||
// staleTime window. App Router unmounts this page on navigation away
|
||||
// and remounts it on return; without this, a return within 5 min
|
||||
// serves the cached list and shows nothing the user changed on
|
||||
// another surface (the iOS app, a Withings sync, a second tab) — the
|
||||
// client cache can't know to invalidate a cross-device write. "always"
|
||||
// makes a return-visit fetch fresh; the per-user server cache keeps it
|
||||
// cheap. Mutation-driven invalidation still covers same-tab writes.
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
|
||||
// v1.16.11 — the same reminder-thresholds read the cards make (shared
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* v1.16.12 (#316) — fractional-dosing UI mapping.
|
||||
*
|
||||
* The wizard's fraction buttons must never offer a value the server's
|
||||
* validator would 422. This pins that contract: the selector's fraction
|
||||
* values equal {@link UNITS_PER_DOSE_FRACTIONS}, every offered option
|
||||
* passes {@link isSupportedUnitsPerDose}, the glyph formatting round-trips,
|
||||
* and a legacy / non-curated value is preserved as an extra option.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
UNITS_PER_DOSE_FRACTIONS,
|
||||
isSupportedUnitsPerDose,
|
||||
} from "@/lib/validations/medication";
|
||||
import {
|
||||
UNITS_PER_DOSE_OPTIONS,
|
||||
UNITS_PER_DOSE_FRACTION_VALUES,
|
||||
formatUnitsPerDose,
|
||||
formatUnitCount,
|
||||
unitsPerDoseOptionsFor,
|
||||
} from "@/components/medications/units-per-dose";
|
||||
|
||||
describe("units-per-dose selector ↔ validator alignment", () => {
|
||||
it("offers exactly the validator's fraction set", () => {
|
||||
expect([...UNITS_PER_DOSE_FRACTION_VALUES].sort()).toEqual(
|
||||
[...UNITS_PER_DOSE_FRACTIONS].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("offers only server-accepted values", () => {
|
||||
for (const opt of UNITS_PER_DOSE_OPTIONS) {
|
||||
expect(isSupportedUnitsPerDose(opt.value)).toBe(true);
|
||||
// The payload string round-trips to the same number.
|
||||
expect(Number(opt.raw)).toBe(opt.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatUnitsPerDose", () => {
|
||||
it("renders curated fractions as glyphs", () => {
|
||||
expect(formatUnitsPerDose(0.25)).toBe("¼");
|
||||
expect(formatUnitsPerDose(0.3333)).toBe("⅓");
|
||||
expect(formatUnitsPerDose(0.5)).toBe("½");
|
||||
expect(formatUnitsPerDose(0.6667)).toBe("⅔");
|
||||
expect(formatUnitsPerDose(0.75)).toBe("¾");
|
||||
});
|
||||
|
||||
it("renders whole / uncurated values as the plain number", () => {
|
||||
expect(formatUnitsPerDose(1)).toBe("1");
|
||||
expect(formatUnitsPerDose(2)).toBe("2");
|
||||
expect(formatUnitsPerDose(10)).toBe("10");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatUnitCount — display rounding", () => {
|
||||
it("passes whole and half counts through unchanged", () => {
|
||||
expect(formatUnitCount(30)).toBe(30);
|
||||
expect(formatUnitCount(29.5)).toBe(29.5);
|
||||
});
|
||||
|
||||
it("rounds the float noise a third-dose leaves", () => {
|
||||
expect(formatUnitCount(29.6667)).toBe(29.67);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unitsPerDoseOptionsFor", () => {
|
||||
it("returns the curated set for a curated current value", () => {
|
||||
expect(unitsPerDoseOptionsFor("0.5")).toBe(UNITS_PER_DOSE_OPTIONS);
|
||||
expect(unitsPerDoseOptionsFor("2")).toBe(UNITS_PER_DOSE_OPTIONS);
|
||||
});
|
||||
|
||||
it("appends a legacy / non-curated current value so an edit never drops it", () => {
|
||||
const opts = unitsPerDoseOptionsFor("10");
|
||||
expect(opts).toHaveLength(UNITS_PER_DOSE_OPTIONS.length + 1);
|
||||
expect(opts.at(-1)).toEqual({ value: 10, raw: "10", label: "10" });
|
||||
});
|
||||
|
||||
it("ignores an empty / invalid current value", () => {
|
||||
expect(unitsPerDoseOptionsFor("")).toBe(UNITS_PER_DOSE_OPTIONS);
|
||||
});
|
||||
});
|
||||
@@ -124,10 +124,12 @@ export interface MedicationCardBodyProps {
|
||||
/**
|
||||
* v1.16.11 — projected supply runway in whole days, set ONLY while it
|
||||
* sits below the user's low-stock threshold (the variants gate it).
|
||||
* Non-null renders one muted warning-toned "Vorrat: ≈ N Tage" line
|
||||
* under the next/last slot; null keeps the card free of stock noise.
|
||||
* Never set for as-needed medications (no consuming schedule, no
|
||||
* runway).
|
||||
* Non-null renders the muted warning-toned "Vorrat: ≈ N Tage" notice on
|
||||
* the compliance bars' meta row (beside the streak flame); null keeps the
|
||||
* card free of stock noise. Never set for as-needed medications (no
|
||||
* consuming schedule, no runway). v1.16.12 moved it off the standalone
|
||||
* line above the bars onto the reserved meta row to stop it shifting the
|
||||
* two-bar baseline across a grid row.
|
||||
*/
|
||||
lowStockRunwayDays?: number | null;
|
||||
|
||||
@@ -228,7 +230,13 @@ export function MedicationCardBody({
|
||||
// The card surface is a constant neutral surface — dose status is never
|
||||
// expressed as a background / border tint (the maintainer, recurring): only the
|
||||
// discreet status line / pill below communicates take-now / overdue.
|
||||
className={cn("h-full", active ? "" : "opacity-60")}
|
||||
//
|
||||
// v1.16.12 — the shadcn card primitive's airy `gap-4 md:gap-6` between
|
||||
// header and content read as a wide gulf below the category badge before
|
||||
// the first content line; tightened to a uniform `gap-3` here (the
|
||||
// header keeps its own `pb-2.5`) so the name / class / next-intake block
|
||||
// reads as one tight stack without touching the shared primitive.
|
||||
className={cn("h-full gap-3 md:gap-3", active ? "" : "opacity-60")}
|
||||
>
|
||||
<MedicationCardHeader
|
||||
name={name}
|
||||
@@ -250,26 +258,19 @@ export function MedicationCardBody({
|
||||
last={lastLine}
|
||||
/>
|
||||
|
||||
{/* v1.16.11 — low-stock context, ONLY below the user's runway
|
||||
threshold (the prop is pre-gated). One calm warning-toned
|
||||
line, not a badge: the card stays quiet until the supply
|
||||
actually needs attention. */}
|
||||
{active && !asNeeded && lowStockRunwayDays != null && (
|
||||
<p
|
||||
className="text-warning text-xs"
|
||||
data-slot="medication-card-low-stock"
|
||||
>
|
||||
{t("medications.cardLowStockRunway", {
|
||||
days: lowStockRunwayDays,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Compliance bars — always two rows; constant-height skeleton holds
|
||||
the slot while the query is in flight so the grid row stays even.
|
||||
A FAILED query swaps the skeleton for the same-footprint quiet
|
||||
error fallback (no bars, one notice line + retry) instead of
|
||||
sitting on the skeleton forever. */}
|
||||
sitting on the skeleton forever.
|
||||
|
||||
v1.16.12 — the low-stock runway notice rides the bars' always-
|
||||
mounted meta row (beside the streak flame, flush right) rather
|
||||
than a standalone line above the bars. The former line inserted
|
||||
a conditional flow row only on low-supply cards, pushing their
|
||||
bars down and breaking the two-bar baseline across a grid row;
|
||||
the meta row is reserved on every card in every state, so the
|
||||
notice now costs zero layout shift. */}
|
||||
{active &&
|
||||
!asNeeded &&
|
||||
(compliance ? (
|
||||
@@ -279,11 +280,17 @@ export function MedicationCardBody({
|
||||
streak={compliance.streak}
|
||||
shortDays={compliance.shortDays}
|
||||
longDays={compliance.longDays}
|
||||
lowStockRunwayDays={lowStockRunwayDays}
|
||||
/>
|
||||
) : complianceError && onRetryCompliance ? (
|
||||
<MedicationComplianceError onRetry={onRetryCompliance} />
|
||||
<MedicationComplianceError
|
||||
onRetry={onRetryCompliance}
|
||||
lowStockRunwayDays={lowStockRunwayDays}
|
||||
/>
|
||||
) : (
|
||||
<MedicationComplianceSkeleton />
|
||||
<MedicationComplianceSkeleton
|
||||
lowStockRunwayDays={lowStockRunwayDays}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Open-cycle status line — calm, rate-decoupled. The slot reserves
|
||||
|
||||
@@ -18,6 +18,54 @@ interface MedicationComplianceBarsProps {
|
||||
shortDays?: number;
|
||||
/** v1.8.6 — the span of the long row in days. */
|
||||
longDays?: number;
|
||||
/**
|
||||
* Projected supply runway in whole days, set ONLY while it sits below the
|
||||
* user's low-stock threshold (the card variants gate it). Non-null renders
|
||||
* the muted warning-toned "Vorrat: ≈ N Tage" notice on the meta row, flush
|
||||
* right beside the streak flame; null keeps the row free of stock noise.
|
||||
*/
|
||||
lowStockRunwayDays?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The always-mounted `min-h-4` meta row beneath the two bars. It carries the
|
||||
* streak flame (left) and, when supply has dropped below the user's runway
|
||||
* threshold, the low-stock notice (right, `ml-auto`). Both ride the SAME
|
||||
* reserved slot every compliance state already holds, so the low-stock line
|
||||
* no longer inserts a flow row above the bars — the two bars stay aligned
|
||||
* across a grid row whether or not a card is low on supply. The row stays
|
||||
* mounted at `min-h-4` even when empty so a streak / low-stock state arriving
|
||||
* or expiring never changes the card height.
|
||||
*/
|
||||
function ComplianceMetaRow({
|
||||
streak,
|
||||
lowStockRunwayDays,
|
||||
}: {
|
||||
streak: number;
|
||||
lowStockRunwayDays: number | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div className="flex min-h-4 items-center gap-4 text-xs">
|
||||
{streak > 0 && (
|
||||
<span className="text-warning flex items-center gap-1 font-medium">
|
||||
<Flame className="h-3.5 w-3.5" />
|
||||
{streak}{" "}
|
||||
{streak === 1
|
||||
? t("medications.dayStreakOne")
|
||||
: t("medications.dayStreak")}
|
||||
</span>
|
||||
)}
|
||||
{lowStockRunwayDays != null && (
|
||||
<span
|
||||
className="text-warning ml-auto font-medium"
|
||||
data-slot="medication-card-low-stock"
|
||||
>
|
||||
{t("medications.cardLowStockRunway", { days: lowStockRunwayDays })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,6 +96,7 @@ export function MedicationComplianceBars({
|
||||
streak,
|
||||
shortDays = 7,
|
||||
longDays = 30,
|
||||
lowStockRunwayDays = null,
|
||||
}: MedicationComplianceBarsProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -94,22 +143,12 @@ export function MedicationComplianceBars({
|
||||
<Progress value={rate30} className="h-2" aria-label={longLabel} />
|
||||
</div>
|
||||
|
||||
{/* Streak flame — the row is ALWAYS mounted at `min-h-4` so a
|
||||
streak arriving (or expiring) never changes the card height
|
||||
and the action row below stays on one baseline across a grid
|
||||
row. The flame itself still gates on `streak > 0`; the
|
||||
skeleton and error fallbacks reserve the same slot. */}
|
||||
<div className="flex min-h-4 items-center gap-4 text-xs">
|
||||
{streak > 0 && (
|
||||
<span className="text-warning flex items-center gap-1 font-medium">
|
||||
<Flame className="h-3.5 w-3.5" />
|
||||
{streak}{" "}
|
||||
{streak === 1
|
||||
? t("medications.dayStreakOne")
|
||||
: t("medications.dayStreak")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Streak flame + low-stock notice — the shared meta row, always
|
||||
mounted at `min-h-4` so a streak or low-stock state arriving (or
|
||||
expiring) never changes the card height and the action row below
|
||||
stays on one baseline across a grid row. The skeleton and error
|
||||
fallbacks reserve the same slot through the same component. */}
|
||||
<ComplianceMetaRow streak={streak} lowStockRunwayDays={lowStockRunwayDays} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -129,24 +168,34 @@ export function MedicationComplianceBars({
|
||||
* exists, so the skeleton must too or a resolving card would grow by one
|
||||
* row and shift the grid.
|
||||
*/
|
||||
export function MedicationComplianceSkeleton() {
|
||||
export function MedicationComplianceSkeleton({
|
||||
lowStockRunwayDays = null,
|
||||
}: {
|
||||
lowStockRunwayDays?: number | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2.5" aria-hidden>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex h-5 items-center justify-between">
|
||||
<span className="bg-muted h-4 w-20 rounded" />
|
||||
<span className="bg-muted h-4 w-9 rounded" />
|
||||
<div className="space-y-2.5">
|
||||
{/* Only the bars are a placeholder; the meta row below is real (the
|
||||
low-stock runway is independent of the in-flight compliance query),
|
||||
so a card that sits on the skeleton still surfaces low supply and
|
||||
announces it. */}
|
||||
<div className="space-y-2.5" aria-hidden>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex h-5 items-center justify-between">
|
||||
<span className="bg-muted h-4 w-20 rounded" />
|
||||
<span className="bg-muted h-4 w-9 rounded" />
|
||||
</div>
|
||||
<div className="bg-muted h-2 rounded" />
|
||||
</div>
|
||||
<div className="bg-muted h-2 rounded" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex h-5 items-center justify-between">
|
||||
<span className="bg-muted h-4 w-20 rounded" />
|
||||
<span className="bg-muted h-4 w-9 rounded" />
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex h-5 items-center justify-between">
|
||||
<span className="bg-muted h-4 w-20 rounded" />
|
||||
<span className="bg-muted h-4 w-9 rounded" />
|
||||
</div>
|
||||
<div className="bg-muted h-2 rounded" />
|
||||
</div>
|
||||
<div className="bg-muted h-2 rounded" />
|
||||
</div>
|
||||
<div className="min-h-4" />
|
||||
<ComplianceMetaRow streak={0} lowStockRunwayDays={lowStockRunwayDays} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -162,8 +211,10 @@ export function MedicationComplianceSkeleton() {
|
||||
*/
|
||||
export function MedicationComplianceError({
|
||||
onRetry,
|
||||
lowStockRunwayDays = null,
|
||||
}: {
|
||||
onRetry: () => void;
|
||||
lowStockRunwayDays?: number | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
@@ -189,8 +240,9 @@ export function MedicationComplianceError({
|
||||
</div>
|
||||
<div className="bg-muted/40 h-2 rounded" />
|
||||
</div>
|
||||
{/* Streak-slot parity with the loaded card + skeleton. */}
|
||||
<div className="min-h-4" />
|
||||
{/* Meta-row parity with the loaded card + skeleton — still surfaces
|
||||
low supply even when the compliance read failed. */}
|
||||
<ComplianceMetaRow streak={0} lowStockRunwayDays={lowStockRunwayDays} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -76,6 +76,7 @@ import { MedicationWizardDialog } from "@/components/medications/wizard/Medicati
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { formatUnitCount } from "@/components/medications/units-per-dose";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload";
|
||||
@@ -353,7 +354,12 @@ export function MedicationDetailTabs({
|
||||
// Availability rides the shared summary helper (ACTIVE / IN_USE with
|
||||
// units only — the list / GLP-1 semantic); expired stock surfaces as
|
||||
// a separate muted suffix and never feeds the runway.
|
||||
const perDose = Math.max(1, medication.unitsPerDose ?? 1);
|
||||
// v1.16.12 — guard at > 0, NOT ≥ 1, so a fractional unitsPerDose (½
|
||||
// tablet per dose) stays fractional instead of halving the dose counts.
|
||||
const perDose =
|
||||
medication.unitsPerDose && medication.unitsPerDose > 0
|
||||
? medication.unitsPerDose
|
||||
: 1;
|
||||
const {
|
||||
unitsRemaining,
|
||||
unitsTotal,
|
||||
@@ -461,12 +467,12 @@ export function MedicationDetailTabs({
|
||||
remaining: dosesRemaining,
|
||||
total: dosesTotal,
|
||||
})}
|
||||
{perDose > 1 && (
|
||||
{perDose !== 1 && (
|
||||
<span className="text-muted-foreground">
|
||||
{" ("}
|
||||
{t("medications.detail.bestand.unitsDetail", {
|
||||
remaining: unitsRemaining,
|
||||
total: unitsTotal,
|
||||
remaining: formatUnitCount(unitsRemaining),
|
||||
total: formatUnitCount(unitsTotal),
|
||||
})}
|
||||
{")"}
|
||||
</span>
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
import { SettingsGroup } from "@/components/medications/settings-group";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { formatUnitCount } from "@/components/medications/units-per-dose";
|
||||
import {
|
||||
apiDelete,
|
||||
apiGet,
|
||||
@@ -144,7 +145,9 @@ export function InventorySection({
|
||||
}
|
||||
}
|
||||
|
||||
const perDose = Math.max(1, unitsPerDose ?? 1);
|
||||
// v1.16.12 — guard at > 0, NOT ≥ 1: a fractional unitsPerDose (½ tablet
|
||||
// per dose) must stay fractional or the dose-derived counts halve.
|
||||
const perDose = unitsPerDose && unitsPerDose > 0 ? unitsPerDose : 1;
|
||||
|
||||
const { data, isLoading } = useQuery<InventoryResponse>({
|
||||
queryKey: queryKeys.medicationInventory(medicationId),
|
||||
@@ -152,6 +155,10 @@ export function InventorySection({
|
||||
return apiGet<InventoryResponse>(`/api/medications/${medicationId}/inventory`);
|
||||
},
|
||||
staleTime: 30_000,
|
||||
// v1.16.12 (#316) — fresh on every mount so reopening the supply tab
|
||||
// reflects stock changed elsewhere (a dose on another device, a
|
||||
// refill) without a manual reload.
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
|
||||
// v1.16.11 — the low-stock alert threshold, for the cross-link row
|
||||
@@ -262,11 +269,11 @@ export function InventorySection({
|
||||
<div className="flex items-center justify-between gap-3 py-3">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("medications.detail.bestand.summary", { remaining, total })}
|
||||
{perDose > 1 && (
|
||||
{perDose !== 1 && (
|
||||
<span className="text-muted-foreground block text-xs font-normal">
|
||||
{t("medications.detail.bestand.unitsDetail", {
|
||||
remaining: remainingUnits,
|
||||
total: totalUnits,
|
||||
remaining: formatUnitCount(remainingUnits),
|
||||
total: formatUnitCount(totalUnits),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
@@ -367,12 +374,12 @@ export function InventorySection({
|
||||
remaining: Math.floor(item.unitsRemaining / perDose),
|
||||
total: Math.floor(item.unitsTotal / perDose),
|
||||
})}
|
||||
{perDose > 1 && (
|
||||
{perDose !== 1 && (
|
||||
<>
|
||||
{" · "}
|
||||
{t("medications.detail.bestand.unitsDetail", {
|
||||
remaining: item.unitsRemaining,
|
||||
total: item.unitsTotal,
|
||||
remaining: formatUnitCount(item.unitsRemaining),
|
||||
total: formatUnitCount(item.unitsTotal),
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
@@ -648,8 +655,9 @@ function AdjustInventoryDialog({
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const parsed = Number(value);
|
||||
// v1.16.12 — fractional remaining allowed (a ½-tablet dose leaves 29.5).
|
||||
const valid =
|
||||
Number.isInteger(parsed) && parsed >= 0 && parsed <= item.unitsTotal;
|
||||
Number.isFinite(parsed) && parsed >= 0 && parsed <= item.unitsTotal;
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -694,10 +702,10 @@ function AdjustInventoryDialog({
|
||||
<Input
|
||||
id="inventory-adjust-remaining"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
max={item.unitsTotal}
|
||||
step={1}
|
||||
step="any"
|
||||
required
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* v1.16.12 (#316) — fractional-dosing UI mapping.
|
||||
*
|
||||
* The wizard offers a CURATED set of units-per-dose values: the sub-unit
|
||||
* fractions (¼ ⅓ ½ ⅔ ¾, for split pills) plus a few whole numbers
|
||||
* (multi-tablet doses). The payload + API carry the DECIMAL value; the
|
||||
* UI renders the glyph. Kept beside the validator's
|
||||
* {@link UNITS_PER_DOSE_FRACTIONS} (one source of truth) — a test asserts
|
||||
* the fraction values here match the validator exactly, so the buttons
|
||||
* can never offer a value the server would 422.
|
||||
*
|
||||
* Thirds are inexact in decimal (⅓ → 0.3333, ⅔ → 0.6667); the column is
|
||||
* Decimal(10,4) and the runway floor absorbs the sub-0.0001 drift.
|
||||
*/
|
||||
const FRACTION_GLYPHS: ReadonlyArray<{ value: number; glyph: string }> = [
|
||||
{ value: 0.25, glyph: "¼" },
|
||||
{ value: 0.3333, glyph: "⅓" },
|
||||
{ value: 0.5, glyph: "½" },
|
||||
{ value: 0.6667, glyph: "⅔" },
|
||||
{ value: 0.75, glyph: "¾" },
|
||||
];
|
||||
|
||||
/** The curated whole-number doses shown alongside the fractions. */
|
||||
const WHOLE_OPTIONS = [1, 2, 3, 4] as const;
|
||||
|
||||
export interface UnitsPerDoseOption {
|
||||
/** Numeric value (e.g. 0.5). */
|
||||
value: number;
|
||||
/** The string stored in the wizard payload + sent to the API. */
|
||||
raw: string;
|
||||
/** Display glyph / number (e.g. "½", "2"). */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Curated selector options: fractions first (½ reads naturally before 1), then wholes. */
|
||||
export const UNITS_PER_DOSE_OPTIONS: UnitsPerDoseOption[] = [
|
||||
...FRACTION_GLYPHS.map((f) => ({
|
||||
value: f.value,
|
||||
raw: String(f.value),
|
||||
label: f.glyph,
|
||||
})),
|
||||
...WHOLE_OPTIONS.map((n) => ({
|
||||
value: n,
|
||||
raw: String(n),
|
||||
label: String(n),
|
||||
})),
|
||||
];
|
||||
|
||||
/**
|
||||
* The exact fraction decimals the UI offers — exported for the alignment
|
||||
* test, which asserts (in Node, where server imports are fine) that this
|
||||
* equals the validator's `UNITS_PER_DOSE_FRACTIONS`. This module itself
|
||||
* stays free of any server import so it can ship in the client bundle.
|
||||
*/
|
||||
export const UNITS_PER_DOSE_FRACTION_VALUES = FRACTION_GLYPHS.map(
|
||||
(f) => f.value,
|
||||
);
|
||||
|
||||
/**
|
||||
* Round a unit count for display — drops the float noise a ⅓-dose
|
||||
* (0.3333/dose) leaves in a running remainder (29.6667 → 29.67). Whole
|
||||
* and ½ counts pass through unchanged.
|
||||
*/
|
||||
export function formatUnitCount(n: number): number {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a stored units-per-dose decimal: the fraction glyph (¼ … ¾) when
|
||||
* it matches a curated fraction, else the plain number ("2", "10").
|
||||
*/
|
||||
export function formatUnitsPerDose(value: number): string {
|
||||
const hit = FRACTION_GLYPHS.find((f) => Math.abs(f.value - value) < 0.001);
|
||||
return hit ? hit.glyph : String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The selector options for a given current value: the curated set, plus
|
||||
* the current value appended when it is a legacy / non-curated positive
|
||||
* number (e.g. an existing medication set to 10 units per dose), so an
|
||||
* edit never silently drops it.
|
||||
*/
|
||||
export function unitsPerDoseOptionsFor(current: string): UnitsPerDoseOption[] {
|
||||
if (UNITS_PER_DOSE_OPTIONS.some((o) => o.raw === current)) {
|
||||
return UNITS_PER_DOSE_OPTIONS;
|
||||
}
|
||||
const n = Number(current);
|
||||
if (Number.isFinite(n) && n > 0) {
|
||||
return [
|
||||
...UNITS_PER_DOSE_OPTIONS,
|
||||
{ value: n, raw: current, label: formatUnitsPerDose(n) },
|
||||
];
|
||||
}
|
||||
return UNITS_PER_DOSE_OPTIONS;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type InjectionSiteKey,
|
||||
} from "@/lib/medications/injection-sites";
|
||||
import type { MedicationDeliveryForm } from "@/lib/validations/medication";
|
||||
import { unitsPerDoseOptionsFor } from "@/components/medications/units-per-dose";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { StepProps } from "./Step1Name";
|
||||
@@ -168,23 +169,38 @@ export function Step3Dose({ payload, applyPartial }: StepProps) {
|
||||
decrements several per take and every dose-level readout
|
||||
divides by this factor. */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wizard-units-per-dose" className="text-sm">
|
||||
<Label id="wizard-units-per-dose-label" className="text-sm">
|
||||
{t("medications.wizard.steps.step3.unitsPerDoseLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="wizard-units-per-dose"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={payload.unitsPerDose}
|
||||
onChange={(e) =>
|
||||
applyPartial({
|
||||
unitsPerDose: e.target.value.replace(/[^0-9]/g, "").slice(0, 3),
|
||||
})
|
||||
}
|
||||
placeholder="1"
|
||||
maxLength={3}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{/* v1.16.12 (#316) — curated fraction / whole-number selector
|
||||
instead of a free-text field: split-pill doses (½ tablet) are
|
||||
now expressible, and a button set is the most error-resistant
|
||||
input (no ambiguous decimal separators, no out-of-set values
|
||||
the server would reject). The decimal value is what the
|
||||
payload + API carry; the button shows the glyph. */}
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby="wizard-units-per-dose-label"
|
||||
data-slot="wizard-units-per-dose"
|
||||
className="flex flex-wrap gap-1.5"
|
||||
>
|
||||
{unitsPerDoseOptionsFor(payload.unitsPerDose).map((opt) => {
|
||||
const selected = payload.unitsPerDose === opt.raw;
|
||||
return (
|
||||
<Button
|
||||
key={opt.raw}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selected ? "default" : "outline"}
|
||||
aria-pressed={selected}
|
||||
className="min-w-10 tabular-nums"
|
||||
onClick={() => applyPartial({ unitsPerDose: opt.raw })}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.wizard.steps.step3.unitsPerDoseHint")}
|
||||
</p>
|
||||
|
||||
@@ -700,7 +700,9 @@ export function buildCreateBody(
|
||||
: committed.schedules;
|
||||
|
||||
const parsedDosesPerUnit = Number.parseInt(committed.dosesPerUnit, 10);
|
||||
const parsedUnitsPerDose = Number.parseInt(committed.unitsPerDose, 10);
|
||||
// v1.16.12 — fractional dosing: parse as a float (½ → 0.5) and gate at
|
||||
// > 0, not the old integer `>= 1`, so a split-pill dose survives.
|
||||
const parsedUnitsPerDose = Number.parseFloat(committed.unitsPerDose);
|
||||
const body: CreateMedicationBody = {
|
||||
name: committed.name.trim(),
|
||||
dose,
|
||||
@@ -714,7 +716,7 @@ export function buildCreateBody(
|
||||
// v1.16.10 — always sent when valid (an edit back to 1 must reach
|
||||
// the server; the create default matches the schema default).
|
||||
...(Number.isFinite(parsedUnitsPerDose) &&
|
||||
parsedUnitsPerDose >= 1 && {
|
||||
parsedUnitsPerDose > 0 && {
|
||||
unitsPerDose: parsedUnitsPerDose,
|
||||
}),
|
||||
// v1.8.5 — injection-site tracking is only meaningful for an
|
||||
@@ -1064,7 +1066,7 @@ export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload
|
||||
? String(initial.dosesPerUnit)
|
||||
: "",
|
||||
unitsPerDose:
|
||||
typeof initial.unitsPerDose === "number" && initial.unitsPerDose >= 1
|
||||
typeof initial.unitsPerDose === "number" && initial.unitsPerDose > 0
|
||||
? String(initial.unitsPerDose)
|
||||
: "1",
|
||||
trackInjectionSites: initial.trackInjectionSites ?? false,
|
||||
|
||||
@@ -394,15 +394,15 @@ export async function buildGlp1SnapshotBlock(
|
||||
const usable = med.inventoryItems.filter(
|
||||
(item) =>
|
||||
(item.state === "ACTIVE" || item.state === "IN_USE") &&
|
||||
item.unitsRemaining > 0,
|
||||
Number(item.unitsRemaining) > 0,
|
||||
);
|
||||
const unitsRemaining = usable.reduce(
|
||||
(sum, item) => sum + item.unitsRemaining,
|
||||
(sum, item) => sum + Number(item.unitsRemaining),
|
||||
0,
|
||||
);
|
||||
pensRemaining = usable.length;
|
||||
dosesRemaining = Math.floor(
|
||||
unitsRemaining / Math.max(1, med.unitsPerDose),
|
||||
unitsRemaining / (Number(med.unitsPerDose) || 1),
|
||||
);
|
||||
weeksOfSupplyApprox = Math.round(dosesRemaining / dosesPerWeek);
|
||||
} else if (med.dosesPerUnit && med.inventoryEvents.length > 0) {
|
||||
|
||||
Vendored
+19
-4
@@ -447,14 +447,25 @@ export const caches = {
|
||||
* via plain `cached` are unaffected: a marked-stale entry has
|
||||
* `expiresAt === now`, so their next `cached` read is a clean miss and
|
||||
* rebuilds fresh — only the `cachedSwr` snapshot serves stale + warms a
|
||||
* background recompute. The 10-minute stale window bounds how old a
|
||||
* served snapshot can be when no reader triggers a revalidation.
|
||||
* background recompute. The stale window bounds how old a served
|
||||
* snapshot can be when no reader triggers a revalidation.
|
||||
*
|
||||
* v1.16.12 — stale window widened 10 min → 1 h. A single self-hoster
|
||||
* visits the dashboard / insights every ~20-30 min; the old 10-minute
|
||||
* window meant nearly every return landed PAST it (a `miss`, ~1.5 s of
|
||||
* synchronous rebuild) instead of inside it (a `stale`, instant + one
|
||||
* background refresh). Each stale-serve re-inserts, so an active session
|
||||
* stays warm regardless of the exact window; the value only governs how
|
||||
* long an ABSENCE still gets an instant response. 1 h covers a normal
|
||||
* break while keeping the dashboard's time-derived `nextDueOverdue` /
|
||||
* tally drift bounded — and that field already self-corrects within the
|
||||
* snapshot client's 120 s poll and the immediate background refresh.
|
||||
*/
|
||||
analytics: new ServerCache<unknown>({
|
||||
name: "analytics",
|
||||
maxEntries: 1000,
|
||||
ttlMs: 60_000,
|
||||
staleTtlMs: 600_000,
|
||||
staleTtlMs: 3_600_000,
|
||||
}),
|
||||
/**
|
||||
* v1.16.8 — stale-while-revalidate on the list bucket. The 60 s fresh
|
||||
@@ -596,7 +607,11 @@ export const caches = {
|
||||
name: "insightsDerived",
|
||||
maxEntries: 2000,
|
||||
ttlMs: 60_000,
|
||||
staleTtlMs: 600_000,
|
||||
// v1.16.12 — 10 min → 1 h, same rationale as the analytics bucket: the
|
||||
// derived-insight aggregates are trend data (no time-critical due
|
||||
// state), so a returning visitor serves an instant stale payload +
|
||||
// background refresh instead of a ~1.1 s cold rebuild.
|
||||
staleTtlMs: 3_600_000,
|
||||
}),
|
||||
/**
|
||||
* v1.5.5 — per-user insights tile layout cache. Mirrors
|
||||
|
||||
@@ -290,8 +290,13 @@ export async function runMedicationLowStockTick(
|
||||
summary.medicationsEvaluated += 1;
|
||||
|
||||
const evaluation = evaluateMedicationRunway(
|
||||
med.inventoryItems,
|
||||
med.unitsPerDose,
|
||||
// v1.16.12 — Decimal columns → JS numbers for the runway math.
|
||||
med.inventoryItems.map((it) => ({
|
||||
state: it.state,
|
||||
unitsTotal: Number(it.unitsTotal),
|
||||
unitsRemaining: Number(it.unitsRemaining),
|
||||
})),
|
||||
Number(med.unitsPerDose),
|
||||
med.schedules,
|
||||
);
|
||||
const decision = decideLowStockAction({
|
||||
|
||||
@@ -283,6 +283,41 @@ describe("consumeForIntake", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("consumes a FRACTION of a unit for a split-pill dose, stamps the fraction, and refunds it exactly on undo (v1.16.12 #316)", async () => {
|
||||
const state: FakeState = {
|
||||
unitsPerDose: 0.5,
|
||||
events: [takenEvent()],
|
||||
items: [
|
||||
item({
|
||||
id: "open",
|
||||
state: "IN_USE",
|
||||
unitsTotal: 30,
|
||||
unitsRemaining: 30,
|
||||
firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY),
|
||||
expiresAt: new Date(NOW.getTime() + 27 * MS_PER_DAY),
|
||||
}),
|
||||
],
|
||||
};
|
||||
const client = makeClient(state);
|
||||
await consumeForIntake(consumeArgs(client));
|
||||
|
||||
// 30 tablets dosed at ½ each leaves 29.5 — the fraction is NOT floored.
|
||||
expect(state.items[0].unitsRemaining).toBe(29.5);
|
||||
expect(state.items[0].state).toBe("IN_USE");
|
||||
expect(state.events[0].inventoryConsumption).toEqual([
|
||||
{ itemId: "open", units: 0.5 },
|
||||
]);
|
||||
|
||||
// Undo refunds exactly the 0.5 it consumed and clears the stamp.
|
||||
await restoreForIntake({
|
||||
client: client as never,
|
||||
userId: "user-1",
|
||||
eventId: "evt-1",
|
||||
});
|
||||
expect(state.items[0].unitsRemaining).toBe(30);
|
||||
expect(state.events[0].inventoryConsumption).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers the IN_USE container over fresher ACTIVE stock", async () => {
|
||||
const state: FakeState = {
|
||||
unitsPerDose: 1,
|
||||
|
||||
@@ -94,7 +94,10 @@ function parseStamp(value: Prisma.JsonValue | null): InventoryConsumptionEntry[]
|
||||
) {
|
||||
entries.push({
|
||||
itemId: (raw as { itemId: string }).itemId,
|
||||
units: Math.floor((raw as { units: number }).units),
|
||||
// v1.16.12 — the stamp carries FRACTIONAL units (½ tablet per
|
||||
// dose), so it is no longer floored: refunding a split-pill dose
|
||||
// must return the exact 0.5 it consumed.
|
||||
units: (raw as { units: number }).units,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -165,7 +168,13 @@ export async function consumeForIntake(input: {
|
||||
select: { unitsPerDose: true },
|
||||
});
|
||||
if (!medication) return;
|
||||
const unitsPerDose = Math.max(1, medication.unitsPerDose);
|
||||
// v1.16.12 — Decimal column; a dose may consume a FRACTION of a
|
||||
// unit (½ / ¼ tablet for a split pill). Convert to a JS number for
|
||||
// the arithmetic below (unit counts stay well within double
|
||||
// precision) and gate at > 0, NOT at ≥ 1 — the old `Math.max(1, …)`
|
||||
// clamp would silently turn a half-tablet dose back into a whole one.
|
||||
const unitsPerDose = Number(medication.unitsPerDose);
|
||||
if (!(unitsPerDose > 0)) return;
|
||||
|
||||
// Candidate containers, in consumption order: the open container
|
||||
// first (earliest in-use deadline), then FEFO over unopened
|
||||
@@ -211,12 +220,12 @@ export async function consumeForIntake(input: {
|
||||
|
||||
for (const item of [...inUse, ...active]) {
|
||||
if (owed <= 0) break;
|
||||
const take = Math.min(owed, item.unitsRemaining);
|
||||
const take = Math.min(owed, Number(item.unitsRemaining));
|
||||
if (take <= 0) continue;
|
||||
|
||||
const wasUnopened = item.firstUseAt === null;
|
||||
const nextFirstUseAt = item.firstUseAt ?? intakeAt;
|
||||
const nextUnitsRemaining = item.unitsRemaining - take;
|
||||
const nextUnitsRemaining = Number(item.unitsRemaining) - take;
|
||||
const nextExpiresAt = computeExpiresAt(
|
||||
nextFirstUseAt,
|
||||
item.printedExpiry,
|
||||
@@ -224,7 +233,7 @@ export async function consumeForIntake(input: {
|
||||
const nextState = computeInventoryState(
|
||||
{
|
||||
state: item.state,
|
||||
unitsTotal: item.unitsTotal,
|
||||
unitsTotal: Number(item.unitsTotal),
|
||||
unitsRemaining: nextUnitsRemaining,
|
||||
firstUseAt: nextFirstUseAt,
|
||||
printedExpiry: item.printedExpiry,
|
||||
@@ -345,14 +354,14 @@ export async function restoreForIntake(input: {
|
||||
if (!item) continue; // container deleted since — nothing to refund.
|
||||
|
||||
const nextUnitsRemaining = Math.min(
|
||||
item.unitsTotal,
|
||||
item.unitsRemaining + entry.units,
|
||||
Number(item.unitsTotal),
|
||||
Number(item.unitsRemaining) + entry.units,
|
||||
);
|
||||
const refundedHere = nextUnitsRemaining - item.unitsRemaining;
|
||||
const refundedHere = nextUnitsRemaining - Number(item.unitsRemaining);
|
||||
const nextState = computeInventoryState(
|
||||
{
|
||||
state: item.state,
|
||||
unitsTotal: item.unitsTotal,
|
||||
unitsTotal: Number(item.unitsTotal),
|
||||
unitsRemaining: nextUnitsRemaining,
|
||||
firstUseAt: item.firstUseAt,
|
||||
printedExpiry: item.printedExpiry,
|
||||
|
||||
@@ -50,6 +50,28 @@ export async function expireStaleInUseItems(input: {
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.12 (#316) — Prisma serialises a Decimal column to a JSON STRING,
|
||||
* but the API contract (and the iOS client) read an inventory item's
|
||||
* `unitsTotal` / `unitsRemaining` as numbers. Convert at every response
|
||||
* boundary that returns an item, so a fractional remainder (29.5 after a
|
||||
* half-tablet dose) lands as a JSON number, never `"29.5"`.
|
||||
*/
|
||||
export function serializeInventoryItem<
|
||||
T extends { unitsTotal: unknown; unitsRemaining: unknown },
|
||||
>(
|
||||
item: T,
|
||||
): Omit<T, "unitsTotal" | "unitsRemaining"> & {
|
||||
unitsTotal: number;
|
||||
unitsRemaining: number;
|
||||
} {
|
||||
return {
|
||||
...item,
|
||||
unitsTotal: Number(item.unitsTotal),
|
||||
unitsRemaining: Number(item.unitsRemaining),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for the POST /[medId]/inventory route. Composes the
|
||||
* Prisma-create with the computed `expiresAt` so the caller doesn't
|
||||
|
||||
@@ -47,7 +47,9 @@ export function summariseSupply(
|
||||
items: readonly SupplyItem[],
|
||||
unitsPerDose: number,
|
||||
): SupplySummary {
|
||||
const perDose = Math.max(1, unitsPerDose);
|
||||
// v1.16.12 — guard at > 0, NOT ≥ 1: a fractional unitsPerDose (½ tablet
|
||||
// per dose) must stay fractional, else the dose-derived counts halve.
|
||||
const perDose = unitsPerDose > 0 ? unitsPerDose : 1;
|
||||
const available = items.filter(isAvailableSupply);
|
||||
const unitsRemaining = available.reduce(
|
||||
(sum, item) => sum + item.unitsRemaining,
|
||||
|
||||
@@ -149,9 +149,8 @@ export const medicationResource = z
|
||||
),
|
||||
unitsPerDose: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.",
|
||||
"v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). v1.16.12 — may be a split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾); thirds carry as ≈0.3333 / 0.6667. Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.",
|
||||
),
|
||||
active: z.boolean(),
|
||||
notificationsEnabled: z.boolean(),
|
||||
@@ -250,7 +249,7 @@ const medicationListEntry = medicationResource
|
||||
.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.",
|
||||
"v1.16.10 — dose-derived stock: `floor(stockUnitsRemaining / unitsPerDose)`, where `unitsPerDose` may be a fraction (½ tablet ⇒ twice the doses). Stays a whole-dose count. NULL when inventory tracking is off. Drives the table view's Bestand column. Read-only — aggregated, not stored.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
@@ -290,15 +289,13 @@ const medicationInventoryItemResource = z
|
||||
),
|
||||
unitsTotal: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"Units the container shipped with (tablets / ampoules / puffs; 1–1000). Dose-derived readouts divide by the medication's `unitsPerDose`.",
|
||||
"Units the container shipped with (tablets / ampoules / puffs; 1–1000). v1.16.12 — fractional, so a split-pill remainder reads cleanly. Dose-derived readouts divide by the medication's `unitsPerDose`.",
|
||||
),
|
||||
unitsRemaining: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"Units left in the container. Decremented by the intake consumption hook (FEFO with spillover across containers); refunded when a taken dose is skipped, edited away, or deleted.",
|
||||
"Units left in the container. v1.16.12 — fractional (a ½-tablet dose leaves 29.5 of 30). Decremented by the intake consumption hook (FEFO with spillover across containers); refunded when a taken dose is skipped, edited away, or deleted.",
|
||||
),
|
||||
firstUseAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
|
||||
@@ -64,11 +64,14 @@ export function useMedicationComplianceSummary(medicationId: string): {
|
||||
const { data, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.medicationComplianceSummary(),
|
||||
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
|
||||
// the cards mount alongside.
|
||||
// Dose actions in this tab invalidate the key through
|
||||
// `medicationDependentKeys`. v1.16.12 (#316) — but a dose logged on
|
||||
// another surface (the iOS app) leaves this stale for up to the
|
||||
// window, so refetch on every mount: returning to the page reflects
|
||||
// cross-device intakes without a manual reload. The batched read is
|
||||
// one request per visit, server-cached.
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnMount: "always",
|
||||
select: (rows: MedicationComplianceSummaryEntry[]) =>
|
||||
rows.find((row) => row.medicationId === medicationId) ?? null,
|
||||
});
|
||||
@@ -88,6 +91,8 @@ export function useMedicationComplianceSummaryAll(): {
|
||||
queryKey: queryKeys.medicationComplianceSummary(),
|
||||
queryFn: fetchComplianceSummary,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
// v1.16.12 (#316) — fresh on return-navigation (see the per-row hook).
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
return { data };
|
||||
}
|
||||
@@ -155,6 +155,43 @@ describe("medication schemas — dosesPerUnit cap 1000 + unitsPerDose 1–100",
|
||||
updateMedicationSchema.safeParse({ unitsPerDose: 101 }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// v1.16.12 (#316) — fractional dosing: a curated set of split-pill
|
||||
// fractions is accepted alongside the whole numbers; an arbitrary
|
||||
// decimal is NOT (the UI only ever sends a curated value).
|
||||
it("accepts every curated unitsPerDose fraction on create + update", () => {
|
||||
for (const v of [0.25, 0.3333, 0.5, 0.6667, 0.75]) {
|
||||
expect(
|
||||
createMedicationSchema.safeParse({
|
||||
...MINIMAL_MEDICATION,
|
||||
unitsPerDose: v,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(updateMedicationSchema.safeParse({ unitsPerDose: v }).success).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an uncurated fractional unitsPerDose", () => {
|
||||
for (const v of [0.333, 0.1, 0.4, 1.5, 2.5]) {
|
||||
expect(
|
||||
createMedicationSchema.safeParse({
|
||||
...MINIMAL_MEDICATION,
|
||||
unitsPerDose: v,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a fractional remaining-stock correction (½-tablet leftover)", () => {
|
||||
expect(
|
||||
updateInventoryItemSchema.safeParse({ unitsRemaining: 29.5 }).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
createInventoryItemSchema.safeParse({ unitsTotal: 1.5 }).success,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("glp1InventoryPostSchema — the legacy pen-ledger delta keeps ±100", () => {
|
||||
|
||||
@@ -98,6 +98,36 @@ export const MEDICATION_TREATMENT_CLASS_VALUES = ["GENERIC", "GLP1"] as const;
|
||||
export type MedicationTreatmentClass =
|
||||
(typeof MEDICATION_TREATMENT_CLASS_VALUES)[number];
|
||||
|
||||
/**
|
||||
* v1.16.12 (#316) — fractional dosing. A dose may consume a sub-unit
|
||||
* fraction of a tablet (split pills). The UI offers a CURATED set of
|
||||
* common fractions (¼ ⅓ ½ ⅔ ¾), stored as their decimal value; thirds
|
||||
* are inexact in decimal (⅓ ≈ 0.3333, ⅔ ≈ 0.6667) — the @db.Decimal(10,4)
|
||||
* column and the runway floor absorb the sub-0.0001 drift. Whole numbers
|
||||
* 1..100 (multi-tablet doses, the pre-v1.16.12 contract) stay valid
|
||||
* alongside the fractions. One source of truth — the UI selector and the
|
||||
* tests import this set so the allowed values can never drift from the
|
||||
* validator.
|
||||
*/
|
||||
export const UNITS_PER_DOSE_FRACTIONS = [
|
||||
0.25, 0.3333, 0.5, 0.6667, 0.75,
|
||||
] as const;
|
||||
export const UNITS_PER_DOSE_MAX_WHOLE = 100;
|
||||
|
||||
export function isSupportedUnitsPerDose(value: number): boolean {
|
||||
if (
|
||||
(UNITS_PER_DOSE_FRACTIONS as readonly number[]).includes(value)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
Number.isInteger(value) && value >= 1 && value <= UNITS_PER_DOSE_MAX_WHOLE
|
||||
);
|
||||
}
|
||||
|
||||
const UNITS_PER_DOSE_MESSAGE =
|
||||
"unitsPerDose must be a whole number 1–100 or a supported fraction (¼, ⅓, ½, ⅔, ¾)";
|
||||
|
||||
/**
|
||||
* v1.6.0 — route of administration. Decoupled from `treatmentClass`:
|
||||
* the injection-site picker surfaces for any `INJECTION` dose, and a
|
||||
@@ -489,12 +519,10 @@ export const createMedicationSchema = z
|
||||
*/
|
||||
unitsPerDose: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.refine(isSupportedUnitsPerDose, { message: UNITS_PER_DOSE_MESSAGE })
|
||||
.optional()
|
||||
.describe(
|
||||
"Inventory units consumed per dose (e.g. 2 tablets of 2 mg for a 4 mg dose). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.",
|
||||
"Inventory units consumed per dose. A whole number 1–100 (e.g. 2 tablets of 2 mg for a 4 mg dose) or a supported fraction for a split pill (¼ / ⅓ / ½ / ⅔ / ¾). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.",
|
||||
),
|
||||
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
|
||||
deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(),
|
||||
@@ -592,15 +620,14 @@ export const updateMedicationSchema = z
|
||||
category: z.enum(MEDICATION_CATEGORY_VALUES).optional(),
|
||||
treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(),
|
||||
dosesPerUnit: z.number().int().min(1).max(1000).nullable().optional(),
|
||||
/** v1.16.10 — inventory units one dose consumes. Default 1. */
|
||||
/** v1.16.10 — inventory units one dose consumes. Default 1.
|
||||
* v1.16.12 — whole number 1–100 or a curated fraction (½ / ⅓ / ¼ …). */
|
||||
unitsPerDose: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.refine(isSupportedUnitsPerDose, { message: UNITS_PER_DOSE_MESSAGE })
|
||||
.optional()
|
||||
.describe(
|
||||
"Inventory units consumed per dose (e.g. 2 tablets of 2 mg for a 4 mg dose). The intake consumption hook decrements this many units per taken dose; already-stamped intake events keep their recorded consumption.",
|
||||
"Inventory units consumed per dose — a whole number 1–100 or a supported split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾). The intake consumption hook decrements this many units per taken dose; already-stamped intake events keep their recorded consumption.",
|
||||
),
|
||||
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
|
||||
deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(),
|
||||
@@ -917,11 +944,10 @@ export const createInventoryItemSchema = z
|
||||
* `Medication.unitsPerDose`. */
|
||||
unitsTotal: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(1000)
|
||||
.describe(
|
||||
"Units the container ships with (tablets / ampoules / puffs; 1–1000). Dose-derived readouts divide by the medication's `unitsPerDose`.",
|
||||
"Units the container ships with (tablets / ampoules / puffs; 1–1000, fractional allowed for split-pill packs). Dose-derived readouts divide by the medication's `unitsPerDose`.",
|
||||
),
|
||||
/** v1.16.10 — container kind. Defaults to OTHER when absent. */
|
||||
containerType: z
|
||||
@@ -979,12 +1005,11 @@ export const updateInventoryItemSchema = z
|
||||
*/
|
||||
unitsRemaining: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(1000)
|
||||
.optional()
|
||||
.describe(
|
||||
"Absolute remaining-unit correction (0–1000). Clamped server-side to the item's `unitsTotal`; the canonical state machine re-derives the state (0 ⇒ USED_UP).",
|
||||
"Absolute remaining-unit correction (0–1000, fractional allowed). Clamped server-side to the item's `unitsTotal`; the canonical state machine re-derives the state (0 ⇒ USED_UP).",
|
||||
),
|
||||
notes: z.string().max(200).nullable().optional(),
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user