fix(reminders): read a first-due date sent as a date as that day in the user's zone

A reminder's first-due date could only be sent as a date-time, and the
web form sent the browser's local midnight, so a browser set to a zone
other than the profile's could move the day. The API now also takes a
bare calendar date and stores it as that day's midnight in the user's
zone, and the web form sends the date itself. A date-time still means the
instant it names.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 22:19:49 +02:00
1 parent b6b51fcb5e
commit 9311337997
8 files changed
+101 -20

No files matched your search

+14 -6
View File
@@ -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
@@ -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);
+5 -1
View File
@@ -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<Response> {
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).
@@ -181,11 +181,13 @@ function toDateInputValue(iso: string | null): string {
return new Date(d.getTime() - offset * 60 * 1000).toISOString().slice(0, 10);
}
/** `<input type="date">` value → an ISO instant at local midnight, or null. */
/**
* `<input type="date">` 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 {
@@ -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"),
);
});
});
@@ -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);
@@ -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);
});
});
+11 -2
View File
@@ -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(),