/** * @factory/database-engine/values — date helpers (TASK-0071 "UTC-day math"). * * All database dates are stored as ISO 8601 UTC strings (`YYYY-MM-DD` for * date-only values, full timestamps for times). "Today"/relative resolution * happens in filters (tokens.ts), NOT here — value level rejects tokens. */ export const DAY_MS = 86_400_000; /** Normalize an input to an ISO 8601 UTC string. Throws on invalid. */ export function normalizeDate(input: unknown, withTime = false): string { if (typeof input === "number") return new Date(input).toISOString().slice(0, withTime ? undefined : 10); if (typeof input === "string") { const s = input.trim(); const dayOnly = /^\d{4}-\d{2}-\d{2}$/.test(s); const full = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/.test(s); if (!dayOnly && !full) throw new Error(`invalid date value: ${input}`); const d = new Date(s.length === 10 ? s + "T00:00:00Z" : s); if (Number.isNaN(d.getTime())) throw new Error(`invalid date value: ${input}`); return dayOnly && !withTime ? s : d.toISOString().slice(0, withTime ? undefined : 10); } throw new Error(`invalid date value: ${String(input)}`); } /** Parse an ISO UTC date string (day-only or full) → epoch ms (UTC midnight for day-only). */ export function dateToMs(value: string): number { const d = new Date(value.length === 10 ? value + "T00:00:00Z" : value); if (Number.isNaN(d.getTime())) throw new Error(`invalid date value: ${value}`); return d.getTime(); } /** UTC-day index (days since epoch) — DST-safe day math. */ export function utcDay(value: string): number { return Math.floor(dateToMs(value) / DAY_MS); } /** Add N days in UTC (day-granular, DST-safe). */ export function addDays(value: string, days: number): string { return new Date(utcDay(value) * DAY_MS + days * DAY_MS).toISOString().slice(0, 10); } /** Whole days from a to b (b − a; negative allowed). */ export function daysBetween(a: string, b: string): number { return utcDay(b) - utcDay(a); } /** * Add N calendar units (weeks/months/years) to a date. Month/year overflow * clamps to month-end: 2025-01-31 + 1 month = 2025-02-28 (TASK-0084 R3). * Weeks are exact day math. */ export function addCalendarUnit( value: string, amount: number, unit: "days" | "weeks" | "months" | "years", ): string { if (unit === "days") return addDays(value, amount); if (unit === "weeks") return addDays(value, amount * 7); const iso = value.length === 10 ? value : value.slice(0, 10); const [y = 0, m = 1, d = 1] = iso.split("-").map((x) => Number.parseInt(x, 10)); const totalMonths = y * 12 + (m - 1) + (unit === "months" ? amount : amount * 12); const ny = Math.floor(totalMonths / 12); const nm = (totalMonths % 12) + 1; const lastDay = new Date(Date.UTC(ny, nm, 0)).getUTCDate(); // day 0 of next month = last of this one const nd = Math.min(d, lastDay); const pad = (n: number) => String(n).padStart(2, "0"); return `${pad(ny) > "99" ? String(ny) : String(ny).padStart(4, "0")}-${pad(nm)}-${pad(nd)}`; } /** True when the date (day-granular) falls within [start, end] UTC-day window. */ export function inWindow(value: string, start: string, end: string): boolean { const v = utcDay(value); return v >= utcDay(start) && v <= utcDay(end); } /** Bucket a date for day/week/month grouping (TASK-0068). */ export function dateBucket(value: string, granularity: "day" | "week" | "month"): string { const iso = value.length === 10 ? value : value.slice(0, 10); if (granularity === "day") return iso; if (granularity === "month") return iso.slice(0, 7); // week: ISO-ish Monday-start week key (UTC). const d = new Date(iso + "T00:00:00Z"); const dow = (d.getUTCDay() + 6) % 7; // Monday = 0 const monday = new Date(d.getTime() - dow * DAY_MS); return monday.toISOString().slice(0, 10); }