1// @effect-diagnostics globalDate:off -- Usage windows are calendar days in the viewer's zone, derived from wall-clock "now" via Intl.
2import {
3 UsageDay,
4 type UsageResolution,
5 type UsageSource,
6 type UsageSummaryInput,
7} 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 formatMalformedUsageWarning(count: number): string {
21 return `${count} malformed or inconsistent usage record(s) were skipped; usage may be incomplete.`;
22}
23
24export function usageSourceMessages(sources: readonly UsageSource[]): readonly string[] {
25 const messages = new Set<string>();
26 const malformedBySource = new Map<string, number>();
27 for (const source of sources) {
28 if (
29 source.status !== "partial" &&
30 source.status !== "failed" &&
31 source.fingerprint.provider !== "cursor" &&
32 !(source.status === "missing" && source.malformedRecords > 0)
33 ) {
34 continue;
35 }
36 let message = source.message;
37 if (source.malformedRecords > 0) {
38 const { hostId, provider, resolvedHomePath, volumeId } = source.fingerprint;
39 const key = JSON.stringify([hostId, provider, resolvedHomePath, volumeId]);
40 malformedBySource.set(
41 key,
42 Math.max(malformedBySource.get(key) ?? 0, source.malformedRecords),
43 );
44 // Servers also append this warning to message; retain any preceding read failure.
45 const warning = formatMalformedUsageWarning(source.malformedRecords);
46 if (message?.endsWith(warning)) message = message.slice(0, -warning.length).trim();
47 }
48 if (message) messages.add(message);
49 }
50 const malformedRecords = [...malformedBySource.values()].reduce(
51 (total, count) => total + count,
52 0,
53 );
54 if (malformedRecords > 0) messages.add(formatMalformedUsageWarning(malformedRecords));
55 return [...messages];
56}
57
58export function formatUsd(value: number): string {
59 return CURRENCY.format(value);
60}
61
62export function formatCount(value: number): string {
63 return INTEGER.format(Math.round(value));
64}
65
66const TOKEN_SUFFIXES = ["K", "M", "B", "T"] as const;
67
68export function formatTokens(value: number): string {
69 if (Math.abs(Math.round(value)) < 1e3) return INTEGER.format(Math.round(value));
70 let scaled = value / 1e3;
71 let unit = 0;
72 // Choose the unit after rounding, so 999,999 reads 1M rather than 1000K.
73 while (Math.abs(Number(trim(scaled))) >= 1e3 && unit < TOKEN_SUFFIXES.length - 1) {
74 scaled /= 1e3;
75 unit += 1;
76 }
77 return `${trim(scaled)}${TOKEN_SUFFIXES[unit]}`;
78}
79
80function trim(value: number): string {
81 const abs = Math.abs(value);
82 const digits = abs >= 100 ? 0 : abs >= 10 ? 1 : 2;
83 return value.toFixed(digits).replace(/\.0+$/, "");
84}
85
86export function formatPercent(share: number, digits = 1): string {
87 const percent = share * 100;
88 const smallest = 10 ** -digits;
89 if (percent > 0 && percent < smallest) return `<${smallest.toFixed(digits)}%`;
90 return `${percent.toFixed(digits)}%`;
91}
92
93export function formatUsageContractMismatch(
94 environmentLabel: string,
95 mismatch: Pick<UsageContractMismatch, "direction">,
96): string {
97 return mismatch.direction === "serverBehind"
98 ? `${environmentLabel} runs an older server version and is excluded from totals.`
99 : `This client is older than the server on ${environmentLabel}; its usage is excluded from totals.`;
100}
101
102export function formatDayShort(day: string): string {
103 const [year, month, dayOfMonth] = day.split("-").map((part) => Number(part));
104 if (year === undefined || month === undefined || dayOfMonth === undefined) return day;
105 const MONTHS = [
106 "Jan",
107 "Feb",
108 "Mar",
109 "Apr",
110 "May",
111 "Jun",
112 "Jul",
113 "Aug",
114 "Sep",
115 "Oct",
116 "Nov",
117 "Dec",
118 ];
119 return `${MONTHS[month - 1] ?? ""} ${dayOfMonth}`;
120}
121
122export function enumerateDays(sinceDay: string, untilDay: string): readonly string[] {
123 const days: string[] = [];
124 const start = Date.parse(`${sinceDay}T00:00:00Z`);
125 const end = Date.parse(`${untilDay}T00:00:00Z`);
126 if (Number.isNaN(start) || Number.isNaN(end) || end < start) return days;
127
128 for (let cursor = start; cursor <= end; cursor += 86_400_000) {
129 days.push(new Date(cursor).toISOString().slice(0, 10));
130 }
131 return days;
132}
133
134const HOUR_MS = 60 * 60 * 1000;
135
136const dateTimeFormatters = new Map<string, Intl.DateTimeFormat>();
137
138function dateTimeFormatter(
139 locale: string,
140 options: Intl.DateTimeFormatOptions,
141): Intl.DateTimeFormat {
142 if (options.timeZone === undefined) return new Intl.DateTimeFormat(locale, options);
143 const key = JSON.stringify([locale, options]);
144 let formatter = dateTimeFormatters.get(key);
145 if (formatter === undefined) {
146 formatter = new Intl.DateTimeFormat(locale, options);
147 if (dateTimeFormatters.size >= 16) dateTimeFormatters.clear();
148 dateTimeFormatters.set(key, formatter);
149 }
150 return formatter;
151}
152
153export function enumerateHourStarts(sinceTime: string, untilTime: string): readonly string[] {
154 const starts: string[] = [];
155 const start = Date.parse(sinceTime);
156 const end = Date.parse(untilTime);
157 if (Number.isNaN(start) || Number.isNaN(end) || end <= start) return starts;
158
159 for (let cursor = start; cursor < end; cursor += HOUR_MS) {
160 starts.push(new Date(cursor).toISOString());
161 }
162 return starts;
163}
164
165function hourOptions(hour12: boolean): Intl.DateTimeFormatOptions {
166 return hour12 ? { hour: "numeric" } : { hour: "2-digit", hourCycle: "h23" };
167}
168
169function formatHour(formatter: Intl.DateTimeFormat, date: Date, hour12: boolean): string {
170 if (hour12) return formatter.format(date);
171 // Rolling buckets can start between whole hours, but their labels always name the hour.
172 return formatter
173 .formatToParts(date)
174 .map((part) => (part.type === "hour" ? `${part.value}:00` : part.value))
175 .join("");
176}
177
178/**
179 * Repeated wall-clock hours during a fall-back transition include their short zone name so the
180 * two distinct buckets remain distinguishable.
181 */
182export function formatHourShort(hourStart: string, timeZone?: string, hour12 = true): string {
183 const instant = new Date(hourStart);
184 if (Number.isNaN(instant.getTime())) return hourStart;
185 const options = timeZone === undefined ? {} : { timeZone };
186 const hourFormat = dateTimeFormatter("en-US", {
187 ...options,
188 ...hourOptions(hour12),
189 });
190 const wallHourFormat = dateTimeFormatter("en-CA", {
191 ...options,
192 year: "numeric",
193 month: "2-digit",
194 day: "2-digit",
195 hour: "2-digit",
196 hourCycle: "h23",
197 });
198 const wallHour = wallHourFormat.format(instant);
199 const isRepeatedHour = [-HOUR_MS, HOUR_MS].some(
200 (offset) => wallHourFormat.format(new Date(instant.getTime() + offset)) === wallHour,
201 );
202
203 if (!isRepeatedHour) return formatHour(hourFormat, instant, hour12);
204 const zonedHourFormat = dateTimeFormatter("en-US", {
205 ...options,
206 ...hourOptions(hour12),
207 timeZoneName: "short",
208 });
209 return formatHour(zonedHourFormat, instant, hour12);
210}
211
212export function formatDateTimeShort(instant: string, timeZone?: string, hour12 = true): string {
213 const date = new Date(instant);
214 if (Number.isNaN(date.getTime())) return instant;
215 const dateHourFormat = dateTimeFormatter("en-US", {
216 ...(timeZone === undefined ? {} : { timeZone }),
217 month: "short",
218 day: "numeric",
219 ...hourOptions(hour12),
220 });
221 return formatHour(dateHourFormat, date, hour12);
222}
223
224export function formatRelativeHourShort(
225 hourStart: string,
226 relativeTo: string,
227 timeZone?: string,
228 hour12 = true,
229): string {
230 const instant = new Date(hourStart);
231 const reference = new Date(relativeTo);
232 if (Number.isNaN(instant.getTime()) || Number.isNaN(reference.getTime())) {
233 return formatDateTimeShort(hourStart, timeZone, hour12);
234 }
235
236 const dayFormat = dateTimeFormatter("en-CA", {
237 ...(timeZone === undefined ? {} : { timeZone }),
238 year: "numeric",
239 month: "2-digit",
240 day: "2-digit",
241 });
242 const instantDay = Date.parse(`${dayFormat.format(instant)}T00:00:00Z`);
243 const referenceDay = Date.parse(`${dayFormat.format(reference)}T00:00:00Z`);
244 const calendarDaysAgo = Math.round((referenceDay - instantDay) / (24 * HOUR_MS));
245 const hour = formatHourShort(hourStart, timeZone, hour12);
246
247 if (calendarDaysAgo === 0) return `${hour} today`;
248 if (calendarDaysAgo === 1) return `${hour} yesterday`;
249 return formatDateTimeShort(hourStart, timeZone, hour12);
250}
251
252export function makeWindow(
253 days: number,
254 now = new Date(),
255 resolution: UsageResolution = "day",
256): UsageSummaryInput {
257 let timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
258 let format: Intl.DateTimeFormat;
259 try {
260 format = new Intl.DateTimeFormat("en-CA", {
261 timeZone,
262 year: "numeric",
263 month: "2-digit",
264 day: "2-digit",
265 });
266 } catch {
267 timeZone = "UTC";
268 format = new Intl.DateTimeFormat("en-CA", {
269 timeZone: "UTC",
270 year: "numeric",
271 month: "2-digit",
272 day: "2-digit",
273 });
274 }
275 const untilDay = format.format(now);
276 if (resolution === "hour") {
277 const untilTimeMs = Math.floor(now.getTime() / 60_000) * 60_000;
278 const sinceTimeMs = untilTimeMs - 24 * HOUR_MS;
279 const sinceTime = new Date(sinceTimeMs);
280 const untilTime = new Date(untilTimeMs);
281 return {
282 sinceDay: UsageDay.make(format.format(sinceTime)),
283 untilDay: UsageDay.make(format.format(untilTime)),
284 timeZone,
285 resolution,
286 sinceTime: sinceTime.toISOString(),
287 untilTime: untilTime.toISOString(),
288 };
289 }
290 // Use calendar arithmetic in UTC; fixed millisecond subtraction crosses DST days incorrectly.
291 const [year = 0, month = 1, dayOfMonth = 1] = untilDay
292 .split("-")
293 .map((part) => Number.parseInt(part, 10));
294 const start = new Date(Date.UTC(year, month - 1, dayOfMonth - (days - 1)));
295 return {
296 sinceDay: UsageDay.make(start.toISOString().slice(0, 10)),
297 untilDay: UsageDay.make(untilDay),
298 timeZone,
299 resolution,
300 };
301}