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 */
7import { UsageDay, type UsageResolution, type UsageSummaryInput } from "@t3tools/contracts";
8
9import type { UsageContractMismatch } from "./usageMerge.ts";
10
11const CURRENCY = new Intl.NumberFormat("en-US", {
12 style: "currency",
13 currency: "USD",
14 minimumFractionDigits: 2,
15 maximumFractionDigits: 2,
16});
17
18const INTEGER = new Intl.NumberFormat("en-US");
19
20export function formatUsd(value: number): string {
21 return CURRENCY.format(value);
22}
23
24export 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 */
32export 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
41function 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
47export 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
54export 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`. */
64export 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. */
85export 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
97const HOUR_MS = 60 * 60 * 1000;
98
99const dateTimeFormatters = new Map<string, Intl.DateTimeFormat>();
100
101function 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. */
117export 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 */
135export 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. */
165export 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. */
177export 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 */
208export 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}