mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
b6b51fcb5e
commit
9311337997
8 files changed
+101
-20
No files matched your search
+14
-6
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user