mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(medications): an api tab, a stockable inventory and a next-due that tells the truth
The detail page gains a dedicated API tab, the full-edit button becomes a lifecycle row that opens the editor, the danger zone drops its red wash in favour of the shared card chrome, and the card menu's edit entry leads to the detail page it describes. The inventory tab finally records stock — packs and pens with optional expiry, plus corrections — instead of pointing at an API. The card's next-dose line reads from the canonical band model instead of a stale schedule window, which also showed old dose times after a schedule change; the repair script learns to reconcile drifted windows and tombstone pending rows parked on abandoned anchors.
This commit is contained in:
1 parent
a2524b6d70
commit
258e2fd365
22 files changed
+1373
-221
No files matched your search
@@ -1,7 +1,8 @@
|
||||
# Medication intake repair
|
||||
|
||||
`scripts/repair-intake-anomalies.ts` repairs two known historic defects in
|
||||
the medication intake ledger (`medication_intake_events`):
|
||||
`scripts/repair-intake-anomalies.ts` repairs the known historic defects in
|
||||
the medication intake ledger (`medication_intake_events`) and its schedule
|
||||
metadata (`medication_schedules`):
|
||||
|
||||
1. **Duplicate dose-slot rows.** More than one live row on the same exact
|
||||
`(user, medication, scheduled_for)` tuple — cross-source duplicates the
|
||||
@@ -12,6 +13,20 @@ the medication intake ledger (`medication_intake_events`):
|
||||
7 days before `scheduled_for` or more than 1 day in the future —
|
||||
typically the residue of a mis-attributed edit before the v1.15.19
|
||||
`taken_at` validation landed.
|
||||
3. **Window / times drift (v1.16.1).** Schedule rows whose legacy
|
||||
`window_start` / `window_end` no longer contains the canonical
|
||||
`times_of_day` — typically a degenerate `07:00 / 07:00` point window
|
||||
left behind by an old write path while the dose times moved on (e.g.
|
||||
to `09:00 / 21:00`). The dose-band model never reads the window once
|
||||
`times_of_day` exists, but the stale pair kept feeding every legacy
|
||||
read (the pre-v1.16.1 cards painted "next intake 07:00" from it).
|
||||
4. **Stale-anchor pending rows (v1.16.1).** Live pending rows
|
||||
(`taken_at IS NULL`, not skipped, not auto-missed) whose
|
||||
`scheduled_for` wall-clock HH:mm — evaluated in the user's timezone —
|
||||
matches no current dose anchor of the medication (any schedule's
|
||||
`times_of_day` entry; `window_start` for legacy rows without times).
|
||||
These are reminder-minted slots on retired schedule times that linger
|
||||
as phantom open doses in the ledger.
|
||||
|
||||
## When to use it
|
||||
|
||||
@@ -51,8 +66,10 @@ Use `pnpm dlx`, not bare `pnpm tsx` — the standalone image also strips
|
||||
`tsx`.
|
||||
|
||||
The dry-run prints every duplicate group (which row would be kept, which
|
||||
would be tombstoned) and a table of implausible rows (id, medication,
|
||||
`scheduledFor`, `takenAt`, source, timestamps).
|
||||
would be tombstoned), a table of implausible rows (id, medication,
|
||||
`scheduledFor`, `takenAt`, source, timestamps), every schedule whose
|
||||
window has drifted off its `times_of_day` (with the reconciled bounds it
|
||||
would write), and a table of stale-anchor pending rows.
|
||||
|
||||
## Applying the fix
|
||||
|
||||
@@ -60,7 +77,7 @@ would be tombstoned) and a table of implausible rows (id, medication,
|
||||
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
|
||||
```
|
||||
|
||||
`--fix` does three things:
|
||||
`--fix` does five things:
|
||||
|
||||
- **Duplicate groups** are collapsed to one winner per slot. Precedence:
|
||||
an actioned row (taken or skipped) beats a pending one; between two
|
||||
@@ -79,6 +96,14 @@ pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
|
||||
```
|
||||
|
||||
`--tombstone-implausible` is refused without `--fix`.
|
||||
- **Drifted schedule windows are reconciled** to
|
||||
`min/max(times_of_day)` so the legacy pair stops contradicting the
|
||||
canonical dose times. Overnight windows (`window_end < window_start`)
|
||||
encode a deliberate wrap and are never touched.
|
||||
- **Stale-anchor pending rows are tombstoned** (same soft-delete shape
|
||||
as the duplicate losers). A medication without any derivable anchor
|
||||
(PRN-only / unscheduled) is skipped — there is nothing to compare
|
||||
against.
|
||||
- **Affected compliance rollups are recomputed** for every touched
|
||||
`(user, medication, day)`. The script executes the same DISTINCT-slot
|
||||
aggregation SQL the shared helper runs (a verbatim twin of
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* scripts/repair-intake-anomalies.ts — operator repair for the two known
|
||||
* scripts/repair-intake-anomalies.ts — operator repair for the known
|
||||
* historic medication-ledger defects:
|
||||
*
|
||||
* 1. SLOT DUPLICATES — more than one live `medication_intake_events` row
|
||||
@@ -18,6 +18,26 @@
|
||||
* `--fix --tombstone-implausible` the operator can explicitly opt
|
||||
* into soft-deleting them.
|
||||
*
|
||||
* 3. WINDOW / TIMES DRIFT (v1.16.1) — `medication_schedules` rows whose
|
||||
* legacy `window_start` / `window_end` no longer contains the
|
||||
* canonical `times_of_day` (e.g. a degenerate 07:00 / 07:00 window
|
||||
* left behind while the dose times moved to 09:00 / 21:00). The
|
||||
* band model never reads the window when `times_of_day` exists, but
|
||||
* the stale pair keeps confusing every legacy read. Repair: clamp
|
||||
* `window_start` / `window_end` to `min/max(times_of_day)`. Report
|
||||
* always; mutate with `--fix`. Overnight windows
|
||||
* (`window_end < window_start`) are skipped — they encode a
|
||||
* deliberate wrap and cannot drift in this shape.
|
||||
*
|
||||
* 4. STALE-ANCHOR PENDING ROWS (v1.16.1) — live pending rows
|
||||
* (`taken_at IS NULL`, not skipped, not auto-missed) whose
|
||||
* `scheduled_for` wall-clock HH:mm (user timezone) matches NO
|
||||
* current dose anchor of the medication (any schedule's
|
||||
* `times_of_day` entry; `window_start` for legacy rows without
|
||||
* times). These are reminder-minted slots on retired schedule
|
||||
* anchors — they linger as phantom open doses. Repair: tombstone
|
||||
* with `--fix` (same soft-delete shape as defect 1).
|
||||
*
|
||||
* After any mutation the affected `(user, medication, day)` compliance
|
||||
* rollups are recomputed with the SAME slot-level DISTINCT aggregation the
|
||||
* shared helper runs — see `recomputeComplianceDay` below, a verbatim
|
||||
@@ -350,9 +370,30 @@ interface Summary {
|
||||
rowsTombstoned: number;
|
||||
implausibleRows: number;
|
||||
implausibleTombstoned: number;
|
||||
windowDriftSchedules: number;
|
||||
windowsReconciled: number;
|
||||
staleAnchorRows: number;
|
||||
staleAnchorTombstoned: number;
|
||||
rollupsRecomputed: number;
|
||||
}
|
||||
|
||||
const HHMM_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
|
||||
/** Wall-clock `HH:mm` of `instant` in the user's timezone. */
|
||||
function hhmmInTz(instant: Date, tz: string | null | undefined): string {
|
||||
const parts = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: safeTimezone(tz),
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(instant);
|
||||
const get = (type: Intl.DateTimeFormatPartTypes): string =>
|
||||
parts.find((p) => p.type === type)?.value ?? "00";
|
||||
// Some engines render midnight as 24:xx; normalise to 00.
|
||||
const hour = String(Number(get("hour")) % 24).padStart(2, "0");
|
||||
return `${hour}:${get("minute")}`;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error(
|
||||
@@ -384,6 +425,10 @@ async function main(): Promise<void> {
|
||||
rowsTombstoned: 0,
|
||||
implausibleRows: 0,
|
||||
implausibleTombstoned: 0,
|
||||
windowDriftSchedules: 0,
|
||||
windowsReconciled: 0,
|
||||
staleAnchorRows: 0,
|
||||
staleAnchorTombstoned: 0,
|
||||
rollupsRecomputed: 0,
|
||||
};
|
||||
|
||||
@@ -591,13 +636,184 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// 3. Recompute the affected compliance rollups so scheduled/taken
|
||||
// 3. Window / times drift — schedules whose legacy window no longer
|
||||
// contains the canonical times_of_day. Reconcile the window to
|
||||
// min/max(times_of_day) with --fix. Overnight windows
|
||||
// (window_end < window_start) are skipped by the predicate.
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
const schedUserFilter = opts.userId ? `AND m."user_id" = $1` : "";
|
||||
const driftRes = await client.query<{
|
||||
id: string;
|
||||
medication_id: string;
|
||||
user_id: string;
|
||||
medication_name: string;
|
||||
window_start: string;
|
||||
window_end: string;
|
||||
times_min: string;
|
||||
times_max: string;
|
||||
}>(
|
||||
`
|
||||
SELECT s."id", s."medication_id", m."user_id",
|
||||
m."name" AS medication_name,
|
||||
s."window_start", s."window_end",
|
||||
(SELECT MIN(t) FROM unnest(s."times_of_day") AS t) AS times_min,
|
||||
(SELECT MAX(t) FROM unnest(s."times_of_day") AS t) AS times_max
|
||||
FROM "medication_schedules" s
|
||||
JOIN "medications" m ON m."id" = s."medication_id"
|
||||
WHERE COALESCE(array_length(s."times_of_day", 1), 0) > 0
|
||||
AND s."window_start" <= s."window_end"
|
||||
AND (
|
||||
(SELECT MIN(t) FROM unnest(s."times_of_day") AS t) < s."window_start"
|
||||
OR (SELECT MAX(t) FROM unnest(s."times_of_day") AS t) > s."window_end"
|
||||
)
|
||||
${schedUserFilter}
|
||||
ORDER BY m."user_id", s."medication_id", s."id"
|
||||
`,
|
||||
userParams,
|
||||
);
|
||||
|
||||
summary.windowDriftSchedules = driftRes.rows.length;
|
||||
console.log(
|
||||
`\n${TAG} 3) schedules whose window does not contain times_of_day: ${driftRes.rows.length}`,
|
||||
);
|
||||
for (const row of driftRes.rows) {
|
||||
console.log(
|
||||
` - schedule=${row.id} medication=${row.medication_id} (${row.medication_name}) ` +
|
||||
`window=${row.window_start}..${row.window_end} → ` +
|
||||
`${row.times_min}..${row.times_max}`,
|
||||
);
|
||||
if (!HHMM_RE.test(row.times_min) || !HHMM_RE.test(row.times_max)) {
|
||||
console.log(
|
||||
`${TAG} SKIP: malformed times_of_day bounds on schedule ${row.id}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (opts.fix) {
|
||||
const res = await client.query(
|
||||
`
|
||||
UPDATE "medication_schedules"
|
||||
SET "window_start" = $2,
|
||||
"window_end" = $3
|
||||
WHERE "id" = $1
|
||||
`,
|
||||
[row.id, row.times_min, row.times_max],
|
||||
);
|
||||
summary.windowsReconciled += res.rowCount ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// 4. Stale-anchor pending rows — live pending rows whose
|
||||
// scheduled_for HH:mm (user tz) matches no current dose anchor of
|
||||
// the medication. Tombstone with --fix.
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
const pendingRes = await client.query<{
|
||||
id: string;
|
||||
user_id: string;
|
||||
medication_id: string;
|
||||
medication_name: string;
|
||||
scheduled_for: Date;
|
||||
source: string;
|
||||
}>(
|
||||
`
|
||||
SELECT e."id", e."user_id", e."medication_id",
|
||||
m."name" AS medication_name,
|
||||
e."scheduled_for", e."source"::text AS source
|
||||
FROM "medication_intake_events" e
|
||||
JOIN "medications" m ON m."id" = e."medication_id"
|
||||
WHERE e."deleted_at" IS NULL
|
||||
AND e."taken_at" IS NULL
|
||||
AND e."skipped" = false
|
||||
AND e."auto_missed" = false
|
||||
${userFilter}
|
||||
ORDER BY e."user_id", e."scheduled_for"
|
||||
`,
|
||||
userParams,
|
||||
);
|
||||
|
||||
/** `medicationId -> Set<HH:mm>` of current dose anchors. */
|
||||
const anchorCache = new Map<string, Set<string>>();
|
||||
async function medicationAnchors(
|
||||
medicationId: string,
|
||||
): Promise<Set<string>> {
|
||||
const cached = anchorCache.get(medicationId);
|
||||
if (cached) return cached;
|
||||
const res = await client.query<{
|
||||
window_start: string;
|
||||
times_of_day: string[];
|
||||
}>(
|
||||
`SELECT "window_start", "times_of_day"
|
||||
FROM "medication_schedules" WHERE "medication_id" = $1`,
|
||||
[medicationId],
|
||||
);
|
||||
const anchors = new Set<string>();
|
||||
for (const s of res.rows) {
|
||||
const times = (s.times_of_day ?? []).filter((t) => HHMM_RE.test(t));
|
||||
if (times.length > 0) {
|
||||
for (const t of times) anchors.add(t);
|
||||
} else if (HHMM_RE.test(s.window_start)) {
|
||||
// Legacy row without times_of_day — its anchor is window_start.
|
||||
anchors.add(s.window_start);
|
||||
}
|
||||
}
|
||||
anchorCache.set(medicationId, anchors);
|
||||
return anchors;
|
||||
}
|
||||
|
||||
const staleRows: typeof pendingRes.rows = [];
|
||||
for (const row of pendingRes.rows) {
|
||||
const anchors = await medicationAnchors(row.medication_id);
|
||||
// No derivable anchors (PRN-only / unscheduled) — leave the row.
|
||||
if (anchors.size === 0) continue;
|
||||
const tz = await userTimezone(row.user_id);
|
||||
if (!anchors.has(hhmmInTz(row.scheduled_for, tz))) {
|
||||
staleRows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
summary.staleAnchorRows = staleRows.length;
|
||||
console.log(
|
||||
`\n${TAG} 4) live pending rows on stale schedule anchors: ${staleRows.length}`,
|
||||
);
|
||||
if (staleRows.length > 0) {
|
||||
console.table(
|
||||
staleRows.map((r) => ({
|
||||
id: r.id,
|
||||
medication: r.medication_name,
|
||||
scheduledFor: r.scheduled_for.toISOString(),
|
||||
source: r.source,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.fix && staleRows.length > 0) {
|
||||
const res = await client.query(
|
||||
`
|
||||
UPDATE "medication_intake_events"
|
||||
SET "deleted_at" = NOW(),
|
||||
"sync_version" = "sync_version" + 1,
|
||||
"updated_at" = NOW()
|
||||
WHERE "id" = ANY($1) AND "deleted_at" IS NULL
|
||||
`,
|
||||
[staleRows.map((r) => r.id)],
|
||||
);
|
||||
summary.staleAnchorTombstoned = res.rowCount ?? 0;
|
||||
|
||||
for (const row of staleRows) {
|
||||
const tz = await userTimezone(row.user_id);
|
||||
const dayKey = dayKeyForScheduledFor(row.scheduled_for, tz);
|
||||
daysToRecompute.add(`${row.user_id}|${row.medication_id}|${dayKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// 5. Recompute the affected compliance rollups so scheduled/taken
|
||||
// counts self-correct. Same DISTINCT-slot SQL the shared helper
|
||||
// runs (see `recomputeComplianceDay` above).
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
if (opts.fix && daysToRecompute.size > 0) {
|
||||
console.log(
|
||||
`\n${TAG} 3) recomputing ${daysToRecompute.size} compliance rollup day(s)`,
|
||||
`\n${TAG} 5) recomputing ${daysToRecompute.size} compliance rollup day(s)`,
|
||||
);
|
||||
for (const key of daysToRecompute) {
|
||||
const [userId, medicationId, dayKey] = key.split("|");
|
||||
@@ -630,6 +846,10 @@ async function main(): Promise<void> {
|
||||
console.log(` duplicate rows tombstoned: ${summary.rowsTombstoned}`);
|
||||
console.log(` implausible taken_at rows: ${summary.implausibleRows}`);
|
||||
console.log(` implausible rows tombstoned: ${summary.implausibleTombstoned}`);
|
||||
console.log(` window/times drift schedules: ${summary.windowDriftSchedules}`);
|
||||
console.log(` windows reconciled: ${summary.windowsReconciled}`);
|
||||
console.log(` stale-anchor pending rows: ${summary.staleAnchorRows}`);
|
||||
console.log(` stale-anchor rows tombstoned: ${summary.staleAnchorTombstoned}`);
|
||||
console.log(` rollup days recomputed: ${summary.rollupsRecomputed}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ vi.mock("@/lib/db", () => ({
|
||||
update: vi.fn(),
|
||||
},
|
||||
medicationIntakeEvent: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
apiToken: {
|
||||
@@ -32,6 +33,13 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMedications: vi.fn(),
|
||||
}));
|
||||
// v1.16.1 — the schedule-replace branch recomputes the compliance
|
||||
// rollups of the tombstoned slots; stub the rollup module so the route
|
||||
// test never reaches `$executeRaw`.
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
dayKeyForScheduledFor: vi.fn(() => "2026-06-10"),
|
||||
recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/medication-category", () => ({
|
||||
deleteMedicationCategory: vi.fn().mockResolvedValue(undefined),
|
||||
getMedicationCategories: vi.fn().mockResolvedValue({}),
|
||||
@@ -55,6 +63,10 @@ import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { getMedicationCategories } from "@/lib/medication-category";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
dayKeyForScheduledFor,
|
||||
recomputeMedicationComplianceForDay,
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -78,6 +90,17 @@ beforeEach(() => {
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
} as never);
|
||||
// `resetAllMocks` clears the factory-level resolved values, so the
|
||||
// schedule-replace reads need fresh defaults each test.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationSchedule.deleteMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/[id] — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
@@ -230,6 +253,40 @@ describe("PUT /api/medications/[id] — schedule replace migrates open slots", (
|
||||
expect(arg.data.syncVersion).toEqual({ increment: 1 });
|
||||
});
|
||||
|
||||
it("recomputes the compliance rollups of the tombstoned days (v1.16.1)", async () => {
|
||||
// Two pendings on the same local day + one on another day → exactly
|
||||
// two distinct day-key recomputes, not three.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{ scheduledFor: new Date("2026-06-10T06:00:00Z") },
|
||||
{ scheduledFor: new Date("2026-06-10T18:00:00Z") },
|
||||
{ scheduledFor: new Date("2026-06-11T06:00:00Z") },
|
||||
] as never);
|
||||
vi.mocked(dayKeyForScheduledFor).mockImplementation(
|
||||
(scheduledFor: Date) => scheduledFor.toISOString().slice(0, 10),
|
||||
);
|
||||
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
schedules: [{ windowStart: "20:00", windowEnd: "20:30" }],
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(recomputeMedicationComplianceForDay).toHaveBeenCalledTimes(2);
|
||||
expect(recomputeMedicationComplianceForDay).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"m1",
|
||||
"2026-06-10",
|
||||
"Europe/Berlin",
|
||||
);
|
||||
expect(recomputeMedicationComplianceForDay).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"m1",
|
||||
"2026-06-11",
|
||||
"Europe/Berlin",
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT touch intake rows when the body carries no schedules array", async () => {
|
||||
const res = await PUT(putReq({ name: "Renamed" }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
@@ -439,7 +439,9 @@ describe("GET /api/medications/[id]/compliance — caching and limits", () => {
|
||||
expect(vi.mocked(cached)).toHaveBeenCalledTimes(1);
|
||||
const [, key] = vi.mocked(cached).mock.calls[0];
|
||||
// `${userId}|...` prefix is what `invalidateUserMedications` sweeps.
|
||||
expect(key).toBe("user-1|med-1|compliance");
|
||||
// The trailing tz segment (v1.16.1) makes a timezone change miss the
|
||||
// old entry instead of serving day buckets computed for the prior zone.
|
||||
expect(key).toBe("user-1|med-1|compliance|UTC");
|
||||
});
|
||||
|
||||
it("bounds the intake-event read to a trailing window and the consumed columns", async () => {
|
||||
|
||||
@@ -258,10 +258,14 @@ export const GET = apiHandler(
|
||||
// Read-through the per-user compliance cache (15 min TTL). Every
|
||||
// intake / medication write flushes the `${userId}|` prefix via
|
||||
// `invalidateUserMedications`, so a warm entry can only be stale
|
||||
// relative to wall-clock drift, never to a user action.
|
||||
// relative to wall-clock drift, never to a user action. The key
|
||||
// carries the timezone the payload was bucketed in: day keys, the
|
||||
// heatmap, and the overdue cut all derive from `userTz`, so a
|
||||
// timezone change must miss the old entry instead of serving day
|
||||
// buckets computed for the previous zone until the TTL lapses.
|
||||
const payload = await cached(
|
||||
caches.medicationCompliance as ServerCache<CompliancePayload>,
|
||||
`${user.id}|${id}|compliance`,
|
||||
`${user.id}|${id}|compliance|${userTz}`,
|
||||
() => buildCompliancePayload(medication, user.id, userTz),
|
||||
annotate,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on PATCH
|
||||
* /api/medications/[id]/inventory/[itemId].
|
||||
*
|
||||
* v1.16.1 — stock-correction contract: `dosesRemaining` sets the count
|
||||
* absolutely, clamps to `dosesTotal`, and the canonical state machine
|
||||
* derives the next state (0 ⇒ USED_UP).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -124,3 +128,55 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — 422 multi-issue (v1
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stock correction (v1.16.1)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
|
||||
id: "i1",
|
||||
medicationId: "m1",
|
||||
userId: "user-1",
|
||||
firstUseAt: null,
|
||||
state: "IN_USE",
|
||||
dosesTotal: 4,
|
||||
dosesRemaining: 3,
|
||||
printedExpiry: null,
|
||||
notes: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationInventoryItem.update).mockImplementation(
|
||||
(async (args: { data: Record<string, unknown> }) => ({
|
||||
id: "i1",
|
||||
...args.data,
|
||||
})) as never,
|
||||
);
|
||||
});
|
||||
|
||||
it("sets the remaining count absolutely", async () => {
|
||||
const res = await PATCH(patchReq({ dosesRemaining: 1 }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
const update = vi.mocked(prisma.medicationInventoryItem.update).mock
|
||||
.calls[0][0] as { data: { dosesRemaining: number } };
|
||||
expect(update.data.dosesRemaining).toBe(1);
|
||||
});
|
||||
|
||||
it("derives USED_UP when corrected to zero", async () => {
|
||||
const res = await PATCH(patchReq({ dosesRemaining: 0 }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
const update = vi.mocked(prisma.medicationInventoryItem.update).mock
|
||||
.calls[0][0] as { data: { dosesRemaining: number; state: string } };
|
||||
expect(update.data.dosesRemaining).toBe(0);
|
||||
expect(update.data.state).toBe("USED_UP");
|
||||
});
|
||||
|
||||
it("clamps a raise above the item's capacity to dosesTotal", async () => {
|
||||
const res = await PATCH(patchReq({ dosesRemaining: 99 }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
const update = vi.mocked(prisma.medicationInventoryItem.update).mock
|
||||
.calls[0][0] as { data: { dosesRemaining: number } };
|
||||
expect(update.data.dosesRemaining).toBe(4);
|
||||
});
|
||||
|
||||
it("rejects a negative correction with 422", async () => {
|
||||
const res = await PATCH(patchReq({ dosesRemaining: -1 }), ROUTE_CTX);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,9 @@
|
||||
* - mark-as-used-up (terminal — operator override when a pen is
|
||||
* physically discarded but the dose ledger hasn't reached zero)
|
||||
* - update printed expiry (e.g. carton label correction)
|
||||
* - stock correction via `dosesRemaining` (v1.16.1 — the Bestand
|
||||
* tab's adjust / withdraw flow; clamped to `dosesTotal`, state
|
||||
* re-derived by the canonical state machine)
|
||||
* - update notes
|
||||
*
|
||||
* DELETE /api/medications/[id]/inventory/[itemId]
|
||||
@@ -67,8 +70,13 @@ export const PATCH = apiHandler(
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { markAsFirstUseAt, markAsUsedUp, printedExpiry, notes } =
|
||||
parsed.data;
|
||||
const {
|
||||
markAsFirstUseAt,
|
||||
markAsUsedUp,
|
||||
printedExpiry,
|
||||
dosesRemaining,
|
||||
notes,
|
||||
} = parsed.data;
|
||||
|
||||
// Compose the next-row shape. Each mutation field is optional and
|
||||
// commutative — applying them in any order produces the same row.
|
||||
@@ -88,6 +96,14 @@ export const PATCH = apiHandler(
|
||||
nextPrintedExpiry = printedExpiry;
|
||||
}
|
||||
|
||||
if (dosesRemaining !== undefined) {
|
||||
// v1.16.1 — stock correction (Bestand tab adjust / withdraw).
|
||||
// Clamp to the item's capacity; the state-machine re-run below
|
||||
// owns every state consequence (0 ⇒ USED_UP, a raise out of 0
|
||||
// re-evaluates against the expiry clocks).
|
||||
nextDosesRemaining = Math.min(dosesRemaining, existing.dosesTotal);
|
||||
}
|
||||
|
||||
if (markAsUsedUp === true) {
|
||||
nextDosesRemaining = 0;
|
||||
nextState = "USED_UP";
|
||||
|
||||
@@ -18,6 +18,10 @@ import {
|
||||
} from "@/lib/medication-category";
|
||||
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { computeNextDueAt } from "@/lib/medications/scheduling/next-due";
|
||||
import {
|
||||
dayKeyForScheduledFor,
|
||||
recomputeMedicationComplianceForDay,
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { getUserTodayBounds } from "@/lib/timezone";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -287,22 +291,50 @@ export const PUT = apiHandler(
|
||||
// `syncVersion` so delta-sync clients drop them. The projector and
|
||||
// the reminder worker re-mint the anchors for the new times on their
|
||||
// next pass — the route does not pre-create them.
|
||||
const { start: todayStart } = getUserTodayBounds(
|
||||
new Date(),
|
||||
user.timezone || "Europe/Berlin",
|
||||
);
|
||||
const userTz = user.timezone || "Europe/Berlin";
|
||||
const { start: todayStart } = getUserTodayBounds(new Date(), userTz);
|
||||
const tombstoneWhere = {
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
deletedAt: null,
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
scheduledFor: { gte: todayStart },
|
||||
} as const;
|
||||
// Capture the affected slots BEFORE the tombstone so the compliance
|
||||
// rollups for those days can be recomputed immediately below —
|
||||
// `updateMany` does not return the touched rows. Without the
|
||||
// recompute a tombstoned pending kept counting as `scheduled` in
|
||||
// the rollup row until the next event-driven write for that day,
|
||||
// depressing the rate the cards read in the meantime.
|
||||
const tombstoned = await prisma.medicationIntakeEvent.findMany({
|
||||
where: tombstoneWhere,
|
||||
select: { scheduledFor: true },
|
||||
});
|
||||
await prisma.medicationIntakeEvent.updateMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
deletedAt: null,
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
scheduledFor: { gte: todayStart },
|
||||
},
|
||||
where: tombstoneWhere,
|
||||
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
|
||||
});
|
||||
// Recompute each affected rollup day (deduped via the user-tz day
|
||||
// key). Best-effort like every other rollup write-hook: a failure
|
||||
// is annotated, never blocks the schedule replace.
|
||||
const staleDayKeys = new Set(
|
||||
tombstoned.map((row) => dayKeyForScheduledFor(row.scheduledFor, userTz)),
|
||||
);
|
||||
for (const dayKey of staleDayKeys) {
|
||||
try {
|
||||
await recomputeMedicationComplianceForDay(user.id, id, dayKey, userTz);
|
||||
} catch (err) {
|
||||
annotate({
|
||||
meta: {
|
||||
medication_compliance_rollup_failed: true,
|
||||
medication_compliance_rollup_error:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
await prisma.medicationSchedule.deleteMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
|
||||
@@ -171,15 +171,14 @@ export default function MedicationsPage() {
|
||||
queryClient.setQueryData(queryKeys.medicationDetail(med.id), med);
|
||||
}
|
||||
|
||||
// v1.15.20 — the kebab's Edit opens the medication wizard directly:
|
||||
// it navigates to the detail page with `?edit=1`, which the tabbed
|
||||
// shell reads synchronously to mount the wizard in edit mode (the same
|
||||
// `editOpen` path the hero's "Vollständig bearbeiten" uses). History
|
||||
// still lands on the Verlauf tab; the former Advanced item is retired —
|
||||
// the Erweitert tab stays reachable through the card body.
|
||||
// v1.16.1 — the kebab's Edit lands on the detail page's Übersicht tab,
|
||||
// exactly like tapping the medication name. Every everyday edit lives
|
||||
// inline in the tabs there; the structural wizard is reachable via
|
||||
// Erweitert → Lebenszyklus (and still answers `?edit=1` deep-links).
|
||||
// History still lands on the Verlauf tab.
|
||||
function openEdit(med: Medication) {
|
||||
seedDetail(med);
|
||||
router.push(`/medications/${med.id}?edit=1`);
|
||||
router.push(`/medications/${med.id}`);
|
||||
}
|
||||
|
||||
function openHistory(med: Medication) {
|
||||
|
||||
@@ -61,6 +61,26 @@ describe("resolveDisplayedSlotInstant", () => {
|
||||
expect(slot!.toISOString()).toBe("2026-06-05T17:00:00.000Z");
|
||||
});
|
||||
|
||||
it("anchors on the matched band's timeOfDay over the stale legacy windowStart (v1.16.1)", () => {
|
||||
// The production regression fixture: degenerate 07:00 / 07:00 window
|
||||
// left behind while the dose times moved to 09:00 / 21:00. The matched
|
||||
// band carries timeOfDay 09:00 — the recorded slot must be 09:00, not
|
||||
// the stale window's 07:00.
|
||||
const inBandNow = new Date("2026-06-05T07:30:00.000Z"); // 09:30 Berlin
|
||||
const slot = resolveDisplayedSlotInstant({
|
||||
currentWindowStatus: {
|
||||
status: "in_window",
|
||||
schedule: { windowStart: "07:00", windowEnd: "07:00" },
|
||||
window: { timeOfDay: "09:00" },
|
||||
},
|
||||
nextDueAt: null,
|
||||
now: inBandNow,
|
||||
timeZone: "Europe/Berlin",
|
||||
});
|
||||
// 09:00 Berlin on 2026-06-05 is 07:00 UTC (CEST = +2).
|
||||
expect(slot!.toISOString()).toBe("2026-06-05T07:00:00.000Z");
|
||||
});
|
||||
|
||||
it("falls back to the server's next-due instant when no window is currently actionable", () => {
|
||||
const nextDueAt = "2026-06-06T05:00:00.000Z";
|
||||
const slot = resolveDisplayedSlotInstant({
|
||||
|
||||
@@ -33,10 +33,17 @@ export interface ResolveDisplayedSlotInstantInput {
|
||||
* The card's current-window status. When its `status` is non-null the
|
||||
* card is surfacing that schedule's window today (in_window / late /
|
||||
* very_late), so the recorded dose is that window's slot today.
|
||||
*
|
||||
* v1.16.1 — `window` carries the matched dose band's anchor
|
||||
* (`timeOfDay`, a `timesOfDay` entry). It is the canonical slot time;
|
||||
* the schedule's `windowStart` stays only as the legacy fallback for
|
||||
* rows without `timesOfDay`, so a stale / degenerate window can no
|
||||
* longer mis-anchor the recorded dose.
|
||||
*/
|
||||
currentWindowStatus: {
|
||||
status: "in_window" | "late" | "very_late" | null;
|
||||
schedule: DisplayedSlotSchedule | null;
|
||||
window?: { timeOfDay: string } | null;
|
||||
};
|
||||
/**
|
||||
* The server-computed next-due instant (`computeNextDueAt`, the canonical
|
||||
@@ -130,10 +137,15 @@ export function resolveDisplayedSlotInstant(
|
||||
const { currentWindowStatus, nextDueAt, now, timeZone = "Europe/Berlin" } =
|
||||
input;
|
||||
|
||||
// The card surfaces the current/overdue window — record that window's
|
||||
// slot on today's calendar day.
|
||||
// The card surfaces the current/overdue dose band — record that band's
|
||||
// slot on today's calendar day. The matched band's `timeOfDay` is the
|
||||
// canonical dose anchor; `windowStart` is only the legacy fallback for
|
||||
// schedules without `timesOfDay`.
|
||||
if (currentWindowStatus.status && currentWindowStatus.schedule) {
|
||||
const hm = parseHm(currentWindowStatus.schedule.windowStart);
|
||||
const anchor =
|
||||
currentWindowStatus.window?.timeOfDay ??
|
||||
currentWindowStatus.schedule.windowStart;
|
||||
const hm = parseHm(anchor);
|
||||
if (hm) return localHmTodayAsUtc(now, timeZone, hm.hour, hm.minute);
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ const TAB_SLUGS = [
|
||||
"bestand",
|
||||
"verlauf",
|
||||
"injektion",
|
||||
"api",
|
||||
"erweitert",
|
||||
] as const;
|
||||
type TabSlug = (typeof TAB_SLUGS)[number];
|
||||
@@ -337,31 +338,19 @@ export function MedicationDetailTabs({
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{/* HERO — always-visible read-only summary + the structural-edit
|
||||
jump into the wizard. Everyday levers live in the tabs below. */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<MedicationDetailSummary
|
||||
name={medication.name}
|
||||
dose={medication.dose}
|
||||
active={medication.active}
|
||||
endsOn={medication.endsOn}
|
||||
payload={payload}
|
||||
oneShot={oneShot}
|
||||
startsOn={medication.startsOn}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditOpen(true)}
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
data-slot="medication-detail-full-edit"
|
||||
>
|
||||
<Pencil aria-hidden="true" className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">
|
||||
{t("medications.detail.shell.fullEdit")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/* HERO — always-visible read-only summary. The structural edit
|
||||
(wizard) moved out of the header: it lives as a row under
|
||||
Erweitert → Lebenszyklus (the header button read as "edit this
|
||||
page" and misled). `?edit=1` still opens the wizard directly. */}
|
||||
<MedicationDetailSummary
|
||||
name={medication.name}
|
||||
dose={medication.dose}
|
||||
active={medication.active}
|
||||
endsOn={medication.endsOn}
|
||||
payload={payload}
|
||||
oneShot={oneShot}
|
||||
startsOn={medication.startsOn}
|
||||
/>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
@@ -528,13 +517,28 @@ export function MedicationDetailTabs({
|
||||
doseWindows: s.doseWindows,
|
||||
}))}
|
||||
/>
|
||||
{/* Quiet cross-link: WHICH channels deliver the reminder
|
||||
(push / Telegram / ntfy, quiet hours) is configured
|
||||
globally, not per medication. */}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Link
|
||||
href="/settings/notifications"
|
||||
className="focus-visible:ring-ring rounded-sm underline underline-offset-2 focus-visible:ring-2 focus-visible:outline-none"
|
||||
data-slot="zeitplan-manage-channels-link"
|
||||
>
|
||||
{t("medications.detail.zeitplan.manageChannels")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</MedicationDetailSection>
|
||||
</TabsContent>
|
||||
|
||||
{/* BESTAND — inventory readout for all meds. */}
|
||||
{/* BESTAND — inventory readout + register / correct flows. */}
|
||||
<TabsContent value="bestand" className="space-y-4 pt-2">
|
||||
<InventorySection medicationId={id} />
|
||||
<InventorySection
|
||||
medicationId={id}
|
||||
dosesPerUnit={medication.dosesPerUnit}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* VERLAUF — the dose-history ledger: every expected slot with its
|
||||
@@ -586,36 +590,14 @@ export function MedicationDetailTabs({
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* ERWEITERT — the dissolved advanced-settings sheet, now grouped
|
||||
un-stacked: Lifecycle → Externe API → Daten → Gefahrenzone. */}
|
||||
<TabsContent value="erweitert" className="space-y-4 pt-2">
|
||||
<SettingsGroup
|
||||
label={t("medications.detail.erweitert.group.lifecycle")}
|
||||
dataSlot="erweitert-group-lifecycle"
|
||||
>
|
||||
<div className="py-3">
|
||||
<LifecycleManageBody
|
||||
medicationId={id}
|
||||
medicationName={medication.name}
|
||||
active={medication.active}
|
||||
/>
|
||||
</div>
|
||||
{isGlp1 && (
|
||||
<div className="py-3">
|
||||
<PhasesRow
|
||||
medicationId={id}
|
||||
treatmentClass={medication.treatmentClass}
|
||||
startsOn={medication.startsOn}
|
||||
endsOn={medication.endsOn}
|
||||
onRequestPhaseSheet={() => setPhaseSheetOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
|
||||
{/* API — the external-ingest surface in its own tab: per-medication
|
||||
tokens + the drug-coding identifiers external systems key on.
|
||||
Pulled out of Erweitert so the destructive zone and the token
|
||||
management stop sharing one surface. */}
|
||||
<TabsContent value="api" className="space-y-4 pt-2">
|
||||
<SettingsGroup
|
||||
label={t("medications.detail.erweitert.group.externalApi")}
|
||||
dataSlot="erweitert-group-external-api"
|
||||
dataSlot="api-group-external-api"
|
||||
>
|
||||
<div className="py-3">
|
||||
<ApiTokensRow
|
||||
@@ -631,6 +613,61 @@ export function MedicationDetailTabs({
|
||||
/>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</TabsContent>
|
||||
|
||||
{/* ERWEITERT — Lifecycle → Daten → Gefahrenzone. The external-API
|
||||
group moved to its own API tab. */}
|
||||
<TabsContent value="erweitert" className="space-y-4 pt-2">
|
||||
<SettingsGroup
|
||||
label={t("medications.detail.erweitert.group.lifecycle")}
|
||||
dataSlot="erweitert-group-lifecycle"
|
||||
>
|
||||
<div className="py-3">
|
||||
<LifecycleManageBody
|
||||
medicationId={id}
|
||||
medicationName={medication.name}
|
||||
active={medication.active}
|
||||
/>
|
||||
</div>
|
||||
{/* The structural editor (the create/edit wizard) — name,
|
||||
class, cadence kind, schedules. Replaces the former hero
|
||||
"Vollständig bearbeiten" button, which read as "edit this
|
||||
page". */}
|
||||
<div
|
||||
className="flex items-center justify-between gap-3 py-3"
|
||||
data-slot="erweitert-editor-row"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("medications.detail.erweitert.editor.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.detail.erweitert.editor.helper")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditOpen(true)}
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
data-slot="medication-detail-full-edit"
|
||||
>
|
||||
<Pencil aria-hidden="true" className="h-4 w-4" />
|
||||
{t("medications.detail.erweitert.editor.button")}
|
||||
</Button>
|
||||
</div>
|
||||
{isGlp1 && (
|
||||
<div className="py-3">
|
||||
<PhasesRow
|
||||
medicationId={id}
|
||||
treatmentClass={medication.treatmentClass}
|
||||
startsOn={medication.startsOn}
|
||||
endsOn={medication.endsOn}
|
||||
onRequestPhaseSheet={() => setPhaseSheetOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup
|
||||
label={t("medications.detail.erweitert.group.data")}
|
||||
@@ -763,18 +800,18 @@ function ComplianceGatedDangerZone({
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
// v1.16.1 — same neutral card chrome as every other group; only the
|
||||
// action buttons stay destructive. A red-washed card read as a
|
||||
// constant alarm on a page the user visits routinely.
|
||||
<SettingsGroup
|
||||
label={t("medications.detail.erweitert.group.danger")}
|
||||
dataSlot="erweitert-group-danger"
|
||||
className="border-destructive/40 bg-destructive/5"
|
||||
>
|
||||
<div className="py-3">
|
||||
<DangerZoneBody
|
||||
medicationId={medicationId}
|
||||
medicationName={medicationName}
|
||||
intakeCount={intakeCount ?? 0}
|
||||
/>
|
||||
</div>
|
||||
<DangerZoneBody
|
||||
medicationId={medicationId}
|
||||
medicationName={medicationName}
|
||||
intakeCount={intakeCount ?? 0}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,13 @@ interface ScheduleLite {
|
||||
label: string | null;
|
||||
dose: string | null;
|
||||
daysOfWeek: string | null;
|
||||
/**
|
||||
* v1.16.1 — first-class dose times + explicit per-dose bands; the
|
||||
* window-status helper derives its bands from these (canonical) and
|
||||
* only falls back to the legacy window when they are absent.
|
||||
*/
|
||||
timesOfDay?: string[];
|
||||
doseWindows?: { timeOfDay: string; start: string; end: string }[] | null;
|
||||
}
|
||||
|
||||
interface DoseChangeLite {
|
||||
@@ -402,8 +409,10 @@ export function Glp1MedicationCard({
|
||||
currentWindowStatus.status
|
||||
? {
|
||||
status: currentWindowStatus.status,
|
||||
windowStart: currentWindowStatus.schedule!.windowStart,
|
||||
windowEnd: currentWindowStatus.schedule!.windowEnd,
|
||||
// v1.16.1 — the pill shows the MATCHED dose band, not the
|
||||
// legacy schedule window (which may be stale / degenerate).
|
||||
windowStart: currentWindowStatus.window!.start,
|
||||
windowEnd: currentWindowStatus.window!.end,
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
* affordance instead of a cluster. The card body itself navigates to the
|
||||
* detail page (the former chevron target); this menu owns the edit and
|
||||
* history actions. v1.15.20 retires the "Advanced settings" item — the
|
||||
* detail page's Erweitert tab is reachable through the card body, and
|
||||
* Edit now opens the medication wizard directly instead of landing on
|
||||
* the Zeitplan tab.
|
||||
* detail page's Erweitert tab is reachable through the card body.
|
||||
* v1.16.1 — Edit navigates to the detail page's Übersicht tab (the same
|
||||
* target as the name tap); the structural wizard lives under the detail
|
||||
* page's Erweitert → Lebenszyklus row.
|
||||
*
|
||||
* The optional `onLogSideEffect` slot exists for the GLP-1 card to fold a
|
||||
* "Log side effect" item into the SAME menu, but it is NOT wired on the
|
||||
|
||||
@@ -34,6 +34,14 @@ interface Schedule {
|
||||
label: string | null;
|
||||
dose: string | null;
|
||||
daysOfWeek: string | null;
|
||||
/**
|
||||
* v1.16.1 — first-class dose times + explicit per-dose bands. The
|
||||
* window-status helper derives its bands from these (canonical) and
|
||||
* only falls back to `windowStart` / `windowEnd` when they are absent,
|
||||
* so a stale window can no longer drive the pill or the recorded slot.
|
||||
*/
|
||||
timesOfDay?: string[];
|
||||
doseWindows?: { timeOfDay: string; start: string; end: string }[] | null;
|
||||
}
|
||||
|
||||
interface Medication {
|
||||
@@ -318,10 +326,18 @@ export function MedicationCard({
|
||||
}
|
||||
}
|
||||
|
||||
// v1.16.1 — the TIME is the canonical next-due instant (the
|
||||
// recurrence engine's timesOfDay-anchored slot), not the legacy
|
||||
// schedule window. A stale `windowStart` / `windowEnd` pair
|
||||
// (e.g. 07:00 / 07:00 while the times moved to 09:00 / 21:00)
|
||||
// used to print "today, 07:00" here. The window range stays as
|
||||
// the fallback for rows the server has no next-due for.
|
||||
return (
|
||||
<>
|
||||
{dayLabel && `${dayLabel}, `}
|
||||
{formatTimeWindowRange(s.windowStart, s.windowEnd, locale)}
|
||||
{nextAt
|
||||
? formatTime(new Date(nextAt).toISOString())
|
||||
: formatTimeWindowRange(s.windowStart, s.windowEnd, locale)}
|
||||
{s.label && (
|
||||
<span className="hidden sm:inline"> ({s.label})</span>
|
||||
)}
|
||||
@@ -350,8 +366,10 @@ export function MedicationCard({
|
||||
currentWindowStatus.status
|
||||
? {
|
||||
status: currentWindowStatus.status,
|
||||
windowStart: currentWindowStatus.schedule!.windowStart,
|
||||
windowEnd: currentWindowStatus.schedule!.windowEnd,
|
||||
// v1.16.1 — the pill shows the MATCHED dose band, not the
|
||||
// legacy schedule window (which may be stale / degenerate).
|
||||
windowStart: currentWindowStatus.window!.start,
|
||||
windowEnd: currentWindowStatus.window!.end,
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -233,8 +232,10 @@ export function LifecycleManageBody({
|
||||
|
||||
/**
|
||||
* v1.7.0 — irreversible danger zone: purge intake history + delete the
|
||||
* medication. Wrapped in a `border-destructive/40` card to keep the
|
||||
* visual warning convention.
|
||||
* medication. v1.16.1 — the body is bare rows (the caller's
|
||||
* `SettingsGroup` supplies the neutral card chrome and dividers); the
|
||||
* destructive signal lives ONLY in the red action buttons + the confirm
|
||||
* dialogs, not in a tinted surface.
|
||||
*/
|
||||
export function DangerZoneBody({
|
||||
medicationId,
|
||||
@@ -317,8 +318,8 @@ export function DangerZoneBody({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="border-destructive/40 bg-destructive/5 divide-destructive/20 gap-0 divide-y px-4 py-1"
|
||||
<div
|
||||
className="divide-border/60 divide-y"
|
||||
data-slot="destructive-zone-card-b"
|
||||
>
|
||||
{/* Tier 3a — Verlauf löschen. Matched pair with the delete row
|
||||
@@ -440,7 +441,7 @@ export function DangerZoneBody({
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,23 +3,42 @@
|
||||
/**
|
||||
* v1.15.18 — Bestand (supply) tab body.
|
||||
*
|
||||
* Read-only inventory readout for the medication detail page's Supply
|
||||
* tab. Reads `GET /api/medications/[id]/inventory` (the same per-item
|
||||
* list the pen/vial CRUD route serves) and renders a calm summary — the
|
||||
* doses remaining across every non-terminal item plus a per-item list
|
||||
* with its state badge. Shown for ALL medications (pill packs count too);
|
||||
* the richer pen math is implicit in the per-item doses figures.
|
||||
* v1.16.1 — the tab grows its write affordances:
|
||||
*
|
||||
* No write affordance here yet — registering a pen still rides the
|
||||
* dedicated inventory route; the tab is the read surface the spec asks
|
||||
* for. An empty inventory shows a quiet empty state, not a spinner.
|
||||
* - REGISTER: a "+" flow (header button + the empty-state CTA) that
|
||||
* records a new pen / pack via `POST /api/medications/[id]/inventory`
|
||||
* — quantity in doses/units, optional printed expiry. The quantity
|
||||
* prefills from the medication's `dosesPerUnit` when configured.
|
||||
* - CORRECT: a per-item adjust flow (`PATCH …/inventory/[itemId]` with
|
||||
* `dosesRemaining`) for stock corrections and manual withdrawals —
|
||||
* the count is set absolutely, clamped server-side to the item's
|
||||
* capacity, and the canonical state machine derives the next state
|
||||
* (0 ⇒ used up).
|
||||
*
|
||||
* Reads stay on `GET /api/medications/[id]/inventory` (the same
|
||||
* per-item list the pen/vial CRUD route serves): a calm summary — the
|
||||
* doses remaining across every non-terminal item — plus a per-item list
|
||||
* with its state badge. Shown for ALL medications (pill packs count
|
||||
* too); the richer pen math is implicit in the per-item doses figures.
|
||||
*/
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2, PackageOpen } from "lucide-react";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, PackageOpen, Plus } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SettingsGroup } from "@/components/medications/settings-group";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -45,8 +64,17 @@ const STATE_BADGE: Record<InventoryState, "secondary" | "outline" | "destructive
|
||||
USED_UP: "outline",
|
||||
};
|
||||
|
||||
export function InventorySection({ medicationId }: { medicationId: string }) {
|
||||
export function InventorySection({
|
||||
medicationId,
|
||||
dosesPerUnit,
|
||||
}: {
|
||||
medicationId: string;
|
||||
/** Prefills the register flow's quantity when the medication tracks it. */
|
||||
dosesPerUnit?: number | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [adjustItem, setAdjustItem] = useState<InventoryItem | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery<InventoryResponse>({
|
||||
queryKey: queryKeys.medicationInventory(medicationId),
|
||||
@@ -80,13 +108,42 @@ export function InventorySection({ medicationId }: { medicationId: string }) {
|
||||
const remaining = live.reduce((sum, i) => sum + i.dosesRemaining, 0);
|
||||
const total = live.reduce((sum, i) => sum + i.dosesTotal, 0);
|
||||
|
||||
const dialogs = (
|
||||
<>
|
||||
{addOpen && (
|
||||
<AddInventoryDialog
|
||||
medicationId={medicationId}
|
||||
defaultDosesTotal={dosesPerUnit ?? null}
|
||||
onClose={() => setAddOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{adjustItem && (
|
||||
<AdjustInventoryDialog
|
||||
medicationId={medicationId}
|
||||
item={adjustItem}
|
||||
onClose={() => setAdjustItem(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<PackageOpen className="size-6" />}
|
||||
title={t("medications.detail.bestand.empty")}
|
||||
description={t("medications.detail.bestand.emptyHelper")}
|
||||
/>
|
||||
<>
|
||||
<EmptyState
|
||||
icon={<PackageOpen className="size-6" />}
|
||||
title={t("medications.detail.bestand.empty")}
|
||||
description={t("medications.detail.bestand.emptyHelper")}
|
||||
ctaSize="lg"
|
||||
action={
|
||||
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||
<Plus aria-hidden="true" className="h-4 w-4" />
|
||||
{t("medications.detail.bestand.emptyCta")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{dialogs}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,10 +153,20 @@ export function InventorySection({ medicationId }: { medicationId: string }) {
|
||||
label={t("medications.detail.bestand.title")}
|
||||
dataSlot="inventory-summary-group"
|
||||
>
|
||||
<div className="py-3">
|
||||
<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 })}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAddOpen(true)}
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
data-slot="inventory-add-button"
|
||||
>
|
||||
<Plus aria-hidden="true" className="h-4 w-4" />
|
||||
{t("medications.detail.bestand.addButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
@@ -119,12 +186,278 @@ export function InventorySection({ medicationId }: { medicationId: string }) {
|
||||
total: item.dosesTotal,
|
||||
})}
|
||||
</span>
|
||||
<Badge variant={STATE_BADGE[item.state]} className="text-xs">
|
||||
{t(`medications.detail.bestand.state.${item.state}`)}
|
||||
</Badge>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Badge variant={STATE_BADGE[item.state]} className="text-xs">
|
||||
{t(`medications.detail.bestand.state.${item.state}`)}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setAdjustItem(item)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="inventory-adjust-button"
|
||||
>
|
||||
{t("medications.detail.bestand.adjustButton")}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
|
||||
{dialogs}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new pen / pack. Two fields (quantity + optional printed
|
||||
* expiry) → Dialog per ui-guidelines §2.3. The quantity prefills from
|
||||
* the medication's `dosesPerUnit` so the common case is one tap.
|
||||
*/
|
||||
function AddInventoryDialog({
|
||||
medicationId,
|
||||
defaultDosesTotal,
|
||||
onClose,
|
||||
}: {
|
||||
medicationId: string;
|
||||
defaultDosesTotal: number | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [dosesTotal, setDosesTotal] = useState(
|
||||
defaultDosesTotal && defaultDosesTotal >= 1 && defaultDosesTotal <= 100
|
||||
? String(defaultDosesTotal)
|
||||
: "",
|
||||
);
|
||||
const [expiry, setExpiry] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const parsed = Number(dosesTotal);
|
||||
const quantityValid =
|
||||
Number.isInteger(parsed) && parsed >= 1 && parsed <= 100;
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!quantityValid || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch(`/api/medications/${medicationId}/inventory`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
dosesTotal: parsed,
|
||||
printedExpiry: expiry
|
||||
? new Date(`${expiry}T00:00:00`).toISOString()
|
||||
: null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast.error(t("medications.detail.bestand.addFailed"));
|
||||
return;
|
||||
}
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.medicationInventory(medicationId),
|
||||
});
|
||||
toast.success(t("medications.detail.bestand.addSuccess"));
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error(t("medications.detail.bestand.addFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("medications.detail.bestand.addTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.detail.bestand.addDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-add-quantity"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.addQuantityLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="inventory-add-quantity"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
required
|
||||
autoComplete="off"
|
||||
value={dosesTotal}
|
||||
onChange={(e) => setDosesTotal(e.target.value)}
|
||||
aria-describedby="inventory-add-quantity-helper"
|
||||
/>
|
||||
<p
|
||||
id="inventory-add-quantity-helper"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("medications.detail.bestand.addQuantityHelper")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-add-expiry"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.addExpiryLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="inventory-add-expiry"
|
||||
type="date"
|
||||
autoComplete="off"
|
||||
value={expiry}
|
||||
onChange={(e) => setExpiry(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!quantityValid || busy}
|
||||
aria-busy={busy || undefined}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
)}
|
||||
{t("medications.detail.bestand.addSubmit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stock correction for one item: sets the remaining-dose count
|
||||
* absolutely (covers both "I miscounted" and a manual withdrawal). The
|
||||
* server clamps to the item's capacity and re-derives the state; 0
|
||||
* marks the item used up.
|
||||
*/
|
||||
function AdjustInventoryDialog({
|
||||
medicationId,
|
||||
item,
|
||||
onClose,
|
||||
}: {
|
||||
medicationId: string;
|
||||
item: InventoryItem;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [value, setValue] = useState(String(item.dosesRemaining));
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const parsed = Number(value);
|
||||
const valid =
|
||||
Number.isInteger(parsed) && parsed >= 0 && parsed <= item.dosesTotal;
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!valid || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/inventory/${item.id}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dosesRemaining: parsed }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
toast.error(t("medications.detail.bestand.adjustFailed"));
|
||||
return;
|
||||
}
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.medicationInventory(medicationId),
|
||||
});
|
||||
toast.success(t("medications.detail.bestand.adjustSuccess"));
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error(t("medications.detail.bestand.adjustFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("medications.detail.bestand.adjustTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.detail.bestand.adjustDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-adjust-remaining"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.adjustQuantityLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="inventory-adjust-remaining"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={item.dosesTotal}
|
||||
step={1}
|
||||
required
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
aria-describedby="inventory-adjust-helper"
|
||||
/>
|
||||
<p
|
||||
id="inventory-adjust-helper"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("medications.detail.bestand.adjustHelper", {
|
||||
total: item.dosesTotal,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!valid || busy}
|
||||
aria-busy={busy || undefined}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
)}
|
||||
{t("medications.detail.bestand.adjustSubmit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -129,7 +129,9 @@ describe("reduceCurrentWindowStatus — degenerate point window", () => {
|
||||
});
|
||||
|
||||
describe("reduceCurrentWindowStatus — timesOfDay-aware overdue coverage", () => {
|
||||
// One schedule row, two dose times, window long past at 22:00.
|
||||
// One schedule row, two dose times. v1.16.1: each dose carries its own
|
||||
// ±60 min band (07:00–09:00 and 19:00–21:00); the legacy row window is
|
||||
// ignored once timesOfDay exists.
|
||||
const twoDoseSchedule = [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
@@ -143,23 +145,40 @@ describe("reduceCurrentWindowStatus — timesOfDay-aware overdue coverage", () =
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: twoDoseSchedule,
|
||||
nowBerlin: localClock(21, 0),
|
||||
nowBerlin: localClock(21, 30),
|
||||
todayEventCount: 1,
|
||||
});
|
||||
// One event covers one dose; the second passed dose is uncovered →
|
||||
// the late pill must stay up. (Pre-v1.15.20 the row counted as ONE
|
||||
// expected dose and the pill vanished here.)
|
||||
// 21:30 — both bands have passed (ends 09:00 + 21:00); one event
|
||||
// covers one dose; the second passed dose is uncovered → the late
|
||||
// pill must stay up. (Pre-v1.15.20 the row counted as ONE expected
|
||||
// dose and the pill vanished here.)
|
||||
expect(res.status).toBe("late");
|
||||
expect(res.window).toEqual({
|
||||
timeOfDay: "20:00",
|
||||
start: "19:00",
|
||||
end: "21:00",
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses the pill once both doses are covered", () => {
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: twoDoseSchedule,
|
||||
nowBerlin: localClock(21, 0),
|
||||
nowBerlin: localClock(21, 30),
|
||||
todayEventCount: 2,
|
||||
});
|
||||
expect(res.status).toBeNull();
|
||||
expect(res.window).toBeNull();
|
||||
});
|
||||
|
||||
it("is in_window inside the second dose's own band", () => {
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: twoDoseSchedule,
|
||||
nowBerlin: localClock(20, 30),
|
||||
});
|
||||
expect(res.status).toBe("in_window");
|
||||
expect(res.window?.timeOfDay).toBe("20:00");
|
||||
});
|
||||
|
||||
it("a schedule without timesOfDay keeps counting as one dose", () => {
|
||||
@@ -175,3 +194,73 @@ describe("reduceCurrentWindowStatus — timesOfDay-aware overdue coverage", () =
|
||||
expect(res.status).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceCurrentWindowStatus — stale window never beats timesOfDay (v1.16.1)", () => {
|
||||
// The production regression: the schedule row still carries the historic
|
||||
// degenerate 07:00 / 07:00 window while the dose times moved to
|
||||
// 09:00 / 21:00. The 07:00 window must not paint any pill.
|
||||
const staleSchedule = [
|
||||
{
|
||||
windowStart: "07:00",
|
||||
windowEnd: "07:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["09:00", "21:00"],
|
||||
},
|
||||
];
|
||||
|
||||
it("shows NO pill at 07:00 (the stale window used to read in_window)", () => {
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: staleSchedule,
|
||||
nowBerlin: localClock(7, 0),
|
||||
});
|
||||
expect(res.status).toBeNull();
|
||||
});
|
||||
|
||||
it("is in_window inside the 09:00 dose band, anchored on 09:00", () => {
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: staleSchedule,
|
||||
nowBerlin: localClock(9, 15),
|
||||
});
|
||||
expect(res.status).toBe("in_window");
|
||||
expect(res.window).toEqual({
|
||||
timeOfDay: "09:00",
|
||||
start: "08:00",
|
||||
end: "10:00",
|
||||
});
|
||||
});
|
||||
|
||||
it("turns late after the 09:00 band end, still anchored on 09:00", () => {
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: staleSchedule,
|
||||
nowBerlin: localClock(11, 0),
|
||||
});
|
||||
expect(res.status).toBe("late");
|
||||
expect(res.window?.timeOfDay).toBe("09:00");
|
||||
});
|
||||
|
||||
it("honours an explicit doseWindows band over the default derivation", () => {
|
||||
const withExplicit = [
|
||||
{
|
||||
windowStart: "07:00",
|
||||
windowEnd: "07:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["09:00"],
|
||||
doseWindows: [{ timeOfDay: "09:00", start: "08:30", end: "11:30" }],
|
||||
},
|
||||
];
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: withExplicit,
|
||||
nowBerlin: localClock(11, 0),
|
||||
});
|
||||
expect(res.status).toBe("in_window");
|
||||
expect(res.window).toEqual({
|
||||
timeOfDay: "09:00",
|
||||
start: "08:30",
|
||||
end: "11:30",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,7 @@
|
||||
* unregistered queue silently never drains.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
@@ -370,6 +371,21 @@ export async function dedupeUserIntakeSlots(
|
||||
summary.daysRecomputed += 1;
|
||||
}
|
||||
|
||||
// v1.16.1 — a collapse changed the rows the cached compliance / intake /
|
||||
// summary payloads were built on; drop this user's server caches so the
|
||||
// next read reflects the corrected slots instead of waiting out the TTL.
|
||||
// Process-local like every server cache: in the default single-container
|
||||
// deploy the worker shares the web process, so the eviction reaches the
|
||||
// serving instance; a split web/worker deployment falls back to the TTL
|
||||
// bound (see `server-cache.ts`).
|
||||
if (
|
||||
summary.slotsCollapsed > 0 ||
|
||||
summary.rowsSoftDeleted > 0 ||
|
||||
summary.rowsNormalised > 0
|
||||
) {
|
||||
invalidateUserMedications(userId);
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.intake.slot_dedup",
|
||||
|
||||
@@ -192,3 +192,57 @@ describe("computeNextDueAt — skip resolved slots (twice-daily)", () => {
|
||||
expect(next!.toISOString()).toBe("2026-06-10T17:00:00.000Z"); // today 19:00
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.16.1 — a stale / degenerate legacy window must NEVER determine
|
||||
* next-due once `timesOfDay` exists. Regression fixture: a twice-daily
|
||||
* med whose schedule row still carries the historic `07:00 / 07:00`
|
||||
* point window while the canonical dose times moved to 09:00 / 21:00.
|
||||
* The card read "today, 07:00" off the window; the engine must anchor on
|
||||
* the timesOfDay slots exclusively.
|
||||
*/
|
||||
describe("computeNextDueAt — stale degenerate window never wins over timesOfDay", () => {
|
||||
function staleWindowTwiceDaily(): WorkerScheduleRow {
|
||||
return {
|
||||
id: "sched-stale",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "07:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["09:00", "21:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
};
|
||||
}
|
||||
|
||||
it("returns today's 09:00 slot before the morning dose", () => {
|
||||
const now = d("2026-06-10T06:00:00Z"); // 08:00 Berlin
|
||||
const next = computeNextDueAt({
|
||||
medication: makeMedication(),
|
||||
schedules: [staleWindowTwiceDaily()],
|
||||
now,
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: null,
|
||||
});
|
||||
expect(next).not.toBeNull();
|
||||
// 09:00 Berlin (CEST, UTC+2) = 07:00 UTC — never the stale 07:00 window.
|
||||
expect(next!.toISOString()).toBe("2026-06-10T07:00:00.000Z");
|
||||
});
|
||||
|
||||
it("returns today's 21:00 slot between the doses", () => {
|
||||
const now = d("2026-06-10T08:00:00Z"); // 10:00 Berlin
|
||||
const next = computeNextDueAt({
|
||||
medication: makeMedication(),
|
||||
schedules: [staleWindowTwiceDaily()],
|
||||
now,
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: null,
|
||||
});
|
||||
expect(next).not.toBeNull();
|
||||
// 21:00 Berlin = 19:00 UTC.
|
||||
expect(next!.toISOString()).toBe("2026-06-10T19:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,19 @@
|
||||
* schedule, so the helper accepts any schedule that satisfies the
|
||||
* minimal `ScheduleWindowInput` shape.
|
||||
*
|
||||
* v1.15.20 — three model fixes:
|
||||
* v1.16.1 — the dose-band model becomes the canonical source. When a
|
||||
* schedule carries `timesOfDay`, every status decision derives from
|
||||
* per-dose on-time bands — the explicit `doseWindows` entry for the
|
||||
* matching dose when present, else `timeOfDay ±` the default daily
|
||||
* on-time half-width. The legacy `windowStart` / `windowEnd` pair is
|
||||
* consulted ONLY for rows without `timesOfDay`; a stale or degenerate
|
||||
* window (e.g. `07:00 / 07:00` left behind by an old write path while
|
||||
* the times moved to `09:00 / 21:00`) can no longer paint a take-now
|
||||
* pill at 07:00 or mis-anchor the recorded slot. The matched band rides
|
||||
* on the result (`window`) so the pill text and the displayed-slot
|
||||
* resolution read the SAME band the status came from.
|
||||
*
|
||||
* v1.15.20 — three model fixes (kept):
|
||||
* - the wall-clock conversion takes the user's IANA timezone (default
|
||||
* `Europe/Berlin` so existing call sites compile + behave unchanged;
|
||||
* threading the real user timezone through the cards is the follow-up);
|
||||
@@ -25,13 +37,20 @@
|
||||
* time echoed into both fields) is a POINT window widened by the
|
||||
* default daily on-time half-width — it previously fell into the
|
||||
* overnight branch and read as a 24 h always-in-window band;
|
||||
* - `countPassedSchedules` counts the schedule's `timesOfDay` doses, not
|
||||
* - passed-dose counting counts the schedule's `timesOfDay` doses, not
|
||||
* schedule rows, so a two-dose row ("08:00" + "20:00") expects two
|
||||
* intake events before the overdue pill is suppressed.
|
||||
*/
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults";
|
||||
|
||||
/** One explicit per-dose on-time window as the API serialises it. */
|
||||
export interface DoseWindowEntryInput {
|
||||
timeOfDay: string;
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
export interface ScheduleWindowInput {
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
@@ -39,16 +58,35 @@ export interface ScheduleWindowInput {
|
||||
/**
|
||||
* v1.15.20 — first-class dose times. Optional so legacy fixtures and the
|
||||
* existing card call sites compile unchanged; when absent the schedule
|
||||
* row counts as ONE expected dose (the pre-v1.15.20 behaviour).
|
||||
* row falls back to the single legacy window (the pre-v1.16.1 behaviour).
|
||||
*/
|
||||
timesOfDay?: string[];
|
||||
/**
|
||||
* v1.16.1 — explicit per-dose on-time windows (the detail page's
|
||||
* Zeitplan editor writes them). Only read for doses listed in
|
||||
* `timesOfDay`; malformed entries fall back to the default derivation.
|
||||
*/
|
||||
doseWindows?: DoseWindowEntryInput[] | null;
|
||||
}
|
||||
|
||||
export type MedicationWindowStatus = "in_window" | "late" | "very_late" | null;
|
||||
|
||||
/**
|
||||
* The dose band that produced a non-null status. `timeOfDay` is the dose
|
||||
* anchor (a `timesOfDay` entry, or `windowStart` on a legacy row); `start`
|
||||
* / `end` are the HH:mm band bounds for display.
|
||||
*/
|
||||
export interface MatchedDoseWindow {
|
||||
timeOfDay: string;
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
export interface CurrentWindowStatus<Schedule extends ScheduleWindowInput> {
|
||||
status: MedicationWindowStatus;
|
||||
schedule: Schedule | null;
|
||||
/** Non-null exactly when `status` is non-null. */
|
||||
window: MatchedDoseWindow | null;
|
||||
}
|
||||
|
||||
/** Fallback timezone until every call site threads the user's own. */
|
||||
@@ -88,15 +126,29 @@ export function parseTimeToMinutes(value: string): number {
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
interface WindowBounds {
|
||||
function minutesToHm(mins: number): string {
|
||||
const clamped = Math.min(Math.max(mins, 0), 24 * 60 - 1);
|
||||
const h = Math.floor(clamped / 60);
|
||||
const m = clamped % 60;
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const HHMM_RE = /^([01]?\d|2[0-3]):([0-5]\d)$/;
|
||||
|
||||
interface DoseWindowBounds {
|
||||
/** Dose anchor for slot resolution + UI labels. */
|
||||
timeOfDay: string;
|
||||
startMins: number;
|
||||
/** May exceed 24 h × 60 for an overnight window (end wrapped past midnight). */
|
||||
/** May exceed 24 h × 60 for a legacy overnight window (end wrapped past midnight). */
|
||||
endMins: number;
|
||||
/** HH:mm display strings (the legacy branch keeps the raw schedule strings). */
|
||||
startHm: string;
|
||||
endHm: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a schedule's window into minute-of-day bounds with two special
|
||||
* shapes handled:
|
||||
* Resolve a legacy (no `timesOfDay`) schedule window into minute-of-day
|
||||
* bounds with two special shapes handled:
|
||||
*
|
||||
* - `end === start` — a degenerate POINT window (a single dose time echoed
|
||||
* into both fields). Previously this fell into the overnight branch and
|
||||
@@ -106,7 +158,10 @@ interface WindowBounds {
|
||||
* - `end < start` — a genuine overnight window; the end wraps past
|
||||
* midnight (`endMins > 1440`), matching the historical behaviour.
|
||||
*/
|
||||
function resolveWindowBounds(schedule: ScheduleWindowInput): WindowBounds {
|
||||
function resolveLegacyWindowBounds(schedule: ScheduleWindowInput): {
|
||||
startMins: number;
|
||||
endMins: number;
|
||||
} {
|
||||
let startMins = parseTimeToMinutes(schedule.windowStart);
|
||||
let endMins = parseTimeToMinutes(schedule.windowEnd);
|
||||
|
||||
@@ -121,34 +176,119 @@ function resolveWindowBounds(schedule: ScheduleWindowInput): WindowBounds {
|
||||
return { startMins, endMins };
|
||||
}
|
||||
|
||||
function getWindowStatus<Schedule extends ScheduleWindowInput>(
|
||||
schedule: Schedule,
|
||||
nowLocal: Date,
|
||||
/**
|
||||
* v1.16.1 — the canonical band resolution. A schedule with `timesOfDay`
|
||||
* yields ONE band per dose: the explicit `doseWindows` entry when present
|
||||
* and well-formed, else `timeOfDay ±` the default daily on-time
|
||||
* half-width (clamped to the local day). A schedule without `timesOfDay`
|
||||
* yields the single legacy window. The legacy `windowStart` / `windowEnd`
|
||||
* NEVER shape a band once `timesOfDay` exists — stale or degenerate
|
||||
* windows must not drive the status or the recorded slot.
|
||||
*/
|
||||
function resolveDoseWindows(schedule: ScheduleWindowInput): DoseWindowBounds[] {
|
||||
const times = (schedule.timesOfDay ?? []).filter((t) => HHMM_RE.test(t));
|
||||
if (times.length > 0) {
|
||||
const half = DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes;
|
||||
return times.map((timeOfDay) => {
|
||||
const explicit = Array.isArray(schedule.doseWindows)
|
||||
? schedule.doseWindows.find(
|
||||
(w) =>
|
||||
w &&
|
||||
w.timeOfDay === timeOfDay &&
|
||||
HHMM_RE.test(w.start) &&
|
||||
HHMM_RE.test(w.end) &&
|
||||
parseTimeToMinutes(w.start) <= parseTimeToMinutes(w.end),
|
||||
)
|
||||
: undefined;
|
||||
if (explicit) {
|
||||
return {
|
||||
timeOfDay,
|
||||
startMins: parseTimeToMinutes(explicit.start),
|
||||
endMins: parseTimeToMinutes(explicit.end),
|
||||
startHm: explicit.start,
|
||||
endHm: explicit.end,
|
||||
};
|
||||
}
|
||||
const anchor = parseTimeToMinutes(timeOfDay);
|
||||
const startMins = Math.max(0, anchor - half);
|
||||
const endMins = Math.min(24 * 60 - 1, anchor + half);
|
||||
return {
|
||||
timeOfDay,
|
||||
startMins,
|
||||
endMins,
|
||||
startHm: minutesToHm(startMins),
|
||||
endHm: minutesToHm(endMins),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const { startMins, endMins } = resolveLegacyWindowBounds(schedule);
|
||||
return [
|
||||
{
|
||||
timeOfDay: schedule.windowStart,
|
||||
startMins,
|
||||
endMins,
|
||||
// Keep the raw schedule strings for display so legacy pills render
|
||||
// byte-identically to the pre-band model.
|
||||
startHm: schedule.windowStart,
|
||||
endHm: schedule.windowEnd,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const STATUS_PRIORITY = { in_window: 3, late: 2, very_late: 1 } as const;
|
||||
|
||||
function bandStatus(
|
||||
band: DoseWindowBounds,
|
||||
nowMins: number,
|
||||
lateMinutes: number,
|
||||
missedMinutes: number,
|
||||
): MedicationWindowStatus {
|
||||
const nowMins = nowLocal.getHours() * 60 + nowLocal.getMinutes();
|
||||
const { startMins, endMins } = resolveWindowBounds(schedule);
|
||||
|
||||
// Handle overnight windows
|
||||
): Exclude<MedicationWindowStatus, null> | null {
|
||||
// Handle legacy overnight windows (wrapped end past midnight).
|
||||
const adjustedNow =
|
||||
nowMins < startMins && endMins > 24 * 60 ? nowMins + 24 * 60 : nowMins;
|
||||
nowMins < band.startMins && band.endMins > 24 * 60
|
||||
? nowMins + 24 * 60
|
||||
: nowMins;
|
||||
|
||||
// Currently in window
|
||||
if (adjustedNow >= startMins && adjustedNow <= endMins) return "in_window";
|
||||
if (adjustedNow >= band.startMins && adjustedNow <= band.endMins) {
|
||||
return "in_window";
|
||||
}
|
||||
|
||||
// Past window end: check late thresholds
|
||||
const minutesPastEnd = adjustedNow - endMins;
|
||||
const minutesPastEnd = adjustedNow - band.endMins;
|
||||
if (minutesPastEnd > 0 && minutesPastEnd <= lateMinutes) return "late";
|
||||
if (minutesPastEnd > lateMinutes && minutesPastEnd <= missedMinutes)
|
||||
if (minutesPastEnd > lateMinutes && minutesPastEnd <= missedMinutes) {
|
||||
return "very_late";
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isLastIntakeInCurrentWindow<Schedule extends ScheduleWindowInput>(
|
||||
function getScheduleStatus(
|
||||
schedule: ScheduleWindowInput,
|
||||
nowLocal: Date,
|
||||
lateMinutes: number,
|
||||
missedMinutes: number,
|
||||
): {
|
||||
status: Exclude<MedicationWindowStatus, null>;
|
||||
band: DoseWindowBounds;
|
||||
} | null {
|
||||
const nowMins = nowLocal.getHours() * 60 + nowLocal.getMinutes();
|
||||
let best: {
|
||||
status: Exclude<MedicationWindowStatus, null>;
|
||||
band: DoseWindowBounds;
|
||||
} | null = null;
|
||||
for (const band of resolveDoseWindows(schedule)) {
|
||||
const status = bandStatus(band, nowMins, lateMinutes, missedMinutes);
|
||||
if (!status) continue;
|
||||
if (!best || STATUS_PRIORITY[status] > STATUS_PRIORITY[best.status]) {
|
||||
best = { status, band };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function isLastIntakeInBand(
|
||||
lastTakenAt: string | null,
|
||||
schedule: Schedule,
|
||||
band: DoseWindowBounds,
|
||||
nowLocal: Date,
|
||||
tz: string,
|
||||
): boolean {
|
||||
@@ -166,35 +306,35 @@ function isLastIntakeInCurrentWindow<Schedule extends ScheduleWindowInput>(
|
||||
}
|
||||
|
||||
const intakeMins = intake.getHours() * 60 + intake.getMinutes();
|
||||
const { startMins, endMins } = resolveWindowBounds(schedule);
|
||||
|
||||
// Handle overnight windows
|
||||
// Handle legacy overnight windows
|
||||
const adjustedIntake =
|
||||
intakeMins < startMins && endMins > 24 * 60
|
||||
intakeMins < band.startMins && band.endMins > 24 * 60
|
||||
? intakeMins + 24 * 60
|
||||
: intakeMins;
|
||||
|
||||
return adjustedIntake >= startMins && adjustedIntake <= endMins;
|
||||
return adjustedIntake >= band.startMins && adjustedIntake <= band.endMins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how many doses are past their window today, honouring the
|
||||
* Count how many doses are past their band today, honouring the
|
||||
* recurrence's allowed days-of-week. Used by both cards to suppress
|
||||
* "overdue" pills once the user has already covered every passed dose
|
||||
* with intake events for the day.
|
||||
*
|
||||
* v1.15.20 — counts `timesOfDay` entries, not schedule rows: a schedule
|
||||
* row carrying two dose times expects TWO intake events once its window
|
||||
* passes. A row without `timesOfDay` (legacy shape) keeps counting as one.
|
||||
* The passed check stays anchored on the (degenerate-resolved) raw window
|
||||
* end of the same local day, matching the historical comparison — an
|
||||
* overnight window therefore never reads as "passed" within the same local
|
||||
* day, exactly as before.
|
||||
* v1.16.1 — counts per-dose BANDS: each `timesOfDay` dose passes when its
|
||||
* own on-time band end has passed, so a 09:00 / 21:00 row reads ONE
|
||||
* passed dose at noon (not two). A row without `timesOfDay` (legacy
|
||||
* shape) keeps the single-window behaviour, including the degenerate
|
||||
* point-window resolution; a wrapped overnight end (> 1440) keeps the raw
|
||||
* minute comparison so an overnight window never reads as "passed"
|
||||
* within the same local day, exactly as before.
|
||||
*/
|
||||
function countPassedSchedules<Schedule extends ScheduleWindowInput>(
|
||||
function countPassedDoses<Schedule extends ScheduleWindowInput>(
|
||||
schedules: Schedule[],
|
||||
nowLocal: Date,
|
||||
): number {
|
||||
const nowMins = nowLocal.getHours() * 60 + nowLocal.getMinutes();
|
||||
return schedules.reduce((sum, s) => {
|
||||
const recurrence = parseScheduleRecurrence(s.daysOfWeek);
|
||||
if (
|
||||
@@ -203,32 +343,27 @@ function countPassedSchedules<Schedule extends ScheduleWindowInput>(
|
||||
) {
|
||||
return sum;
|
||||
}
|
||||
// Same-day end bound: a wrapped overnight end (> 1440) keeps the raw
|
||||
// minute value so the comparison below matches the historical
|
||||
// behaviour; the degenerate point window resolves to its widened end.
|
||||
const rawStart = parseTimeToMinutes(s.windowStart);
|
||||
const rawEnd = parseTimeToMinutes(s.windowEnd);
|
||||
const endMins =
|
||||
rawEnd === rawStart
|
||||
? Math.min(24 * 60 - 1, rawEnd + DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes)
|
||||
: rawEnd;
|
||||
const nowMins = nowLocal.getHours() * 60 + nowLocal.getMinutes();
|
||||
if (nowMins <= endMins) return sum;
|
||||
const doseCount =
|
||||
s.timesOfDay && s.timesOfDay.length > 0 ? s.timesOfDay.length : 1;
|
||||
return sum + doseCount;
|
||||
const passed = resolveDoseWindows(s).filter((band) => {
|
||||
// Same-day end bound: a wrapped overnight end (> 1440) keeps the
|
||||
// raw minute value so the comparison matches the historical
|
||||
// behaviour (it never passes within the same local day).
|
||||
const endMins =
|
||||
band.endMins > 24 * 60 ? band.endMins - 24 * 60 : band.endMins;
|
||||
return nowMins > endMins;
|
||||
}).length;
|
||||
return sum + passed;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce a list of schedules into the most actionable window status
|
||||
* for the card header pill. Returns `null` status when the medication
|
||||
* is paused, no schedule is currently in/past its window, or every
|
||||
* overdue schedule is already covered by today's intake events.
|
||||
* is paused, no dose band is currently in/past its window, or every
|
||||
* overdue dose is already covered by today's intake events.
|
||||
*
|
||||
* Priority: in_window > late > very_late. Suppresses an `in_window`
|
||||
* status when the last intake already falls inside the window so the
|
||||
* pill doesn't nag after the user took the dose.
|
||||
* status when the last intake already falls inside the matched band so
|
||||
* the pill doesn't nag after the user took the dose.
|
||||
*
|
||||
* `nowBerlin` is the wall-clock "now" already shifted into the user's
|
||||
* timezone (via `toZonedDate`); `tz` must name the same timezone so the
|
||||
@@ -260,32 +395,41 @@ export function reduceCurrentWindowStatus<Schedule extends ScheduleWindowInput>(
|
||||
tz = DEFAULT_TZ,
|
||||
} = options;
|
||||
|
||||
if (!active) return { status: null, schedule: null };
|
||||
const none: CurrentWindowStatus<Schedule> = {
|
||||
status: null,
|
||||
schedule: null,
|
||||
window: null,
|
||||
};
|
||||
if (!active) return none;
|
||||
|
||||
const passedScheduleCount = countPassedSchedules(schedules, nowBerlin);
|
||||
const hasUncoveredOverdue = todayEventCount < passedScheduleCount;
|
||||
const passedDoseCount = countPassedDoses(schedules, nowBerlin);
|
||||
const hasUncoveredOverdue = todayEventCount < passedDoseCount;
|
||||
|
||||
return schedules.reduce<CurrentWindowStatus<Schedule>>(
|
||||
(best, s) => {
|
||||
const status = getWindowStatus(s, nowBerlin, lateMinutes, missedMinutes);
|
||||
if (!status) return best;
|
||||
// Don't show late/very_late if all overdue schedules are covered
|
||||
// by intake events for the day.
|
||||
if (status !== "in_window" && !hasUncoveredOverdue) return best;
|
||||
// Don't show in_window if last intake is already within this
|
||||
// window today.
|
||||
if (
|
||||
status === "in_window" &&
|
||||
isLastIntakeInCurrentWindow(lastTakenAt, s, nowBerlin, tz)
|
||||
) {
|
||||
return best;
|
||||
}
|
||||
const priority = { in_window: 3, late: 2, very_late: 1 };
|
||||
if (!best.status || priority[status] > priority[best.status]) {
|
||||
return { status, schedule: s };
|
||||
}
|
||||
return schedules.reduce<CurrentWindowStatus<Schedule>>((best, s) => {
|
||||
const hit = getScheduleStatus(s, nowBerlin, lateMinutes, missedMinutes);
|
||||
if (!hit) return best;
|
||||
// Don't show late/very_late if all overdue doses are covered by
|
||||
// intake events for the day.
|
||||
if (hit.status !== "in_window" && !hasUncoveredOverdue) return best;
|
||||
// Don't show in_window if last intake is already within this band
|
||||
// today.
|
||||
if (
|
||||
hit.status === "in_window" &&
|
||||
isLastIntakeInBand(lastTakenAt, hit.band, nowBerlin, tz)
|
||||
) {
|
||||
return best;
|
||||
},
|
||||
{ status: null, schedule: null },
|
||||
);
|
||||
}
|
||||
if (!best.status || STATUS_PRIORITY[hit.status] > STATUS_PRIORITY[best.status]) {
|
||||
return {
|
||||
status: hit.status,
|
||||
schedule: s,
|
||||
window: {
|
||||
timeOfDay: hit.band.timeOfDay,
|
||||
start: hit.band.startHm,
|
||||
end: hit.band.endHm,
|
||||
},
|
||||
};
|
||||
}
|
||||
return best;
|
||||
}, none);
|
||||
}
|
||||
@@ -803,6 +803,13 @@ export const updateInventoryItemSchema = z.object({
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional(),
|
||||
/**
|
||||
* v1.16.1 — stock correction (the Bestand tab's adjust / withdraw
|
||||
* flow). Sets the remaining-dose count directly; the route clamps to
|
||||
* `dosesTotal` and re-runs the canonical state machine (0 ⇒ USED_UP,
|
||||
* a raise out of 0 re-evaluates against the expiry clocks).
|
||||
*/
|
||||
dosesRemaining: z.number().int().min(0).max(100).optional(),
|
||||
notes: z.string().max(200).nullable().optional(),
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user