SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
7.6 KB · 187 lines typescript
Raw Blame History
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