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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 19:58:15 +01:00
1 parent 71fe874ef2
commit a28c6b55ab
2 files changed
+81 -3

No files matched your search

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