diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5953a81d8..a739c5947 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -27454,9 +27454,13 @@ components: - type: "null" anchorDate: anyOf: - - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - anyOf: + - type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" notifyHour: default: 9 @@ -27528,9 +27532,13 @@ components: - type: "null" anchorDate: anyOf: - - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - anyOf: + - type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" notifyHour: type: integer diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts index 39228567a..9922ff88c 100644 --- a/src/app/api/measurement-reminders/[id]/route.ts +++ b/src/app/api/measurement-reminders/[id]/route.ts @@ -29,6 +29,7 @@ import { annotate } from "@/lib/logging/context"; import { updateMeasurementReminderSchema } from "@/lib/validations/measurement-reminders"; import { computeReminderNextDueAt, + parseReminderAnchor, type ReminderScheduleInput, } from "@/lib/measurement-reminders/scheduling"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; @@ -141,9 +142,12 @@ export const PATCH = apiHandler( updateData.intervalDays = null; } } + const timezone = await resolveTimezone(user.id); if (data.anchorDate !== undefined) { updateData.anchorDate = - data.anchorDate != null ? new Date(data.anchorDate) : null; + data.anchorDate != null + ? parseReminderAnchor(data.anchorDate, timezone) + : null; } if (data.notifyHour !== undefined) updateData.notifyHour = data.notifyHour; if (data.location !== undefined) updateData.location = data.location; @@ -166,7 +170,6 @@ export const PATCH = apiHandler( // auto-clear a few lines up — so keying off its own keys (present, even // when the value is `null`) can never diverge from what the database // ends up holding. - const timezone = await resolveTimezone(user.id); const now = new Date(); const merged: ReminderScheduleInput = { intervalDays: Object.hasOwn(updateData, "intervalDays") @@ -193,10 +196,10 @@ export const PATCH = apiHandler( // open, overdue check-up quietly moved it to its next slot. Compared by // value, because a client may resend every field on each save. // - // The first due date is compared by CALENDAR DAY in the profile zone. The - // web form sends a date as the browser's local midnight, and a booster - // row stores the dose instant plus N days, so the same day arrives as a - // different instant. A resent same-day anchor is dropped from the write + // The first due date is compared by CALENDAR DAY in the profile zone. A + // client may send a date-time (older web builds sent the browser's local + // midnight), and a booster row stores the dose instant plus N days, so the + // same day arrives as a different instant. A resent same-day anchor is dropped from the write // altogether, so the stored instant stays what it was. const sameInstant = (a: Date | null, b: Date | null) => (a?.getTime() ?? null) === (b?.getTime() ?? null); diff --git a/src/app/api/measurement-reminders/route.ts b/src/app/api/measurement-reminders/route.ts index 06fe23443..150bbe793 100644 --- a/src/app/api/measurement-reminders/route.ts +++ b/src/app/api/measurement-reminders/route.ts @@ -21,6 +21,7 @@ import { withIdempotency } from "@/lib/idempotency"; import { createMeasurementReminderSchema } from "@/lib/validations/measurement-reminders"; import { computeReminderNextDueAt, + parseReminderAnchor, type ReminderScheduleInput, } from "@/lib/measurement-reminders/scheduling"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; @@ -88,7 +89,10 @@ async function postReminder(request: NextRequest): Promise { const timezone = await resolveTimezone(user.id); const now = new Date(); - const anchorDate = data.anchorDate != null ? new Date(data.anchorDate) : null; + const anchorDate = + data.anchorDate != null + ? parseReminderAnchor(data.anchorDate, timezone) + : null; // Server-authoritative next-due. No satisfy yet → anchors on // anchorDate ?? createdAt (createdAt ≈ now for a fresh row). diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index 1674b2457..f1085e2c5 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -181,11 +181,13 @@ function toDateInputValue(iso: string | null): string { return new Date(d.getTime() - offset * 60 * 1000).toISOString().slice(0, 10); } -/** `` value → an ISO instant at local midnight, or null. */ +/** + * `` value → the calendar date itself, or null. The server + * reads a bare date as that day in the profile's zone, so a browser set to + * another zone cannot move the first-due day. + */ function fromDateInputValue(value: string): string | null { - if (!value) return null; - const d = new Date(`${value}T00:00:00`); - return Number.isNaN(d.getTime()) ? null : d.toISOString(); + return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : null; } interface FormState { diff --git a/src/lib/measurement-reminders/__tests__/scheduling-timezones.test.ts b/src/lib/measurement-reminders/__tests__/scheduling-timezones.test.ts index 560d08bbc..7cd923cc4 100644 --- a/src/lib/measurement-reminders/__tests__/scheduling-timezones.test.ts +++ b/src/lib/measurement-reminders/__tests__/scheduling-timezones.test.ts @@ -9,7 +9,7 @@ import { describe, expect, it } from "vitest"; import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock"; -import { computeReminderNextDueAt } from "../scheduling"; +import { computeReminderNextDueAt, parseReminderAnchor } from "../scheduling"; const ZONES = [ "America/New_York", @@ -75,3 +75,29 @@ describe.each(ZONES)("reminder anchors in %s", (tz) => { expect(localLabel(next, tz)).toBe("2026-09-20 19:00 w0"); }); }); + +describe.each(ZONES)("a first-due date sent as a calendar date in %s", (tz) => { + it("is that day in the user's zone", () => { + const anchor = parseReminderAnchor("2026-10-01", tz); + expect(anchor).toEqual(localMidnight(tz, 2026, 10, 1)); + const next = computeReminderNextDueAt( + { + intervalDays: 30, + rrule: null, + anchorDate: anchor, + notifyHour: 9, + lastSatisfiedAt: null, + createdAt: new Date("2026-09-01T12:00:00Z"), + }, + tz, + new Date("2026-09-26T12:00:00Z"), + ); + expect(localLabel(next, tz)?.slice(0, 16)).toBe("2026-10-01 09:00"); + }); + + it("keeps a date-time as the instant it names", () => { + expect(parseReminderAnchor("2026-10-01T00:00:00.000Z", tz)).toEqual( + new Date("2026-10-01T00:00:00.000Z"), + ); + }); +}); diff --git a/src/lib/measurement-reminders/scheduling.ts b/src/lib/measurement-reminders/scheduling.ts index 2227d0008..49cbf8aaf 100644 --- a/src/lib/measurement-reminders/scheduling.ts +++ b/src/lib/measurement-reminders/scheduling.ts @@ -31,6 +31,7 @@ import { } from "@/lib/medications/scheduling/recurrence"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; /** * The reminder fields this module reads. A subset of the Prisma row so @@ -52,6 +53,18 @@ export interface ReminderScheduleInput { endsOn?: Date | null; } +/** + * A first-due date from a request: a calendar date (`YYYY-MM-DD`) is that + * day in the user's zone, stored as its local midnight; a date-time is the + * instant it names. A date sent as UTC midnight would read as the evening + * before west of UTC, which is why a bare date is accepted at all. + */ +export function parseReminderAnchor(value: string, timeZone: string): Date { + return /^\d{4}-\d{2}-\d{2}$/.test(value) + ? startOfLocalDayKey(value, timeZone || DEFAULT_TIMEZONE) + : new Date(value); +} + /** The calendar day `instant` falls on in `tz`, as UTC midnight of that date. */ function calendarDayInZone(instant: Date, tz: string): Date { const p = wallClockInTz(instant, tz); diff --git a/src/lib/validations/__tests__/measurement-reminders.test.ts b/src/lib/validations/__tests__/measurement-reminders.test.ts index aa013d421..78881ba55 100644 --- a/src/lib/validations/__tests__/measurement-reminders.test.ts +++ b/src/lib/validations/__tests__/measurement-reminders.test.ts @@ -118,3 +118,19 @@ describe("measurementReminderTypeEnum (V3 — completeness)", () => { expect(parsed.success).toBe(true); }); }); + +describe("createMeasurementReminderSchema — first-due date", () => { + const base = { label: "Blood test", intervalDays: 365 }; + it.each([ + ["2026-10-01", true], + ["2026-10-01T00:00:00.000Z", true], + ["2026-10-01T00:00:00-07:00", true], + ["2026-10-01T00:00:00", false], + ["01.10.2026", false], + ])("anchorDate %s is accepted: %s", (anchorDate, ok) => { + expect( + createMeasurementReminderSchema.safeParse({ ...base, anchorDate }) + .success, + ).toBe(ok); + }); +}); diff --git a/src/lib/validations/measurement-reminders.ts b/src/lib/validations/measurement-reminders.ts index 724e2b2d4..a329afa46 100644 --- a/src/lib/validations/measurement-reminders.ts +++ b/src/lib/validations/measurement-reminders.ts @@ -143,13 +143,22 @@ const cadenceRefinement = ( } }; +/** + * The first-due date: a calendar date (`YYYY-MM-DD`, read as that day in the + * user's zone) or a date-time (the instant it names). + */ +const anchorDateField = z.union([ + z.iso.date(), + z.iso.datetime({ offset: true }), +]); + export const createMeasurementReminderSchema = z .object({ label: z.string().trim().min(1).max(120), measurementType: measurementReminderTypeEnum.nullish(), intervalDays: z.number().int().min(1).max(3650).nullish(), rrule: rruleField.nullish(), - anchorDate: z.iso.datetime({ offset: true }).nullish(), + anchorDate: anchorDateField.nullish(), notifyHour: z.number().int().min(0).max(23).default(9), location: z.string().trim().max(200).nullish(), enabled: z.boolean().default(true), @@ -177,7 +186,7 @@ export const updateMeasurementReminderSchema = z measurementType: measurementReminderTypeEnum.nullish(), intervalDays: z.number().int().min(1).max(3650).nullish(), rrule: rruleField.nullish(), - anchorDate: z.iso.datetime({ offset: true }).nullish(), + anchorDate: anchorDateField.nullish(), notifyHour: z.number().int().min(0).max(23).optional(), location: z.string().trim().max(200).nullish(), enabled: z.boolean().optional(),