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, );