Merge branch 'release/v1.43.0' into v143/fixui

This commit is contained in:
Marc-André Bombeck committed 2026-10-16 05:55:14 +02:00
commit beb7ef4ccc
93 files changed
+4369 -3956

No files matched your search

@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from "vitest";
import {
DATETIME_NO_SECONDS_MESSAGE,
isoDateTime,
registerDatetimeNoSecondsObserver,
} from "@/lib/validations/datetime";
describe("isoDateTime", () => {
it("accepts an instant with seconds, with or without fraction and offset", () => {
const s = isoDateTime();
for (const v of [
"2026-10-15T06:15:00Z",
"2026-10-15T06:15:00.000Z",
"2026-10-15T06:15:00.123456Z",
"2026-10-15T06:15:00+02:00",
]) {
expect(s.safeParse(v).success, v).toBe(true);
}
});
it("refuses a minute-precision instant and says seconds are missing", () => {
for (const v of ["2026-10-15T06:15Z", "2026-10-15T06:15+02:00"]) {
const r = isoDateTime().safeParse(v);
expect(r.success, v).toBe(false);
expect(r.error?.issues[0].message).toBe(DATETIME_NO_SECONDS_MESSAGE);
}
});
it("keeps the caller's message for every other refusal", () => {
const s = isoDateTime({ offset: false, message: "takenAt is bad" });
expect(s.safeParse("yesterday").error?.issues[0].message).toBe(
"takenAt is bad",
);
expect(s.safeParse("2026-10-15T06:15Z").error?.issues[0].message).toBe(
DATETIME_NO_SECONDS_MESSAGE,
);
});
it("offset: false accepts only Z and does not count an offset form", () => {
const s = isoDateTime({ offset: false });
expect(s.safeParse("2026-10-15T06:15:00Z").success).toBe(true);
expect(s.safeParse("2026-10-15T06:15:00+02:00").success).toBe(false);
expect(
s.safeParse("2026-10-15T06:15+02:00").error?.issues[0].message,
).not.toBe(DATETIME_NO_SECONDS_MESSAGE);
});
it("tells the observer about minute-precision refusals only", () => {
const seen = vi.fn();
registerDatetimeNoSecondsObserver(seen);
const s = isoDateTime();
s.safeParse("2026-10-15T06:15Z");
s.safeParse("not a date");
s.safeParse("2026-10-15T06:15:00Z");
expect(seen).toHaveBeenCalledTimes(1);
});
});
+2 -1
View File
@@ -13,6 +13,7 @@
import { z } from "zod/v4";
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
import { isoDateTime } from "@/lib/validations/datetime";
/**
* How long a value naming an account may be.
@@ -41,7 +42,7 @@ export const inviteGrantSchema = z
* ends it — the common household case, where an expiry the owner has to
* remember to renew is worse than one that does not exist.
*/
expiresAt: z.iso.datetime({ offset: true }).nullable().optional(),
expiresAt: isoDateTime().nullable().optional(),
/**
* What the invitation offers, and the ONLY place the level is defaulted.
*
+6 -4
View File
@@ -14,17 +14,19 @@
*/
import { z } from "zod/v4";
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
import { isoDateTime } from "@/lib/validations/datetime";
/**
* Plausible-instant bound shared with the measurement / illness paths: no
* future instant beyond the 5-min skew, no instant before 1900. Stays a
* string on the wire (iOS reads it back verbatim).
*/
const boundedInstant = z.iso
.datetime({ offset: true })
.refine((s) => isPlausibleEntryInstant(new Date(s)), {
const boundedInstant = isoDateTime().refine(
(s) => isPlausibleEntryInstant(new Date(s)),
{
message: "must be a plausible instant (not future, not pre-1900)",
});
},
);
/* ── enums (mirror the Prisma enums) ─────────────────────────────── */
+4 -4
View File
@@ -22,6 +22,7 @@
import { z } from "zod/v4";
import { reportSelectionSchema } from "@/lib/report-selection/selection";
import { isoDateTime } from "@/lib/validations/datetime";
/**
* The one leaf a share link may never carry. The share view has never
@@ -56,8 +57,8 @@ export const SHARE_LINK_MAX_DOCUMENTS = 50;
export const createShareLinkSchema = z
.object({
label: z.string().trim().min(1).max(120),
rangeStart: z.iso.datetime({ offset: true }),
rangeEnd: z.iso.datetime({ offset: true }).nullable().optional(),
rangeStart: isoDateTime(),
rangeEnd: isoDateTime().nullable().optional(),
selection: reportSelectionSchema.optional(),
documentIds: z
.array(z.string().trim().min(1).max(64))
@@ -72,8 +73,7 @@ export const createShareLinkSchema = z
* server-side so a document link can never widen into a record.
*/
documentOnly: z.boolean().optional(),
expiresAt: z.iso
.datetime({ offset: true })
expiresAt: isoDateTime()
.refine((v) => new Date(v).getTime() > Date.now(), {
message: "expiresAt must be in the future",
})
+2 -1
View File
@@ -16,6 +16,7 @@
import { z } from "zod/v4";
import { COACH_PLAN_STATUSES } from "@/lib/ai/coach/plans";
import { isoDateTime } from "@/lib/validations/datetime";
/**
* Optional `?scope=` filter on the list endpoint — a named status group so
@@ -48,7 +49,7 @@ export const coachPlansListQuerySchema = z
export const coachPlanPatchSchema = z
.object({
status: z.enum(COACH_PLAN_STATUSES).optional(),
reviewDate: z.iso.datetime({ offset: true }).nullable().optional(),
reviewDate: isoDateTime().nullable().optional(),
})
.strict()
.refine(
+2 -1
View File
@@ -1,4 +1,5 @@
import { z } from "zod/v4";
import { isoDateTime } from "@/lib/validations/datetime";
/**
* v1.4.40 SB-10 — AI consent receipts (App-Store Guideline 5.1.2(i) +
@@ -51,7 +52,7 @@ export const consentPostBody = z.object({
.refine((value) => Buffer.byteLength(value, "utf8") <= ARTEFACT_MAX_BYTES, {
message: "artefact exceeds 64 KB cap (UTF-8 byte length)",
}),
signedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
signedAt: isoDateTime().transform((s) => new Date(s)),
});
export type ConsentPostBody = z.infer<typeof consentPostBody>;
+5 -4
View File
@@ -17,6 +17,7 @@
import { z } from "zod/v4";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
import { isoDateTime } from "@/lib/validations/datetime";
/** Trimmed, bounded free-text field with a min-length guard. */
const requiredText = (max: number) => z.string().trim().min(1).max(max);
@@ -98,10 +99,10 @@ const ENTRY_MAX_AGE_MS = 50 * 365.25 * 24 * 60 * 60 * 1000;
/** Shared `measuredAt` field: ISO string → Date, plausibility-bounded. */
const measuredAtField = validateEntryInstant(
z
.string()
.datetime({ message: "measuredAt must be an ISO 8601 datetime" })
.transform((s) => new Date(s)),
isoDateTime({
offset: false,
message: "measuredAt must be an ISO 8601 datetime",
}).transform((s) => new Date(s)),
{
maxAgeMs: ENTRY_MAX_AGE_MS,
pastMessage: "Measurement date is too far in the past",
+6 -4
View File
@@ -12,6 +12,7 @@
import { z } from "zod/v4";
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
import { assertStableExternalId } from "@/lib/validations/external-id";
import { isoDateTime } from "@/lib/validations/datetime";
/**
* v1.17 W1b — `loggedAt` instant on the cycle wire stays a string (the iOS
@@ -21,11 +22,12 @@ import { assertStableExternalId } from "@/lib/validations/external-id";
* the measurement / mood / medication-intake paths use: no future instant
* beyond the 5-min skew, no instant before 1900.
*/
const boundedLoggedAt = z.iso
.datetime({ offset: true })
.refine((s) => isPlausibleEntryInstant(new Date(s)), {
const boundedLoggedAt = isoDateTime().refine(
(s) => isPlausibleEntryInstant(new Date(s)),
{
message: "loggedAt must be a plausible instant (not future, not pre-1900)",
});
},
);
/* ── enums (mirror the Prisma enums) ─────────────────────────────── */
+89
View File
@@ -0,0 +1,89 @@
/**
* The one ISO 8601 instant validator every schema uses.
*
* Since v1.43 an instant must carry seconds: `2026-10-15T06:15:00Z`,
* `2026-10-15T06:15:00.000Z` and `2026-10-15T06:15:00+02:00` pass,
* `2026-10-15T06:15Z` is refused. zod 4.5 made that the behaviour of
* `z.iso.datetime()`; routing every call site through this helper keeps the
* rule in one place, gives the refusal a message that says what is missing,
* and counts each refusal so a client that still sends minute-precision
* instants shows up in the logs before its users do.
*
* `src/__tests__/datetime-helper-guard.test.ts` forbids a direct
* `z.iso.datetime(` or `z.string().datetime(` anywhere else under `src/`.
*
* Client-safe: the counter goes through an observer the server logging module
* registers on import (the same pattern as `registerSourcePriorityParseObserver`),
* so a form that validates with these schemas never pulls `node:async_hooks`
* into the browser bundle.
*/
import { z } from "zod/v4";
/** Wide-event meta key: how many instants this request refused for lacking seconds. */
export const DATETIME_NO_SECONDS_COUNTER = "validation.datetime.no_seconds";
/** Message for an instant that would be valid if it carried seconds. */
export const DATETIME_NO_SECONDS_MESSAGE =
"Invalid ISO datetime: seconds are required (YYYY-MM-DDTHH:mm:ssZ)";
// Date, hours and minutes, then an optional zone and nothing else: the shape a
// minute-precision encoder produces. Only this shape counts, so the counter
// measures exactly the clients the seconds rule turned away and not every
// malformed string. A field that requires `Z` does not count an offset form,
// which it would refuse with seconds too.
const MINUTE_PRECISION_OFFSET =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?:Z|[+-]\d{2}:\d{2})$/;
const MINUTE_PRECISION_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/;
type NoSecondsObserver = () => void;
let noSecondsObserver: NoSecondsObserver = () => {};
/**
* Server-side opt-in for the refusal counter. Called once from
* `@/lib/logging/context`; the browser keeps the no-op default.
*/
export function registerDatetimeNoSecondsObserver(
observer: NoSecondsObserver,
): void {
noSecondsObserver = observer;
}
export interface IsoDateTimeOptions {
/**
* Accept a numeric UTC offset (`+02:00`) besides `Z`. Default `true`;
* `false` keeps the handful of fields that have always required `Z`.
*/
offset?: boolean;
/** Message for every other refusal; zod's default when omitted. */
message?: string;
}
/**
* An ISO 8601 instant with seconds. Use it for every timestamp a schema
* accepts or publishes; chain `.optional()`, `.nullable()`, `.describe()` and
* `.meta()` as on any zod string.
*/
export function isoDateTime(options: IsoDateTimeOptions = {}) {
const { offset = true, message } = options;
const minutePrecision = offset
? MINUTE_PRECISION_OFFSET
: MINUTE_PRECISION_UTC;
return (
z.iso
.datetime({
offset,
// zod builds issue messages lazily, when a caller first reads them,
// so this callback only words the refusal; it cannot count it.
error: (issue) =>
typeof issue.input === "string" && minutePrecision.test(issue.input)
? DATETIME_NO_SECONDS_MESSAGE
: message,
})
// The count runs here instead: a refinement runs on every parse of a
// string, even after the format check failed, and adds no issue.
.refine((value) => {
if (minutePrecision.test(value)) noSecondsObserver();
return true;
})
);
}
+4 -3
View File
@@ -18,6 +18,7 @@
* park a row a century out.
*/
import { z } from "zod/v4";
import { isoDateTime } from "@/lib/validations/datetime";
/** Ten years out. Long enough for any real appointment, short enough that a
* mistyped year lands as a 422 rather than as a row nobody sees again. */
@@ -30,7 +31,7 @@ const MAX_FUTURE_MS = 10 * 365 * 24 * 60 * 60 * 1000;
* five-minute clock skew. That bound is right for a measurement, which can only
* describe something that already happened, and wrong here.
*/
const visitInstant = z.iso.datetime({ offset: true }).refine(
const visitInstant = isoDateTime().refine(
(value) => {
const at = new Date(value).getTime();
if (Number.isNaN(at)) return false;
@@ -150,8 +151,8 @@ export type EncounterUpdate = z.infer<typeof encounterUpdateSchema>;
*/
export const encounterListQuerySchema = z
.object({
from: z.iso.datetime({ offset: true }).optional(),
to: z.iso.datetime({ offset: true }).optional(),
from: isoDateTime().optional(),
to: isoDateTime().optional(),
status: encounterStatusEnum.optional(),
practitionerId: id.optional(),
/**
+4 -3
View File
@@ -1,4 +1,5 @@
import { z } from "zod/v4";
import { isoDateTime } from "@/lib/validations/datetime";
export const HEALTH_PROFILE_AI_SECTIONS = [
"ABOUT_ME",
@@ -116,11 +117,11 @@ export const healthProfileFactDtoSchema = z.object({
kind: healthProfileFactKindSchema,
value: z.string().nullable(),
unreadable: z.boolean(),
validFrom: z.iso.datetime({ offset: true }),
validUntil: z.iso.datetime({ offset: true }).nullable(),
validFrom: isoDateTime(),
validUntil: isoDateTime().nullable(),
provenance: z.enum(["USER_REPORTED", "USER_CORRECTION"]),
supersededByRevisionId: z.string().nullable(),
createdAt: z.iso.datetime({ offset: true }),
createdAt: isoDateTime(),
});
export const healthProfileFactsResponseSchema = z.object({
+3 -2
View File
@@ -22,14 +22,15 @@ import { z } from "zod/v4";
import { locales } from "@/lib/i18n/config";
import { reportSelectionSchema } from "@/lib/report-selection/selection";
import { isoDateTime } from "@/lib/validations/datetime";
export const exportSelectionSchema = z
.object({
format: z.enum(["pdf", "fhir", "package"]),
range: z
.object({
startDate: z.iso.datetime({ offset: true }).optional(),
endDate: z.iso.datetime({ offset: true }).optional(),
startDate: isoDateTime().optional(),
endDate: isoDateTime().optional(),
days: z.number().int().min(1).max(365).optional(),
})
.strict()
+8 -6
View File
@@ -16,17 +16,19 @@
import { z } from "zod/v4";
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
import { lateralityEnum } from "@/lib/validations/encounters";
import { isoDateTime } from "@/lib/validations/datetime";
/**
* Plausible-instant bound shared with the measurement / mood / cycle
* paths: no future instant beyond the 5-min skew, no instant before 1900.
* Stays a string on the wire (iOS reads it back verbatim).
*/
const boundedInstant = z.iso
.datetime({ offset: true })
.refine((s) => isPlausibleEntryInstant(new Date(s)), {
const boundedInstant = isoDateTime().refine(
(s) => isPlausibleEntryInstant(new Date(s)),
{
message: "must be a plausible instant (not future, not pre-1900)",
});
},
);
const dateString = z
.string()
@@ -141,8 +143,8 @@ export const illnessEpisodeListQuerySchema = z.object({
* episodes around a visit and pages older or newer from there.
*/
offset: z.coerce.number().int().min(0).max(100_000).optional(),
from: z.iso.datetime({ offset: true }).optional(),
to: z.iso.datetime({ offset: true }).optional(),
from: isoDateTime().optional(),
to: isoDateTime().optional(),
/** Onset order; newest first by default. */
sortDir: z.enum(["asc", "desc"]).optional(),
/** Case-insensitive substring over the episode's label. */
+5 -4
View File
@@ -22,6 +22,7 @@ import { z } from "zod/v4";
import type { WrittenOutcome } from "@/lib/outcome/written-outcome";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
import { isoDateTime } from "@/lib/validations/datetime";
/** Max rows a single scan / commit may carry — a dense panel is ~30 analytes. */
export const OCR_MAX_ROWS = 100;
@@ -34,10 +35,10 @@ const LAB_TAKEN_AT_MAX_AGE_MS = 50 * 365.25 * 24 * 60 * 60 * 1000;
/** Shared committed-row `takenAt`: ISO string → Date, plausibility-bounded. */
const takenAtField = validateEntryInstant(
z
.string()
.datetime({ message: "takenAt must be an ISO 8601 datetime" })
.transform((s) => new Date(s)),
isoDateTime({
offset: false,
message: "takenAt must be an ISO 8601 datetime",
}).transform((s) => new Date(s)),
{
maxAgeMs: LAB_TAKEN_AT_MAX_AGE_MS,
pastMessage: "Sample date is too far in the past",
+7 -10
View File
@@ -16,6 +16,7 @@ import { z } from "zod/v4";
import { SOURCE_REFERENCE_TEXT_MAX } from "@/lib/labs/parse-reference-range";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
import { isoDateTime } from "@/lib/validations/datetime";
/**
* Far-past floor for a lab sample. A self-hoster legitimately backdates an
@@ -26,10 +27,10 @@ const LAB_TAKEN_AT_MAX_AGE_MS = 50 * 365.25 * 24 * 60 * 60 * 1000;
/** Shared `takenAt` field: ISO string → Date, plausibility-bounded. */
const takenAtField = validateEntryInstant(
z
.string()
.datetime({ message: "takenAt must be an ISO 8601 datetime" })
.transform((s) => new Date(s)),
isoDateTime({
offset: false,
message: "takenAt must be an ISO 8601 datetime",
}).transform((s) => new Date(s)),
{
maxAgeMs: LAB_TAKEN_AT_MAX_AGE_MS,
pastMessage: "Sample date is too far in the past",
@@ -289,14 +290,10 @@ export const listLabResultsSchema = z.object({
* picker can find one result in a long history without paging to it.
*/
q: z.string().trim().min(1).max(120).optional(),
from: z
.string()
.datetime()
from: isoDateTime({ offset: false })
.transform((s) => new Date(s))
.optional(),
to: z
.string()
.datetime()
to: isoDateTime({ offset: false })
.transform((s) => new Date(s))
.optional(),
limit: z.coerce.number().int().min(1).max(500).default(100),
+6 -5
View File
@@ -18,6 +18,7 @@ import {
SMOKING_STATUS_VALUES,
isHealthProfileFactValue,
} from "@/lib/validations/health-profile-facts";
import { isoDateTime } from "@/lib/validations/datetime";
// ── Fact kinds and their values ──────────────────────────────────────────────
@@ -189,7 +190,7 @@ export type LifeAiSection = (typeof LIFE_AI_SECTIONS)[number];
export const lifeAiSectionsPatchSchema = z
.strictObject({
sections: z.partialRecord(z.enum(LIFE_AI_SECTIONS), z.boolean()),
baseUpdatedAt: z.iso.datetime({ offset: true }).optional(),
baseUpdatedAt: isoDateTime().optional(),
})
.refine((value) => Object.keys(value.sections).length > 0, {
path: ["sections"],
@@ -212,7 +213,7 @@ const auditCItem = z.number().int().min(0).max(4);
export const auditCRequestSchema = z
.strictObject({
items: z.tuple([auditCItem, auditCItem, auditCItem]),
takenAt: z.iso.datetime({ offset: true }).optional(),
takenAt: isoDateTime().optional(),
tz: z.string().min(1).max(64).optional(),
locale: z.string().min(2).max(16).optional(),
source: z.enum(["WEB", "IOS"]),
@@ -343,7 +344,7 @@ export type LifeFactValue =
export const removedFactSchema = z.object({
id: z.string(),
kind: z.enum(STORED_FACT_KINDS),
removedAt: z.iso.datetime({ offset: true }),
removedAt: isoDateTime(),
});
/**
@@ -362,10 +363,10 @@ export const lifeFactRevisionSchema = z.object({
unreadable: z
.boolean()
.describe("The sealed value could not be opened on this host."),
validFrom: z.iso.datetime({ offset: true }),
validFrom: isoDateTime(),
validUntil: z.null(),
provenance: z.enum(["USER_REPORTED", "USER_CORRECTION"]),
createdAt: z.iso.datetime({ offset: true }),
createdAt: isoDateTime(),
});
/**
+2 -1
View File
@@ -13,6 +13,7 @@
import { z } from "zod/v4";
import { isValidTimezone } from "@/lib/tz/format";
import { isoDateTime } from "@/lib/validations/datetime";
/**
* The record's sex, as `User.gender` already stores it.
@@ -84,6 +85,6 @@ export const updateManagedProfileSchema = z
export const inviteManagedProfileGuardianSchema = z
.object({
identifier: z.string().trim().min(1).max(255),
expiresAt: z.iso.datetime({ offset: true }).nullable().optional(),
expiresAt: isoDateTime().nullable().optional(),
})
.strict();
+12 -14
View File
@@ -13,6 +13,7 @@
* recomputes).
*/
import { z } from "zod/v4";
import { isoDateTime } from "@/lib/validations/datetime";
/**
* The auto-resolve target types. Kept as an explicit allow-list rather
@@ -147,10 +148,7 @@ 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 }),
]);
const anchorDateField = z.union([z.iso.date(), isoDateTime()]);
export const createMeasurementReminderSchema = z
.object({
@@ -212,19 +210,19 @@ export const measurementReminderDto = z
measurementType: measurementReminderTypeEnum.nullable(),
intervalDays: z.number().int().nullable(),
rrule: z.string().nullable(),
anchorDate: z.iso.datetime({ offset: true }).nullable(),
endsOn: z.iso.datetime({ offset: true }).nullable(),
anchorDate: isoDateTime().nullable(),
endsOn: isoDateTime().nullable(),
origin: z.enum(["VORSORGE", "COACH"]),
notifyHour: z.number().int(),
location: z.string().nullable(),
nextDueAt: z.iso.datetime({ offset: true }).nullable(),
lastSatisfiedAt: z.iso.datetime({ offset: true }).nullable(),
snoozedUntil: z.iso.datetime({ offset: true }).nullable(),
lastSkippedAt: z.iso.datetime({ offset: true }).nullable(),
nextDueAt: isoDateTime().nullable(),
lastSatisfiedAt: isoDateTime().nullable(),
snoozedUntil: isoDateTime().nullable(),
lastSkippedAt: isoDateTime().nullable(),
skipCount: z.number().int(),
enabled: z.boolean(),
createdAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso.datetime({ offset: true }),
createdAt: isoDateTime(),
updatedAt: isoDateTime(),
})
.meta({
id: "MeasurementReminderDTO",
@@ -310,7 +308,7 @@ export const measurementReminderEventDto = z
.object({
id: z.string(),
kind: z.enum(["SATISFIED", "SKIPPED"]),
occurredAt: z.iso.datetime({ offset: true }),
occurredAt: isoDateTime(),
onTime: z.boolean(),
source: z.enum([
"manual",
@@ -321,7 +319,7 @@ export const measurementReminderEventDto = z
"encounter",
"skip",
]),
createdAt: z.iso.datetime({ offset: true }),
createdAt: isoDateTime(),
})
.meta({
id: "MeasurementReminderEvent",
+5 -6
View File
@@ -2,6 +2,7 @@ import { z } from "zod/v4";
import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source";
import { validateEntryInstant } from "./entry-instant";
import { isCalendarDateKey } from "@/lib/tz/date-only";
import { isoDateTime } from "@/lib/validations/datetime";
export const measurementTypeEnum = z.enum([
"WEIGHT",
@@ -551,7 +552,7 @@ export const createMeasurementSchema = z
// 1900. Closes the data-portability gap where a hand-rolled POST could
// backdate or forward-date a reading arbitrarily.
measuredAt: validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
isoDateTime().transform((s) => new Date(s)),
),
notes: z.string().max(MEASUREMENT_NOTES_MAX_LENGTH).optional(),
// v1.10.0 QA — validate against the client-writable subset, not the full
@@ -611,7 +612,7 @@ export const updateMeasurementSchema = z.object({
// v1.17 W1b — same plausibility bound on the edit path; an edit cannot
// forward-date a reading into the future nor before 1900.
measuredAt: validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
isoDateTime().transform((s) => new Date(s)),
).optional(),
notes: z
.string()
@@ -645,12 +646,10 @@ export const listPersonalRecordsSchema = z.object({
export const listMeasurementsSchema = z
.object({
type: measurementTypeEnum.optional(),
from: z.iso
.datetime({ offset: true })
from: isoDateTime()
.transform((s) => new Date(s))
.optional(),
to: z.iso
.datetime({ offset: true })
to: isoDateTime()
.transform((s) => new Date(s))
.optional(),
// v1.4.28 FB-D2 — when the chart sends an explicit from/to window the
+2 -1
View File
@@ -3,6 +3,7 @@ import { z } from "zod/v4";
import { INJECTION_SITE_KEYS } from "@/lib/medications/injection-sites";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
import { hhmmToMinutes } from "@/lib/medications/scheduling/hhmm";
import { isoDateTime } from "@/lib/validations/datetime";
/**
* v1.8.5 — the eight injection-site enum values, mirrored from the
@@ -244,7 +245,7 @@ export const TAKEN_AT_MAX_AGE_MS = 5 * 365 * 24 * 60 * 60 * 1000;
* 1900 floor. The 5-min clock-skew tolerance is the shared default.
*/
export const boundedTakenAtSchema = validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
isoDateTime().transform((s) => new Date(s)),
{
maxAgeMs: TAKEN_AT_MAX_AGE_MS,
// Preserve the established wording so the iOS contract + tests stay stable.
+8 -14
View File
@@ -2,6 +2,7 @@ import { z } from "zod/v4";
import { boundedTakenAtSchema, injectionSiteEnum } from "./base";
import { assertStableIdempotencyKey } from "@/lib/validations/external-id";
import { isoDateTime } from "@/lib/validations/datetime";
export const intakeSchema = z
.object({
@@ -11,8 +12,7 @@ export const intakeSchema = z
.describe(
"Server-narrowed from the URL path. The route layer overwrites whatever the body supplies before Zod parsing so a caller cannot log an intake against another medication.",
),
scheduledFor: z.iso
.datetime({ offset: true })
scheduledFor: isoDateTime()
.transform((s) => new Date(s))
.optional()
.describe(
@@ -60,8 +60,7 @@ export const intakeSchema = z
* server validates it against the band anchors); an arbitrary instant is
* rejected with 422. Absent → default band attribution.
*/
forceSlotInstant: z.iso
.datetime({ offset: true })
forceSlotInstant: isoDateTime()
.transform((s) => new Date(s))
.optional()
.describe(
@@ -149,8 +148,7 @@ export const updateIntakeEventSchema = z
.object({
takenAt: boundedTakenAtSchema.nullable().optional(),
skipped: z.boolean().optional(),
scheduledFor: z.iso
.datetime({ offset: true })
scheduledFor: isoDateTime()
.transform((s) => new Date(s))
.optional(),
/**
@@ -165,8 +163,7 @@ export const updateIntakeEventSchema = z
* resets to AUTO — the "Zuordnung lösen" path. A pin / unpin no longer
* requires `takenAt` or `skipped` to change in the same request.
*/
forceSlotInstant: z.iso
.datetime({ offset: true })
forceSlotInstant: isoDateTime()
.transform((s) => new Date(s))
.nullable()
.optional()
@@ -242,15 +239,13 @@ export const intakeStatusUpdateSchema = z.object({
.describe(
"`snoozed` writes the deferral onto the MEDICATION row rather than the event, so it defers every pending dose of that medication and not just this one.",
),
takenAt: z.iso
.datetime({ offset: true })
takenAt: isoDateTime()
.transform((s) => new Date(s))
.optional()
.describe(
"When the dose was taken. Defaults to now. Ignored unless the status is `taken`.",
),
snoozedUntil: z.iso
.datetime({ offset: true })
snoozedUntil: isoDateTime()
.transform((s) => new Date(s))
.optional()
.describe(
@@ -261,8 +256,7 @@ export const intakeStatusUpdateSchema = z.object({
.describe(
"Site recorded alongside a `taken` toggle, validated server-side against the medication's effective allowed set. Dropped for a non-injection medication, for one with site tracking off, and for a skip or snooze.",
),
forceSlotInstant: z.iso
.datetime({ offset: true })
forceSlotInstant: isoDateTime()
.transform((s) => new Date(s))
.optional()
.describe(
+6 -10
View File
@@ -1,4 +1,5 @@
import { z } from "zod/v4";
import { isoDateTime } from "@/lib/validations/datetime";
/**
* v1.4.25 W19b — inventory (pen / vial) CRUD validators.
@@ -47,13 +48,11 @@ export const createInventoryItemSchema = z
.describe(
"Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Defaults to OTHER. PEN and AMPOULE are the sealed reservoirs the first dose breaches, so they carry a 30-day post-opening window; every other kind expires only on its printed date.",
),
printedExpiry: z.iso
.datetime({ offset: true })
printedExpiry: isoDateTime()
.transform((s) => new Date(s))
.nullable()
.optional(),
purchasedAt: z.iso
.datetime({ offset: true })
purchasedAt: isoDateTime()
.transform((s) => new Date(s))
.nullable()
.optional(),
@@ -85,8 +84,7 @@ export const createInventoryItemSchema = z
export const updateInventoryItemSchema = z
.object({
markAsFirstUseAt: z.iso
.datetime({ offset: true })
markAsFirstUseAt: isoDateTime()
.transform((s) => new Date(s))
.nullable()
.optional()
@@ -99,8 +97,7 @@ export const updateInventoryItemSchema = z
.describe(
"Terminal override: zero the remaining units and mark the container USED_UP (physically discarded).",
),
printedExpiry: z.iso
.datetime({ offset: true })
printedExpiry: isoDateTime()
.transform((s) => new Date(s))
.nullable()
.optional(),
@@ -183,8 +180,7 @@ const MIN_EFFECTIVE_FROM = new Date("2020-01-01T00:00:00Z");
const FIVE_YEARS_MS = 5 * 365 * 24 * 60 * 60 * 1000;
export const glp1DoseChangePostSchema = z.object({
effectiveFrom: z.iso
.datetime({ offset: true })
effectiveFrom: isoDateTime()
.transform((s) => new Date(s))
.refine((d) => d.getTime() >= MIN_EFFECTIVE_FROM.getTime(), {
message: "effectiveFrom must be on or after 2020-01-01",
+2 -1
View File
@@ -13,6 +13,7 @@ import {
} from "@/lib/mental-health/instruments";
import { assertStableExternalId } from "@/lib/validations/external-id";
import { isValidTimezone } from "@/lib/tz/format";
import { isoDateTime } from "@/lib/validations/datetime";
export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7", "WHO5", "SCI"]);
@@ -53,7 +54,7 @@ export const createAssessmentSchema = z
/** Optional functional-impairment follow-up (not scored into the total). */
functionalDifficulty: z.number().int().min(0).max(3).optional(),
/** ISO instant the screener was taken; defaults to now server-side. */
takenAt: z.string().datetime().optional(),
takenAt: isoDateTime({ offset: false }).optional(),
tz: z
.string()
.max(64)
+4 -5
View File
@@ -23,6 +23,7 @@ import {
LEISURE_CATEGORY_KEYS,
WORK_STATUS_KEYS,
} from "@/lib/mood/context-vocabulary";
import { isoDateTime } from "@/lib/validations/datetime";
// --- CRUD schemas for mood entries ---
@@ -192,9 +193,7 @@ export const moodContextSchema = z.object({
.optional(),
// The same plausibility bound the entry's own instant carries: no future
// instants beyond the clock-skew tolerance, nothing before 1900.
eventAt: validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
)
eventAt: validateEntryInstant(isoDateTime().transform((s) => new Date(s)))
.nullable()
.optional(),
// Capped like the entry note beside it. One note for the whole context.
@@ -242,7 +241,7 @@ export const createMoodEntrySchema = z
// future instants beyond a 5-min clock-skew tolerance, no instant before
// 1900. Mirrors the measurement + medication-intake bound.
moodLoggedAt: validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
isoDateTime().transform((s) => new Date(s)),
),
source: moodSourceEnum.optional().default("MANUAL"),
// v1.12.1 — optional source-stable id (e.g. an iOS SwiftData row UUID).
@@ -285,7 +284,7 @@ export const updateMoodEntrySchema = z.object({
context: moodContextSchema.nullable().optional(),
// v1.17 W1b — same plausibility bound on the edit path.
moodLoggedAt: validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
isoDateTime().transform((s) => new Date(s)),
).optional(),
});
+2 -1
View File
@@ -14,6 +14,7 @@ import {
NUTRIENT_CODES,
SYNCABLE_NUTRIENT_CODES,
} from "@/lib/nutrients/catalog";
import { isoDateTime } from "@/lib/validations/datetime";
export const MAX_NUTRIENT_ENTRIES_PER_BATCH = 500;
@@ -170,7 +171,7 @@ export const nutrientOverviewSchema = z
),
lastAttempt: z
.object({
at: z.iso.datetime({ offset: true }),
at: isoDateTime(),
topReason: z.string(),
})
.nullable()
+7 -8
View File
@@ -14,6 +14,7 @@
* minter reads it exactly like a write-path archive.
*/
import { z } from "zod/v4";
import { isoDateTime } from "@/lib/validations/datetime";
/** HH:mm wall-clock literal — mirrors `validations/medication.ts`. */
const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/;
@@ -21,20 +22,18 @@ const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/;
export const scheduleRevisionCreateSchema = z
.object({
/** Inclusive start instant of the manual era. */
validFrom: z.iso
.datetime({ offset: true })
.describe("Inclusive start instant of the manual era (ISO 8601)."),
validFrom: isoDateTime().describe(
"Inclusive start instant of the manual era (ISO 8601).",
),
/**
* Exclusive end instant — the moment the next plan took over. Must
* lie after `validFrom` and at or before the start of the live
* schedule era (the newest archived revision's `validUntil`, or
* "now" when no revision exists yet).
*/
validUntil: z.iso
.datetime({ offset: true })
.describe(
"Exclusive end instant of the manual era (ISO 8601). Must precede the current plan.",
),
validUntil: isoDateTime().describe(
"Exclusive end instant of the manual era (ISO 8601). Must precede the current plan.",
),
/**
* Daily dose times the era ran at. Same cap as live schedules.
* Duplicates collapse and the result sorts ascending — "07:00,
+3 -4
View File
@@ -7,6 +7,7 @@
*/
import { z } from "zod/v4";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { isoDateTime } from "@/lib/validations/datetime";
/** Upper bound on the types per batch — the dashboard never asks for more. */
export const SERIES_BATCH_MAX_TYPES = 16;
@@ -28,12 +29,10 @@ export const seriesBatchQuerySchema = z
`${SERIES_BATCH_MAX_TYPES}). Each is read as a daily series through ` +
"the rollup tier.",
),
from: z.iso
.datetime({ offset: true })
from: isoDateTime()
.transform((s) => new Date(s))
.describe("Window start (ISO-8601 instant)."),
to: z.iso
.datetime({ offset: true })
to: isoDateTime()
.transform((s) => new Date(s))
.describe("Window end (ISO-8601 instant)."),
})
+2 -3
View File
@@ -23,6 +23,7 @@ import {
validateMeasurementRange,
writableMeasurementSourceEnum,
} from "./measurement";
import { isoDateTime } from "@/lib/validations/datetime";
/** The stage names the `SleepStage` column stores, as a caller writes them. */
export const SLEEP_STAGE_NAMES = [
@@ -40,9 +41,7 @@ export type SleepStageName = (typeof SLEEP_STAGE_NAMES)[number];
const VALUE_TOLERANCE_MINUTES = 1;
const instant = () =>
validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
);
validateEntryInstant(isoDateTime().transform((s) => new Date(s)));
export const sleepStageEntrySchema = z
.object({
+5 -4
View File
@@ -29,6 +29,7 @@ import {
ANTIGEN_SLUGS,
VACCINE_CATALOG_SLUGS,
} from "@/lib/vaccinations/vaccine-catalog";
import { isoDateTime } from "@/lib/validations/datetime";
const KNOWN_SLUGS: ReadonlySet<string> = new Set(VACCINE_CATALOG_SLUGS);
@@ -43,7 +44,7 @@ const KNOWN_SLUGS: ReadonlySet<string> = new Set(VACCINE_CATALOG_SLUGS);
*/
const DAY_SKEW_MS = 24 * 60 * 60 * 1000;
const doseInstant = z.iso.datetime({ offset: true }).refine(
const doseInstant = isoDateTime().refine(
(value) => {
const at = new Date(value).getTime();
if (Number.isNaN(at)) return false;
@@ -191,8 +192,8 @@ export type VaccinationUpdate = z.infer<typeof vaccinationUpdateSchema>;
export const vaccinationListQuerySchema = z
.object({
antigenSlug: z.string().min(1).max(64).optional(),
from: z.iso.datetime({ offset: true }).optional(),
to: z.iso.datetime({ offset: true }).optional(),
from: isoDateTime().optional(),
to: isoDateTime().optional(),
limit: z.coerce.number().int().min(1).max(500).optional(),
})
.strict()
@@ -299,7 +300,7 @@ export type VaccinationBoosterInput = z.infer<typeof vaccinationBoosterSchema>;
*/
export const vaccinationSuggestQuerySchema = z
.object({
anchor: z.iso.datetime({ offset: true }),
anchor: isoDateTime(),
})
.strict();
+5 -4
View File
@@ -1,5 +1,6 @@
import { z } from "zod/v4";
import { writableMeasurementSourceEnum } from "./measurement";
import { isoDateTime } from "@/lib/validations/datetime";
/**
* Workout sport-type union.
@@ -84,7 +85,7 @@ export type GeoJsonLineString = z.infer<typeof geoJsonLineStringSchema>;
*/
export const workoutRouteSamplesSchema = z.array(
z.object({
t: z.iso.datetime({ offset: true }),
t: isoDateTime(),
speedMs: z.number().min(0).max(50).optional(),
hr: z.number().int().min(20).max(300).optional(),
}),
@@ -122,7 +123,7 @@ export const MAX_WORKOUT_HR_SAMPLES = 30_000;
export const workoutHrSamplesSchema = z
.array(
z.object({
t: z.iso.datetime({ offset: true }),
t: isoDateTime(),
hr: z.number().int().min(20).max(300).optional(),
speedMs: z.number().min(0).max(50).optional(),
power: z.number().min(0).max(3000).optional(),
@@ -157,8 +158,8 @@ export type WorkoutHrSamples = z.infer<typeof workoutHrSamplesSchema>;
export const createWorkoutSchema = z
.object({
sportType: workoutSportTypeEnum,
startedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
endedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
startedAt: isoDateTime().transform((s) => new Date(s)),
endedAt: isoDateTime().transform((s) => new Date(s)),
totalEnergyKcal: z.number().min(0).max(50000).optional(),
totalDistanceM: z.number().min(0).max(1_000_000).optional(),
avgHeartRate: z.number().int().min(20).max(300).optional(),