diff --git a/messages/de.json b/messages/de.json
index 1e559d5c7..bf1251f77 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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",
diff --git a/messages/en.json b/messages/en.json
index 5984d6925..d9a1c06b9 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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",
diff --git a/messages/es.json b/messages/es.json
index 2947cee83..870556bce 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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",
diff --git a/messages/fr.json b/messages/fr.json
index eb0f49d1d..857daa932 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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",
diff --git a/messages/it.json b/messages/it.json
index 560cf21b2..a3f58daa8 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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ì",
diff --git a/messages/pl.json b/messages/pl.json
index 25b3b9132..265b3f967 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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",
diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx
index b22e667e9..37858ce52 100644
--- a/src/app/medications/page.tsx
+++ b/src/app/medications/page.tsx
@@ -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). */
diff --git a/src/components/medications/__tests__/medication-card-glp1.test.tsx b/src/components/medications/__tests__/medication-card-glp1.test.tsx
index fa3086ad9..6263f478c 100644
--- a/src/components/medications/__tests__/medication-card-glp1.test.tsx
+++ b/src/components/medications/__tests__/medication-card-glp1.test.tsx
@@ -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,
diff --git a/src/components/medications/__tests__/medication-card-next-due.test.tsx b/src/components/medications/__tests__/medication-card-next-due.test.tsx
new file mode 100644
index 000000000..8fdf0c629
--- /dev/null
+++ b/src/components/medications/__tests__/medication-card-next-due.test.tsx
@@ -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(
+
+ {node}
+ ,
+ );
+}
+
+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(" — 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(
+ {}}
+ 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(
+ {}}
+ 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(
+ {}}
+ onOpenHistory={() => {}}
+ onOpenAdvanced={() => {}}
+ />,
+ client,
+ );
+
+ expect(html).toContain("Next intake:");
+ // Relative day label from the server instant (capitalised "Tomorrow").
+ expect(html).toContain("Tomorrow,");
+ });
+});
diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx
index ed38a03bb..0b8956ac0 100644
--- a/src/components/medications/glp1-medication-card.tsx
+++ b/src/components/medications/glp1-medication-card.tsx
@@ -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;
diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx
index 615189f83..47ebb329b 100644
--- a/src/components/medications/medication-card.tsx
+++ b/src/components/medications/medication-card.tsx
@@ -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 = "";
diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts
index 1feffe1c1..6496ad39c 100644
--- a/src/lib/medications/scheduling/recurrence.ts
+++ b/src/lib/medications/scheduling/recurrence.ts
@@ -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):
*