mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Merge branch 'release/v1.43.0' into v143/fixui
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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) ─────────────────────────────── */
|
||||
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) ─────────────────────────────── */
|
||||
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
/**
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)."),
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user