From a28c6b55abbff6650b985ad0a2efa21d412222be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 19:58:09 +0100 Subject: [PATCH] fix(medications): spell the reminder's slot time in the user's clock The dose reminder printed the slot as the raw stored HH:mm, so it said "20:00" while the medication card said "08:00 PM". That hit everyone who set a 12-hour clock, and every en or ko user on the automatic setting, which resolves to 12 hours for those languages. The reminder now formats the slot's instant in the owner's zone with the same formatter the card uses, honouring the owner's hour-cycle choice. A recipient reading the message in another language gets that language's clock when the owner left the setting on automatic. --- .../medication-reminder-check.test.ts | 49 +++++++++++++++++++ .../reminder/medication-reminder-check.ts | 35 +++++++++++-- 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/src/lib/jobs/__tests__/medication-reminder-check.test.ts b/src/lib/jobs/__tests__/medication-reminder-check.test.ts index a47225f06..053d222c3 100644 --- a/src/lib/jobs/__tests__/medication-reminder-check.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-check.test.ts @@ -111,6 +111,7 @@ vi.mock("@/lib/notifications/reminder-dedup", () => ({ import { handleReminderCheck, nextSlotHeldBySnooze, + slotClock, } from "@/lib/jobs/reminder/medication-reminder-check"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; import { isModuleEnabled } from "@/lib/modules/gate"; @@ -308,6 +309,54 @@ describe("handleReminderCheck per-slot reminder windows", () => { }); }); +describe("the slot time in the reminder text follows the 12/24-hour setting", () => { + // 20:00 in Berlin on a summer day. + const slot = new Date("2026-07-28T18:00:00.000Z"); + + it("AUTO follows the message's language", () => { + const clock = slotClock(slot, "Europe/Berlin", { + timeFormat: "AUTO", + dateFormat: "AUTO", + }); + expect(clock("en")).toBe("08:00 PM"); + expect(clock("de")).toBe("20:00"); + }); + + it("an explicit choice wins over the language", () => { + expect( + slotClock(slot, "Europe/Berlin", { + timeFormat: "H24", + dateFormat: "AUTO", + })("en"), + ).toBe("20:00"); + expect( + slotClock(slot, "Europe/Berlin", { + timeFormat: "H12", + dateFormat: "AUTO", + })("de"), + ).toBe("08:00 PM"); + }); + + it("the dispatched reminder carries the person's clock", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T09:15:00.000Z")); + const medication = medicationWithSchedule({ + windowEnd: "18:00", + timesOfDay: ["08:00", "18:00"], + }); + prismaMock.medication.findMany.mockResolvedValue([medication]); + + await handleReminderCheck([]); + + expect(dispatchNotification).toHaveBeenCalledTimes(1); + const payload = vi.mocked(dispatchNotification).mock.calls[0][0]; + // An `en` owner on AUTO reads a 12-hour clock, as on the medication card. + expect(payload.message).toContain("08:00 AM"); + // A recipient in another language gets that language's clock. + expect(payload.renderForRecipient?.("de").message).toContain("08:00)"); + }); +}); + describe("handleReminderCheck medication-wide slot attribution", () => { it("lets an overlapping schedule claim the nearest medication-wide slot", async () => { vi.useFakeTimers(); diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index c7fa1dbc4..b57f86942 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -49,6 +49,11 @@ import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order"; import { isModuleEnabled } from "@/lib/modules/gate"; import { getEvent } from "@/lib/logging/context"; +import { + makeFormatters, + type DateFormatPreference, + type TimeFormatPreference, +} from "@/lib/format-locale"; export interface ReminderCheckPayload { triggeredAt: string; @@ -93,6 +98,9 @@ async function loadRemindableMedications( // Used to localise the reminder title / message / keyboard // labels per user. Null falls back to the app default. locale: true, + // How the slot time is spelled (`slotClock`). + timeFormat: true, + dateFormat: true, }, }, }, @@ -110,6 +118,27 @@ async function loadRemindableMedications( return medications.filter((med) => !moduleOff.has(med.user.id)); } +/** + * The slot's clock time as the person reads it everywhere else in the app: + * their hour cycle, through the same formatter as the medication card. AUTO + * follows the message's language, so an `en` reminder reads "08:00 PM" where + * a `de` one reads "20:00". The slot's instant is formatted in the person's + * zone; the bare `HH:mm` would be read in the server's. + */ +export function slotClock( + slot: Date, + timeZone: string, + person: { + timeFormat: TimeFormatPreference; + dateFormat: DateFormatPreference; + }, +): (locale: Locale) => string { + return (locale) => + makeFormatters(locale, timeZone, person.timeFormat, person.dateFormat).time( + slot, + ); +} + /** * Whether a medication-level snooze holds the reminder of one slot. It * holds only while it runs (`now < snoozedUntil`), and only for a slot whose @@ -613,7 +642,7 @@ export async function handleReminderCheck( } const doseInfo = schedule.dose ?? med.dose; - const timeWindow = `${slotTime}`; + const timeWindow = slotClock(slotScheduledFor, userTz, med.user); // RED phase: create missed intake event for this slot. if (currentPhase === "RED") { @@ -663,7 +692,7 @@ export async function handleReminderCheck( currentPhase, med.name, doseInfo, - timeWindow, + timeWindow(recipientLocale), Math.ceil(minutesToEnd), recipientLocale, ); @@ -672,7 +701,7 @@ export async function handleReminderCheck( currentPhase, med.name, doseInfo, - timeWindow, + timeWindow(locale), Math.ceil(minutesToEnd), locale, );