TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1/**2 * Pure time helpers for the usage dashboard (no DB, unit-tested). Buckets are computed in the3 * caller's IANA time zone so "today" and daily bars match what the user sees on their clock.4 */56export type UsageRangeKey = "today" | "7d" | "30d" | "90d" | "custom" | "all";7export type Bucket = "hour" | "day";89export const RANGE_KEYS: readonly UsageRangeKey[] = ["today", "7d", "30d", "90d", "custom", "all"] as const;1011export function isRangeKey(v: unknown): v is UsageRangeKey {12 return typeof v === "string" && (RANGE_KEYS as readonly string[]).includes(v);13}1415const DAY_MS = 86_400_000;16const MAX_CUSTOM_DAYS = 366;1718let supportedTz: Set<string> | null = null;19export function isValidTimeZone(tz: unknown): tz is string {20 if (typeof tz !== "string" || !/^[A-Za-z_]+(?:\/[A-Za-z0-9_+.-]+)*$/.test(tz) || tz.length > 64) return false;21 if (!supportedTz) {22 try {23 supportedTz = new Set(Intl.supportedValuesOf("timeZone"));24 } catch {25 supportedTz = new Set();26 }27 supportedTz.add("UTC");28 }29 if (supportedTz.has(tz)) return true;30 try {31 new Intl.DateTimeFormat("en-US", { timeZone: tz });32 return true;33 } catch {34 return false;35 }36}3738interface Parts {39 year: number;40 month: number;41 day: number;42 hour: number;43 minute: number;44 second: number;45}4647const partCache = new Map<string, Intl.DateTimeFormat>();48function formatter(tz: string): Intl.DateTimeFormat {49 let f = partCache.get(tz);50 if (!f) {51 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" });52 partCache.set(tz, f);53 }54 return f;55}5657/** Wall-clock parts of `d` in `tz`. */58export function zonedParts(d: Date, tz: string): Parts {59 const out: Partial<Parts> = {};60 for (const p of formatter(tz).formatToParts(d)) {61 if (p.type === "year") out.year = Number(p.value);62 else if (p.type === "month") out.month = Number(p.value);63 else if (p.type === "day") out.day = Number(p.value);64 else if (p.type === "hour") out.hour = Number(p.value) % 24;65 else if (p.type === "minute") out.minute = Number(p.value);66 else if (p.type === "second") out.second = Number(p.value);67 }68 return out as Parts;69}7071/** Offset (ms) of `tz` relative to UTC at instant `d`. */72export function tzOffsetMs(d: Date, tz: string): number {73 const p = zonedParts(d, tz);74 const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);75 return asUtc - Math.floor(d.getTime() / 1000) * 1000;76}7778/** Instant of `year-month-day 00:00` in `tz` (two-pass to absorb DST). */79export function zonedMidnight(year: number, month: number, day: number, tz: string): Date {80 let guess = Date.UTC(year, month - 1, day, 0, 0, 0);81 for (let i = 0; i < 2; i++) guess = Date.UTC(year, month - 1, day, 0, 0, 0) - tzOffsetMs(new Date(guess), tz);82 return new Date(guess);83}8485export function startOfDayInTz(d: Date, tz: string): Date {86 const p = zonedParts(d, tz);87 return zonedMidnight(p.year, p.month, p.day, tz);88}8990export interface ResolvedRange {91 key: UsageRangeKey;92 /** Inclusive start. `null` for "all" (no lower bound). */93 from: Date | null;94 /** Exclusive end. */95 to: Date;96 bucket: Bucket;97 tz: string;98 /** Whole days covered (≥ 1), used for daily averages. */99 days: number;100}101102/**103 * Resolve a range key (+ optional custom bounds, ISO `YYYY-MM-DD` or full ISO) into instants.104 * Custom ranges are clamped to 366 days; `to` for a custom date is the end of that day (exclusive next midnight).105 */106export function resolveRange(input: { range?: string | null; from?: string | null; to?: string | null; tz?: string | null }, now = new Date()): ResolvedRange {107 const tz = isValidTimeZone(input.tz) ? input.tz : "UTC";108 const key: UsageRangeKey = isRangeKey(input.range) ? input.range : "30d";109 const todayStart = startOfDayInTz(now, tz);110 const tomorrow = new Date(todayStart.getTime() + DAY_MS + 3 * 3_600_000); // generous: DST-safe upper bound, trimmed below111 const endOfToday = startOfDayInTz(tomorrow, tz);112 if (key === "today") return { key, from: todayStart, to: endOfToday, bucket: "hour", tz, days: 1 };113 if (key === "7d" || key === "30d" || key === "90d") {114 const n = key === "7d" ? 7 : key === "30d" ? 30 : 90;115 const from = addDays(todayStart, -(n - 1), tz);116 return { key, from, to: endOfToday, bucket: "day", tz, days: n };117 }118 if (key === "custom") {119 const fromD = parseDateInput(input.from, tz);120 const toD = parseDateInput(input.to, tz);121 let from = fromD ?? addDays(todayStart, -29, tz);122 let to = toD ? startOfDayInTz(new Date(toD.getTime() + DAY_MS + 3 * 3_600_000), tz) : endOfToday;123 if (to <= from) [from, to] = [startOfDayInTz(to, tz), addDays(startOfDayInTz(from, tz), 1, tz)];124 if (to > endOfToday) to = endOfToday;125 if (to.getTime() - from.getTime() > MAX_CUSTOM_DAYS * DAY_MS) from = addDays(to, -MAX_CUSTOM_DAYS, tz);126 const days = Math.max(1, Math.round((to.getTime() - from.getTime()) / DAY_MS));127 return { key, from, to, bucket: days <= 2 ? "hour" : "day", tz, days };128 }129 // all130 return { key: "all", from: null, to: endOfToday, bucket: "day", tz, days: 0 };131}132133function parseDateInput(v: string | null | undefined, tz: string): Date | null {134 if (!v) return null;135 const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v.trim());136 if (m) return zonedMidnight(Number(m[1]), Number(m[2]), Number(m[3]), tz);137 const d = new Date(v);138 return Number.isNaN(d.getTime()) ? null : startOfDayInTz(d, tz);139}140141/** Add calendar days in `tz` (keeps local midnight across DST). */142export function addDays(d: Date, n: number, tz: string): Date {143 const p = zonedParts(d, tz);144 const shifted = new Date(Date.UTC(p.year, p.month - 1, p.day + n));145 return zonedMidnight(shifted.getUTCFullYear(), shifted.getUTCMonth() + 1, shifted.getUTCDate(), tz);146}147148/** Bucket label used both by SQL (`to_char`) and gap filling: `YYYY-MM-DD` or `YYYY-MM-DDTHH:00`. */149export function bucketKey(d: Date, bucket: Bucket, tz: string): string {150 const p = zonedParts(d, tz);151 const ymd = `${p.year}-${String(p.month).padStart(2, "0")}-${String(p.day).padStart(2, "0")}`;152 return bucket === "day" ? ymd : `${ymd}T${String(p.hour).padStart(2, "0")}:00`;153}154155/** Every bucket label between `from` (inclusive) and `to` (exclusive). Capped at 400 entries. */156export function bucketKeys(from: Date, to: Date, bucket: Bucket, tz: string): string[] {157 const out: string[] = [];158 const seen = new Set<string>();159 const step = bucket === "day" ? DAY_MS : 3_600_000;160 // Start from the bucket that contains `from`.161 let t = bucket === "day" ? startOfDayInTz(from, tz).getTime() : from.getTime() - (from.getTime() % 3_600_000);162 // Hourly buckets in a zone with a non-hour offset (e.g. +05:30) still align because we key on wall-clock hours.163 for (let i = 0; i < 400 && t < to.getTime(); i++, t += step) {164 const k = bucketKey(new Date(t), bucket, tz);165 if (!seen.has(k)) {166 seen.add(k);167 out.push(k);168 }169 }170 return out;171}172173/** Previous period of the same length, ending where `range.from` starts. `null` for "all". */174export function previousPeriod(range: ResolvedRange): { from: Date; to: Date } | null {175 if (!range.from) return null;176 const span = range.to.getTime() - range.from.getTime();177 return { from: new Date(range.from.getTime() - span), to: range.from };178}179180/** Days of data observed so far in the range (fractional for "today", capped at the range length). */181export function observedDays(range: ResolvedRange, now = new Date(), firstRecordAt?: Date | null): number {182 const start = range.from ?? firstRecordAt ?? now;183 const end = Math.min(now.getTime(), range.to.getTime());184 const days = (end - start.getTime()) / DAY_MS;185 return Math.max(days, 1 / 24);186}187