| 1 | // @effect-diagnostics globalDate:off -- Usage windows are calendar days in the viewer's zone, derived from wall-clock "now" via Intl. |
| 2 | /** |
| 3 | * Display formatting for the usage page. |
| 4 | * |
| 5 | * @module usageFormat |
| 6 | */ |
| 7 | import { UsageDay, type UsageResolution, type UsageSummaryInput } from "@t3tools/contracts"; |
| 8 | |
| 9 | import type { UsageContractMismatch } from "./usageMerge.ts"; |
| 10 | |
| 11 | const CURRENCY = new Intl.NumberFormat("en-US", { |
| 12 | style: "currency", |
| 13 | currency: "USD", |
| 14 | minimumFractionDigits: 2, |
| 15 | maximumFractionDigits: 2, |
| 16 | }); |
| 17 | |
| 18 | const INTEGER = new Intl.NumberFormat("en-US"); |
| 19 | |
| 20 | export function formatUsd(value: number): string { |
| 21 | return CURRENCY.format(value); |
| 22 | } |
| 23 | |
| 24 | export function formatCount(value: number): string { |
| 25 | return INTEGER.format(Math.round(value)); |
| 26 | } |
| 27 | |
| 28 | /** |
| 29 | * Compacts a token count to three significant figures with a unit suffix, so |
| 30 | * columns of numbers line up at a glance (`19.9B`, `76.7M`, `804K`). |
| 31 | */ |
| 32 | export function formatTokens(value: number): string { |
| 33 | const abs = Math.abs(value); |
| 34 | if (abs >= 1e12) return `${trim(value / 1e12)}T`; |
| 35 | if (abs >= 1e9) return `${trim(value / 1e9)}B`; |
| 36 | if (abs >= 1e6) return `${trim(value / 1e6)}M`; |
| 37 | if (abs >= 1e3) return `${trim(value / 1e3)}K`; |
| 38 | return INTEGER.format(Math.round(value)); |
| 39 | } |
| 40 | |
| 41 | function trim(value: number): string { |
| 42 | const abs = Math.abs(value); |
| 43 | const digits = abs >= 100 ? 0 : abs >= 10 ? 1 : 2; |
| 44 | return value.toFixed(digits).replace(/\.0+$/, ""); |
| 45 | } |
| 46 | |
| 47 | export function formatPercent(share: number, digits = 1): string { |
| 48 | const percent = share * 100; |
| 49 | const smallest = 10 ** -digits; |
| 50 | if (percent > 0 && percent < smallest) return `<${smallest.toFixed(digits)}%`; |
| 51 | return `${percent.toFixed(digits)}%`; |
| 52 | } |
| 53 | |
| 54 | export function formatUsageContractMismatch( |
| 55 | environmentLabel: string, |
| 56 | mismatch: Pick<UsageContractMismatch, "direction">, |
| 57 | ): string { |
| 58 | return mismatch.direction === "serverBehind" |
| 59 | ? `${environmentLabel} runs an older server version and is excluded from totals.` |
| 60 | : `This client is older than the server on ${environmentLabel}; its usage is excluded from totals.`; |
| 61 | } |
| 62 | |
| 63 | /** `2026-08-07` to `Aug 7`. */ |
| 64 | export function formatDayShort(day: string): string { |
| 65 | const [year, month, dayOfMonth] = day.split("-").map((part) => Number(part)); |
| 66 | if (year === undefined || month === undefined || dayOfMonth === undefined) return day; |
| 67 | const MONTHS = [ |
| 68 | "Jan", |
| 69 | "Feb", |
| 70 | "Mar", |
| 71 | "Apr", |
| 72 | "May", |
| 73 | "Jun", |
| 74 | "Jul", |
| 75 | "Aug", |
| 76 | "Sep", |
| 77 | "Oct", |
| 78 | "Nov", |
| 79 | "Dec", |
| 80 | ]; |
| 81 | return `${MONTHS[month - 1] ?? ""} ${dayOfMonth}`; |
| 82 | } |
| 83 | |
| 84 | /** Inclusive day list between two `YYYY-MM-DD` bounds. */ |
| 85 | export function enumerateDays(sinceDay: string, untilDay: string): readonly string[] { |
| 86 | const days: string[] = []; |
| 87 | const start = Date.parse(`${sinceDay}T00:00:00Z`); |
| 88 | const end = Date.parse(`${untilDay}T00:00:00Z`); |
| 89 | if (Number.isNaN(start) || Number.isNaN(end) || end < start) return days; |
| 90 | |
| 91 | for (let cursor = start; cursor <= end; cursor += 86_400_000) { |
| 92 | days.push(new Date(cursor).toISOString().slice(0, 10)); |
| 93 | } |
| 94 | return days; |
| 95 | } |
| 96 | |
| 97 | const HOUR_MS = 60 * 60 * 1000; |
| 98 | |
| 99 | const dateTimeFormatters = new Map<string, Intl.DateTimeFormat>(); |
| 100 | |
| 101 | function dateTimeFormatter( |
| 102 | locale: string, |
| 103 | options: Intl.DateTimeFormatOptions, |
| 104 | ): Intl.DateTimeFormat { |
| 105 | if (options.timeZone === undefined) return new Intl.DateTimeFormat(locale, options); |
| 106 | const key = JSON.stringify([locale, options]); |
| 107 | let formatter = dateTimeFormatters.get(key); |
| 108 | if (formatter === undefined) { |
| 109 | formatter = new Intl.DateTimeFormat(locale, options); |
| 110 | if (dateTimeFormatters.size >= 16) dateTimeFormatters.clear(); |
| 111 | dateTimeFormatters.set(key, formatter); |
| 112 | } |
| 113 | return formatter; |
| 114 | } |
| 115 | |
| 116 | /** Every fixed-duration bucket start in an hourly rolling window. */ |
| 117 | export function enumerateHourStarts(sinceTime: string, untilTime: string): readonly string[] { |
| 118 | const starts: string[] = []; |
| 119 | const start = Date.parse(sinceTime); |
| 120 | const end = Date.parse(untilTime); |
| 121 | if (Number.isNaN(start) || Number.isNaN(end) || end <= start) return starts; |
| 122 | |
| 123 | for (let cursor = start; cursor < end; cursor += HOUR_MS) { |
| 124 | starts.push(new Date(cursor).toISOString()); |
| 125 | } |
| 126 | return starts; |
| 127 | } |
| 128 | |
| 129 | /** |
| 130 | * A rolling bucket start rendered in the viewer's requested time zone. |
| 131 | * |
| 132 | * Repeated wall-clock hours during a fall-back transition include their short |
| 133 | * zone name so the two distinct buckets remain distinguishable. |
| 134 | */ |
| 135 | export function formatHourShort(hourStart: string, timeZone?: string): string { |
| 136 | const instant = new Date(hourStart); |
| 137 | if (Number.isNaN(instant.getTime())) return hourStart; |
| 138 | const options = timeZone === undefined ? {} : { timeZone }; |
| 139 | const hourFormat = dateTimeFormatter("en-US", { |
| 140 | ...options, |
| 141 | hour: "numeric", |
| 142 | }); |
| 143 | const wallHourFormat = dateTimeFormatter("en-CA", { |
| 144 | ...options, |
| 145 | year: "numeric", |
| 146 | month: "2-digit", |
| 147 | day: "2-digit", |
| 148 | hour: "2-digit", |
| 149 | hourCycle: "h23", |
| 150 | }); |
| 151 | const wallHour = wallHourFormat.format(instant); |
| 152 | const isRepeatedHour = [-HOUR_MS, HOUR_MS].some( |
| 153 | (offset) => wallHourFormat.format(new Date(instant.getTime() + offset)) === wallHour, |
| 154 | ); |
| 155 | |
| 156 | if (!isRepeatedHour) return hourFormat.format(instant); |
| 157 | return dateTimeFormatter("en-US", { |
| 158 | ...(timeZone === undefined ? {} : { timeZone }), |
| 159 | hour: "numeric", |
| 160 | timeZoneName: "short", |
| 161 | }).format(instant); |
| 162 | } |
| 163 | |
| 164 | /** `2026-08-11T14:37:00Z` to `Aug 11, 2 PM` in the requested zone. */ |
| 165 | export function formatDateTimeShort(instant: string, timeZone?: string): string { |
| 166 | const date = new Date(instant); |
| 167 | if (Number.isNaN(date.getTime())) return instant; |
| 168 | return dateTimeFormatter("en-US", { |
| 169 | ...(timeZone === undefined ? {} : { timeZone }), |
| 170 | month: "short", |
| 171 | day: "numeric", |
| 172 | hour: "numeric", |
| 173 | }).format(date); |
| 174 | } |
| 175 | |
| 176 | /** An hourly tooltip label relative to the rolling window's end date. */ |
| 177 | export function formatRelativeHourShort( |
| 178 | hourStart: string, |
| 179 | relativeTo: string, |
| 180 | timeZone?: string, |
| 181 | ): string { |
| 182 | const instant = new Date(hourStart); |
| 183 | const reference = new Date(relativeTo); |
| 184 | if (Number.isNaN(instant.getTime()) || Number.isNaN(reference.getTime())) { |
| 185 | return formatDateTimeShort(hourStart, timeZone); |
| 186 | } |
| 187 | |
| 188 | const dayFormat = dateTimeFormatter("en-CA", { |
| 189 | ...(timeZone === undefined ? {} : { timeZone }), |
| 190 | year: "numeric", |
| 191 | month: "2-digit", |
| 192 | day: "2-digit", |
| 193 | }); |
| 194 | const instantDay = Date.parse(`${dayFormat.format(instant)}T00:00:00Z`); |
| 195 | const referenceDay = Date.parse(`${dayFormat.format(reference)}T00:00:00Z`); |
| 196 | const calendarDaysAgo = Math.round((referenceDay - instantDay) / (24 * HOUR_MS)); |
| 197 | const hour = formatHourShort(hourStart, timeZone); |
| 198 | |
| 199 | if (calendarDaysAgo === 0) return `${hour} today`; |
| 200 | if (calendarDaysAgo === 1) return `${hour} yesterday`; |
| 201 | return formatDateTimeShort(hourStart, timeZone); |
| 202 | } |
| 203 | |
| 204 | /** |
| 205 | * The window the page requests, expressed in the viewer's own time zone so days |
| 206 | * line up with what they actually experienced. |
| 207 | */ |
| 208 | export function makeWindow( |
| 209 | days: number, |
| 210 | now = new Date(), |
| 211 | resolution: UsageResolution = "day", |
| 212 | ): UsageSummaryInput { |
| 213 | let timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; |
| 214 | let format: Intl.DateTimeFormat; |
| 215 | try { |
| 216 | format = new Intl.DateTimeFormat("en-CA", { |
| 217 | timeZone, |
| 218 | year: "numeric", |
| 219 | month: "2-digit", |
| 220 | day: "2-digit", |
| 221 | }); |
| 222 | } catch { |
| 223 | // An unknown zone should degrade to UTC rather than crash the page. |
| 224 | timeZone = "UTC"; |
| 225 | format = new Intl.DateTimeFormat("en-CA", { |
| 226 | timeZone: "UTC", |
| 227 | year: "numeric", |
| 228 | month: "2-digit", |
| 229 | day: "2-digit", |
| 230 | }); |
| 231 | } |
| 232 | const untilDay = format.format(now); |
| 233 | if (resolution === "hour") { |
| 234 | // Minute-aligned bounds keep labels readable while still representing an |
| 235 | // exact rolling 24-hour duration. Fixed-duration buckets remain correct |
| 236 | // across offset changes and daylight-saving transitions. |
| 237 | const untilTimeMs = Math.floor(now.getTime() / 60_000) * 60_000; |
| 238 | const sinceTimeMs = untilTimeMs - 24 * HOUR_MS; |
| 239 | const sinceTime = new Date(sinceTimeMs); |
| 240 | const untilTime = new Date(untilTimeMs); |
| 241 | return { |
| 242 | sinceDay: UsageDay.make(format.format(sinceTime)), |
| 243 | untilDay: UsageDay.make(format.format(untilTime)), |
| 244 | timeZone, |
| 245 | resolution, |
| 246 | sinceTime: sinceTime.toISOString(), |
| 247 | untilTime: untilTime.toISOString(), |
| 248 | }; |
| 249 | } |
| 250 | // Subtracting fixed milliseconds from `now` lands on the wrong calendar day |
| 251 | // around a DST transition. The window start is pure calendar arithmetic on |
| 252 | // the local end day, done in UTC where days are uniform. |
| 253 | const [year = 0, month = 1, dayOfMonth = 1] = untilDay |
| 254 | .split("-") |
| 255 | .map((part) => Number.parseInt(part, 10)); |
| 256 | const start = new Date(Date.UTC(year, month - 1, dayOfMonth - (days - 1))); |
| 257 | return { |
| 258 | sinceDay: UsageDay.make(start.toISOString().slice(0, 10)), |
| 259 | untilDay: UsageDay.make(untilDay), |
| 260 | timeZone, |
| 261 | resolution, |
| 262 | }; |
| 263 | } |