fix(medications): read next-due from the server and confirm intake actions

Two fixes on the medication cards.

Next-due: the card recomputed "next intake" client-side from
`daysOfWeek` + `windowStart` alone, ignoring rolling intervals, RRULE,
one-shot doses, and the last-intake anchor. A rolling medication (e.g. a
35-day interval) showed "tomorrow" and never re-anchored after a dose.
`GET /api/medications` already returns `nextDueAt` per medication,
computed by the canonical recurrence engine anchored on the last intake.
Both card variants now render that value directly: the generic card
drops its `getNextOccurrenceTimestamp` walker, and the GLP-1 card drops
its weekly-only predictor that hardcoded a 7-day step. The day-label and
window-range rendering are unchanged; only the timestamp source moves to
the server. `nextDueAt` is threaded through the list page's medication
type into both cards.

Intake confirmation: tapping Taken / Skipped now raises a toast naming
the medication ("… recorded as taken" / "… skipped"), so the action has
clear feedback. The next-intake line refreshes on the invalidated list
query, which the next-due fix above makes accurate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-01 17:31:02 +02:00
1 parent 96f0b225fd
commit 0e3a8f9cd3
12 files changed
+290 -89

No files matched your search

+2
View File
@@ -549,6 +549,8 @@
"dayStreak": "Tage Serie",
"taken": "Genommen",
"skipped": "Übersprungen",
"intakeToastTaken": "{name} als genommen erfasst",
"intakeToastSkipped": "{name} übersprungen",
"weekdaySunday": "Sonntag",
"weekdayMonday": "Montag",
"weekdayTuesday": "Dienstag",
+2
View File
@@ -549,6 +549,8 @@
"dayStreak": "day streak",
"taken": "Taken",
"skipped": "Skipped",
"intakeToastTaken": "{name} recorded as taken",
"intakeToastSkipped": "{name} skipped",
"weekdaySunday": "Sunday",
"weekdayMonday": "Monday",
"weekdayTuesday": "Tuesday",
+2
View File
@@ -549,6 +549,8 @@
"dayStreak": "días de racha",
"taken": "Tomada",
"skipped": "Omitida",
"intakeToastTaken": "{name} registrada como tomada",
"intakeToastSkipped": "{name} omitida",
"weekdaySunday": "Domingo",
"weekdayMonday": "Lunes",
"weekdayTuesday": "Martes",
+2
View File
@@ -549,6 +549,8 @@
"dayStreak": "jours de série",
"taken": "Pris",
"skipped": "Sauté",
"intakeToastTaken": "{name} enregistré comme pris",
"intakeToastSkipped": "{name} sauté",
"weekdaySunday": "Dimanche",
"weekdayMonday": "Lundi",
"weekdayTuesday": "Mardi",
+2
View File
@@ -549,6 +549,8 @@
"dayStreak": "giorni di serie",
"taken": "Assunta",
"skipped": "Saltata",
"intakeToastTaken": "{name} registrata come assunta",
"intakeToastSkipped": "{name} saltata",
"weekdaySunday": "Domenica",
"weekdayMonday": "Lunedì",
"weekdayTuesday": "Martedì",
+2
View File
@@ -549,6 +549,8 @@
"dayStreak": "dni serii",
"taken": "Przyjęte",
"skipped": "Pominięte",
"intakeToastTaken": "{name} oznaczone jako przyjęte",
"intakeToastSkipped": "{name} pominięte",
"weekdaySunday": "Niedziela",
"weekdayMonday": "Poniedziałek",
"weekdayTuesday": "Wtorek",
+6
View File
@@ -48,6 +48,12 @@ interface Medication {
notificationsEnabled: boolean;
pausedAt: string | null;
lastTakenAt: string | null;
/**
* v1.8.4 — server-computed next due instant (canonical recurrence
* engine). Threaded straight through to both card variants, which render
* it instead of re-deriving the timestamp client-side.
*/
nextDueAt?: string | null;
/** v1.5 — medication-level course start date (ISO string). */
startsOn?: string | null;
/** v1.5 — medication-level course end date (ISO string). */
@@ -49,11 +49,14 @@ const med7p5: Glp1Medication = {
// not "today/yesterday" — keeps the SSR string deterministic-ish
// across local clocks while still exercising the with-site branch.
lastTakenAt: "2026-05-08T08:00:00.000Z",
// v1.8.4 — the card now reads the server-computed next-due instant
// directly instead of re-deriving it client-side. A far-future fixed
// date keeps the "in N days" branch deterministic across host clocks.
nextDueAt: "2099-05-09T08:00:00.000Z",
schedules: [
{
id: "s1",
// Saturday only — one entry triggers the weekly-preset branch
// in `predictNextWeeklyDate()`.
// Saturday only.
windowStart: "08:00",
windowEnd: "20:00",
label: null,
@@ -0,0 +1,202 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { MedicationCard } from "@/components/medications/medication-card";
/**
* v1.8.4 — the generic medication card must render the server-computed
* `nextDueAt` (canonical recurrence engine, anchored on the last intake)
* as the "next intake" timestamp, NOT a client-side daysOfWeek walker.
*
* The legacy walker only read `daysOfWeek` + `windowStart` and ignored
* rolling / RRULE / one-shot cadences, so a rolling medication (e.g.
* `rollingIntervalDays = 35`) wrongly read "tomorrow" and never
* re-anchored after an intake. These tests pin that the card now
* surfaces the server value, while the day-label / window-range
* rendering for ordinary single-time schedules is unchanged.
*/
function makeClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0, staleTime: Infinity },
},
});
}
function render(node: React.ReactNode, client: QueryClient) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
function seedCompliance(client: QueryClient, medId: string) {
client.setQueryData(["medications", medId, "compliance"], {
compliance7: { rate: 90, streak: 0, totalExpected: 7, taken: 6 },
compliance30: { rate: 88 },
});
}
// A schedule whose window has already passed for today (01:00–02:00) so
// the card surfaces the "next intake" line rather than the take-now
// pill, regardless of host clock.
const pastWindow = {
windowStart: "01:00",
windowEnd: "02:00",
label: null,
daysOfWeek: null,
dose: null,
};
describe("<MedicationCard> — next-due reads from server nextDueAt", () => {
it("renders the server nextDueAt date for a rolling medication, not a today/tomorrow walker value", () => {
// Rolling cadence: server anchors the next dose 35 days out. The
// card must surface that calendar date — the legacy walker would
// have produced "tomorrow" off the schedule window alone.
const now = new Date();
const due = new Date(now);
due.setDate(due.getDate() + 35);
// Midday so the Berlin display can't cross a day boundary regardless
// of the host timezone.
due.setHours(12, 0, 0, 0);
const rollingMed = {
id: "med-rolling-1",
name: "Mounjaro",
dose: "7.5 mg",
category: "OTHER",
treatmentClass: undefined as string | undefined,
active: true,
notificationsEnabled: true,
pausedAt: null,
lastTakenAt: null,
todayEventCount: 0,
nextDueAt: due.toISOString(),
schedules: [{ id: "s-rolling-1", ...pastWindow }],
};
const client = makeClient();
seedCompliance(client, rollingMed.id);
const html = render(
<MedicationCard
medication={rollingMed}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
// The next-intake line surfaces.
expect(html).toContain("Next intake:");
// 35 days out is past the 5-day threshold, so the card renders the
// full weekday-anchored short date (e.g. "Mon, 07/06") derived from
// the SERVER instant — NOT a today/tomorrow walker value. Pin the
// day-of-month + month of the SERVER date (Berlin-rendered), proving
// the date tracks `nextDueAt` rather than the schedule window alone.
const berlin = new Intl.DateTimeFormat("en", {
timeZone: "Europe/Berlin",
weekday: "short",
day: "2-digit",
month: "2-digit",
}).format(due);
expect(html).toContain(berlin);
expect(html).not.toContain("Next intake: Today,");
expect(html).not.toContain("Next intake: Tomorrow,");
});
it("falls back to the schedule window range when nextDueAt is absent", () => {
// A medication with no server next-due (e.g. ended course) still
// renders the window-range line off its first schedule; it just
// omits the day label.
const med = {
id: "med-nodue-1",
name: "Ramipril",
dose: "5 mg",
category: "BLOOD_PRESSURE",
treatmentClass: undefined as string | undefined,
active: true,
notificationsEnabled: true,
pausedAt: null,
lastTakenAt: null,
todayEventCount: 0,
nextDueAt: null,
schedules: [{ id: "s-nodue-1", ...pastWindow }],
};
const client = makeClient();
seedCompliance(client, med.id);
const html = render(
<MedicationCard
medication={med}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
expect(html).toContain("Next intake:");
// No day label is prepended when the server gave no instant — the
// line goes straight to the window range.
expect(html).not.toContain("Next intake: Today,");
expect(html).not.toContain("Next intake: Tomorrow,");
});
it("renders the day label for a single-time daysOfWeek medication due tomorrow", () => {
// A daily / weekly medication whose server next-due is tomorrow
// still renders the relative "tomorrow" label — proving the ordinary
// single-time path keeps working.
const now = new Date();
const due = new Date(now);
due.setDate(due.getDate() + 1);
due.setHours(8, 0, 0, 0);
const med = {
id: "med-daily-1",
name: "Ramipril",
dose: "5 mg",
category: "BLOOD_PRESSURE",
treatmentClass: undefined as string | undefined,
active: true,
notificationsEnabled: true,
pausedAt: null,
lastTakenAt: null,
todayEventCount: 0,
nextDueAt: due.toISOString(),
schedules: [
{
id: "s-daily-1",
windowStart: "08:00",
windowEnd: "09:00",
label: null,
daysOfWeek: null,
dose: "5 mg",
},
],
};
const client = makeClient();
seedCompliance(client, med.id);
const html = render(
<MedicationCard
medication={med}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
expect(html).toContain("Next intake:");
// Relative day label from the server instant (capitalised "Tomorrow").
expect(html).toContain("Tomorrow,");
});
});
@@ -2,6 +2,7 @@
import { useEffect, useReducer, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Card, CardContent } from "@/components/ui/card";
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
@@ -79,6 +80,14 @@ export interface Glp1Medication {
pausedAt: string | null;
lastTakenAt: string | null;
todayEventCount?: number;
/**
* v1.8.4 — server-computed next due instant from `GET /api/medications`
* (the canonical recurrence engine anchored on the last intake). The card
* renders this directly so a rolling GLP-1 with an interval other than the
* weekly default re-anchors correctly; the old client-side predictor only
* walked a single hardcoded weekly day-of-week.
*/
nextDueAt?: string | null;
schedules: ScheduleLite[];
}
@@ -126,14 +135,6 @@ const DAY_KEYS = [
"medications.daysSat",
] as const;
function parseDayList(raw: string | null): number[] {
if (!raw) return [];
return raw
.split(",")
.map((p) => Number(p.trim()))
.filter((n) => Number.isInteger(n) && n >= 0 && n <= 6);
}
function diffDays(target: Date, from: Date): number {
const ms =
Date.UTC(target.getFullYear(), target.getMonth(), target.getDate()) -
@@ -141,26 +142,22 @@ function diffDays(target: Date, from: Date): number {
return Math.round(ms / (24 * 60 * 60 * 1000));
}
function predictNextWeeklyDate(
schedule: ScheduleLite | null,
lastTakenAt: string | null,
/**
* v1.8.4 — the next injection instant now comes from the server's
* `nextDueAt` (the canonical recurrence engine, anchored on the last
* intake). The legacy client-side predictor only handled a single
* hardcoded weekly day-of-week and re-anchored at +7 days, so a rolling
* GLP-1 with a non-weekly interval rendered the wrong date and never
* advanced after an injection.
*/
function nextInjectionFromServer(
nextDueAt: string | null | undefined,
now: Date,
): { date: Date; daysAway: number } | null {
if (!schedule) return null;
const dow = parseDayList(schedule.daysOfWeek);
if (dow.length !== 1) return null;
const target = dow[0];
const anchor = lastTakenAt ? new Date(lastTakenAt) : now;
const cursor = new Date(anchor);
cursor.setHours(0, 0, 0, 0);
for (let i = 1; i <= 14; i += 1) {
cursor.setDate(cursor.getDate() + 1);
if (cursor.getDay() === target) {
const days = diffDays(cursor, now);
return { date: cursor, daysAway: Math.max(0, days) };
}
}
return null;
if (!nextDueAt) return null;
const date = new Date(nextDueAt);
if (Number.isNaN(date.getTime())) return null;
return { date, daysAway: Math.max(0, diffDays(date, now)) };
}
export function Glp1MedicationCard({
@@ -234,6 +231,14 @@ export function Glp1MedicationCard({
body: JSON.stringify({ skipped }),
});
if (res.ok) {
toast.success(
t(
skipped
? "medications.intakeToastSkipped"
: "medications.intakeToastTaken",
{ name: medication.name },
),
);
await invalidateKeys(queryClient, medicationDependentKeys);
}
} finally {
@@ -243,7 +248,7 @@ export function Glp1MedicationCard({
const schedule = medication.schedules[0] ?? null;
const now = new Date();
const next = predictNextWeeklyDate(schedule, medication.lastTakenAt, now);
const next = nextInjectionFromServer(medication.nextDueAt, now);
const rate7 = compliance?.compliance7?.rate ?? 0;
const rate30 = compliance?.compliance30?.rate ?? 0;
const streak = compliance?.compliance7?.streak ?? 0;
+29 -57
View File
@@ -2,6 +2,7 @@
import { useState, useEffect, useReducer } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Card, CardContent } from "@/components/ui/card";
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
import { MedicationCardMenu } from "@/components/medications/medication-card-menu";
@@ -9,12 +10,10 @@ import { MedicationStateBadges } from "@/components/medications/card-parts/medic
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars";
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { formatTimeWindowRange } from "@/lib/time-window-format";
import { formatDateTime, formatTime } from "@/lib/format";
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
import {
parseTimeToMinutes,
reduceCurrentWindowStatus,
toBerlinDate,
} from "@/lib/medications/window-status";
@@ -52,6 +51,14 @@ interface Medication {
pausedAt: string | null;
lastTakenAt: string | null;
todayEventCount?: number;
/**
* v1.8.4 — server-computed next due instant from `GET /api/medications`
* (`computeNextDueAt` → the canonical recurrence engine, anchored on the
* last intake). The card renders this directly instead of re-deriving the
* timestamp client-side; the engine honours rolling / RRULE / one-shot
* cadences that the legacy daysOfWeek-only walker ignored.
*/
nextDueAt?: string | null;
schedules: Schedule[];
}
@@ -86,47 +93,6 @@ interface MedicationCardProps {
onOpenAdvanced: (med: Medication) => void;
}
function getNextOccurrenceTimestamp(
schedule: Schedule,
nowBerlin: Date,
): number | null {
const recurrence = parseScheduleRecurrence(schedule.daysOfWeek);
const baseDay = new Date(nowBerlin);
baseDay.setHours(0, 0, 0, 0);
const nowMinutes = nowBerlin.getHours() * 60 + nowBerlin.getMinutes();
const weekMs = 7 * 24 * 60 * 60 * 1000;
const baseWeekIndex = Math.floor(baseDay.getTime() / weekMs);
const startMinutes = parseTimeToMinutes(schedule.windowStart);
for (let offset = 0; offset <= 400; offset += 1) {
const candidate = new Date(baseDay);
candidate.setDate(baseDay.getDate() + offset);
const candidateWeekIndex = Math.floor(candidate.getTime() / weekMs);
const isAllowedWeek =
recurrence.intervalWeeks <= 1 ||
candidateWeekIndex % recurrence.intervalWeeks ===
baseWeekIndex % recurrence.intervalWeeks;
if (!isAllowedWeek) continue;
if (
recurrence.daysOfWeek.length > 0 &&
!recurrence.daysOfWeek.includes(candidate.getDay())
) {
continue;
}
if (offset === 0 && startMinutes <= nowMinutes) {
continue;
}
return candidate.getTime() + startMinutes * 60 * 1000;
}
return null;
}
export function MedicationCard({
medication,
onEdit,
@@ -177,6 +143,14 @@ export function MedicationCard({
body: JSON.stringify({ skipped }),
});
if (res.ok) {
toast.success(
t(
skipped
? "medications.intakeToastSkipped"
: "medications.intakeToastTaken",
{ name: medication.name },
),
);
await invalidateKeys(queryClient, medicationDependentKeys);
}
} finally {
@@ -194,19 +168,18 @@ export function MedicationCard({
a.windowEnd.localeCompare(b.windowEnd),
);
const nowBerlin = toBerlinDate(new Date());
const nextOccurrence =
sortedSchedules.length > 0
? (sortedSchedules
.map((schedule) => ({
schedule,
nextAt: getNextOccurrenceTimestamp(schedule, nowBerlin),
}))
.filter((entry): entry is { schedule: Schedule; nextAt: number } =>
Number.isFinite(entry.nextAt),
)
.sort((a, b) => a.nextAt - b.nextAt)[0] ?? null)
: null;
const nextSchedule = nextOccurrence?.schedule ?? sortedSchedules[0] ?? null;
// v1.8.4 — the next-due instant comes from the server (`nextDueAt`,
// computed by the canonical recurrence engine anchored on the last
// intake). The day label below derives from it; the window-range /
// dose / label still come from the earliest configured schedule. The
// legacy client-side daysOfWeek walker only ever read daysOfWeek +
// windowStart and silently mis-anchored rolling / RRULE / one-shot
// cadences, so it is gone.
const nextDueMs = medication.nextDueAt
? new Date(medication.nextDueAt).getTime()
: NaN;
const nextAt = Number.isFinite(nextDueMs) ? nextDueMs : undefined;
const nextSchedule = sortedSchedules[0] ?? null;
const lateMinutes = thresholds?.lateMinutes ?? 120;
const missedMinutes = thresholds?.missedMinutes ?? 240;
@@ -291,7 +264,6 @@ export function MedicationCard({
currentWindowStatus.status !== "in_window" &&
(() => {
const s = nextSchedule;
const nextAt = nextOccurrence?.nextAt;
// Format day label relative to today
let dayLabel = "";
+4 -3
View File
@@ -6,9 +6,10 @@
* the v1.5.0 cut wires only the reminder worker (via
* `worker-helpers.ts`) through it. The today-projector
* (`expandTodayIntakes`), the cadence chart (`expandScheduleSlots`),
* the medication card (`getNextOccurrenceTimestamp`), and the
* form-level helpers continue on the legacy walker through v1.5.x and
* migrate in v1.5.1 per the read-flip plan.
* and the form-level helpers continue on the legacy walker through
* v1.5.x and migrate per the read-flip plan. The medication card's
* "next intake" line reads the server-computed `nextDueAt` (this engine
* via `computeNextDueAt`) directly as of v1.8.4.
*
* Dispatch tiers (first matching tier wins):
*