/** * Pure time helpers for the usage dashboard (no DB, unit-tested). Buckets are computed in the * caller's IANA time zone so "today" and daily bars match what the user sees on their clock. */ export type UsageRangeKey = "today" | "7d" | "30d" | "90d" | "custom" | "all"; export type Bucket = "hour" | "day"; export const RANGE_KEYS: readonly UsageRangeKey[] = ["today", "7d", "30d", "90d", "custom", "all"] as const; export function isRangeKey(v: unknown): v is UsageRangeKey { return typeof v === "string" && (RANGE_KEYS as readonly string[]).includes(v); } const DAY_MS = 86_400_000; const MAX_CUSTOM_DAYS = 366; let supportedTz: Set | null = null; export function isValidTimeZone(tz: unknown): tz is string { if (typeof tz !== "string" || !/^[A-Za-z_]+(?:\/[A-Za-z0-9_+.-]+)*$/.test(tz) || tz.length > 64) return false; if (!supportedTz) { try { supportedTz = new Set(Intl.supportedValuesOf("timeZone")); } catch { supportedTz = new Set(); } supportedTz.add("UTC"); } if (supportedTz.has(tz)) return true; try { new Intl.DateTimeFormat("en-US", { timeZone: tz }); return true; } catch { return false; } } interface Parts { year: number; month: number; day: number; hour: number; minute: number; second: number; } const partCache = new Map(); function formatter(tz: string): Intl.DateTimeFormat { let f = partCache.get(tz); if (!f) { f = new Intl.DateTimeFormat("en-US", { timeZone: tz, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }); partCache.set(tz, f); } return f; } /** Wall-clock parts of `d` in `tz`. */ export function zonedParts(d: Date, tz: string): Parts { const out: Partial = {}; for (const p of formatter(tz).formatToParts(d)) { if (p.type === "year") out.year = Number(p.value); else if (p.type === "month") out.month = Number(p.value); else if (p.type === "day") out.day = Number(p.value); else if (p.type === "hour") out.hour = Number(p.value) % 24; else if (p.type === "minute") out.minute = Number(p.value); else if (p.type === "second") out.second = Number(p.value); } return out as Parts; } /** Offset (ms) of `tz` relative to UTC at instant `d`. */ export function tzOffsetMs(d: Date, tz: string): number { const p = zonedParts(d, tz); const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second); return asUtc - Math.floor(d.getTime() / 1000) * 1000; } /** Instant of `year-month-day 00:00` in `tz` (two-pass to absorb DST). */ export function zonedMidnight(year: number, month: number, day: number, tz: string): Date { let guess = Date.UTC(year, month - 1, day, 0, 0, 0); for (let i = 0; i < 2; i++) guess = Date.UTC(year, month - 1, day, 0, 0, 0) - tzOffsetMs(new Date(guess), tz); return new Date(guess); } export function startOfDayInTz(d: Date, tz: string): Date { const p = zonedParts(d, tz); return zonedMidnight(p.year, p.month, p.day, tz); } export interface ResolvedRange { key: UsageRangeKey; /** Inclusive start. `null` for "all" (no lower bound). */ from: Date | null; /** Exclusive end. */ to: Date; bucket: Bucket; tz: string; /** Whole days covered (≥ 1), used for daily averages. */ days: number; } /** * Resolve a range key (+ optional custom bounds, ISO `YYYY-MM-DD` or full ISO) into instants. * Custom ranges are clamped to 366 days; `to` for a custom date is the end of that day (exclusive next midnight). */ export function resolveRange(input: { range?: string | null; from?: string | null; to?: string | null; tz?: string | null }, now = new Date()): ResolvedRange { const tz = isValidTimeZone(input.tz) ? input.tz : "UTC"; const key: UsageRangeKey = isRangeKey(input.range) ? input.range : "30d"; const todayStart = startOfDayInTz(now, tz); const tomorrow = new Date(todayStart.getTime() + DAY_MS + 3 * 3_600_000); // generous: DST-safe upper bound, trimmed below const endOfToday = startOfDayInTz(tomorrow, tz); if (key === "today") return { key, from: todayStart, to: endOfToday, bucket: "hour", tz, days: 1 }; if (key === "7d" || key === "30d" || key === "90d") { const n = key === "7d" ? 7 : key === "30d" ? 30 : 90; const from = addDays(todayStart, -(n - 1), tz); return { key, from, to: endOfToday, bucket: "day", tz, days: n }; } if (key === "custom") { const fromD = parseDateInput(input.from, tz); const toD = parseDateInput(input.to, tz); let from = fromD ?? addDays(todayStart, -29, tz); let to = toD ? startOfDayInTz(new Date(toD.getTime() + DAY_MS + 3 * 3_600_000), tz) : endOfToday; if (to <= from) [from, to] = [startOfDayInTz(to, tz), addDays(startOfDayInTz(from, tz), 1, tz)]; if (to > endOfToday) to = endOfToday; if (to.getTime() - from.getTime() > MAX_CUSTOM_DAYS * DAY_MS) from = addDays(to, -MAX_CUSTOM_DAYS, tz); const days = Math.max(1, Math.round((to.getTime() - from.getTime()) / DAY_MS)); return { key, from, to, bucket: days <= 2 ? "hour" : "day", tz, days }; } // all return { key: "all", from: null, to: endOfToday, bucket: "day", tz, days: 0 }; } function parseDateInput(v: string | null | undefined, tz: string): Date | null { if (!v) return null; const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v.trim()); if (m) return zonedMidnight(Number(m[1]), Number(m[2]), Number(m[3]), tz); const d = new Date(v); return Number.isNaN(d.getTime()) ? null : startOfDayInTz(d, tz); } /** Add calendar days in `tz` (keeps local midnight across DST). */ export function addDays(d: Date, n: number, tz: string): Date { const p = zonedParts(d, tz); const shifted = new Date(Date.UTC(p.year, p.month - 1, p.day + n)); return zonedMidnight(shifted.getUTCFullYear(), shifted.getUTCMonth() + 1, shifted.getUTCDate(), tz); } /** Bucket label used both by SQL (`to_char`) and gap filling: `YYYY-MM-DD` or `YYYY-MM-DDTHH:00`. */ export function bucketKey(d: Date, bucket: Bucket, tz: string): string { const p = zonedParts(d, tz); const ymd = `${p.year}-${String(p.month).padStart(2, "0")}-${String(p.day).padStart(2, "0")}`; return bucket === "day" ? ymd : `${ymd}T${String(p.hour).padStart(2, "0")}:00`; } /** Every bucket label between `from` (inclusive) and `to` (exclusive). Capped at 400 entries. */ export function bucketKeys(from: Date, to: Date, bucket: Bucket, tz: string): string[] { const out: string[] = []; const seen = new Set(); const step = bucket === "day" ? DAY_MS : 3_600_000; // Start from the bucket that contains `from`. let t = bucket === "day" ? startOfDayInTz(from, tz).getTime() : from.getTime() - (from.getTime() % 3_600_000); // Hourly buckets in a zone with a non-hour offset (e.g. +05:30) still align because we key on wall-clock hours. for (let i = 0; i < 400 && t < to.getTime(); i++, t += step) { const k = bucketKey(new Date(t), bucket, tz); if (!seen.has(k)) { seen.add(k); out.push(k); } } return out; } /** Previous period of the same length, ending where `range.from` starts. `null` for "all". */ export function previousPeriod(range: ResolvedRange): { from: Date; to: Date } | null { if (!range.from) return null; const span = range.to.getTime() - range.from.getTime(); return { from: new Date(range.from.getTime() - span), to: range.from }; } /** Days of data observed so far in the range (fractional for "today", capped at the range length). */ export function observedDays(range: ResolvedRange, now = new Date(), firstRecordAt?: Date | null): number { const start = range.from ?? firstRecordAt ?? now; const end = Math.min(now.getTime(), range.to.getTime()); const days = (end - start.getTime()) / DAY_MS; return Math.max(days, 1 / 24); }