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): *