Skip to content
Source
Sign inSign up
Open History to see the last commit

usageFormat.ts

296 lines9.7 KB

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.`;
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];
57​
58export function formatUsd(value: number): string {
59 return CURRENCY.format(value);
61​
62export function formatCount(value: number): string {
63 return INTEGER.format(Math.round(value));
65​
66export function formatTokens(value: number): string {
67 const abs = Math.abs(value);
68 if (abs >= 1e12) return `${trim(value / 1e12)}T`;
69 if (abs >= 1e9) return `${trim(value / 1e9)}B`;
70 if (abs >= 1e6) return `${trim(value / 1e6)}M`;
71 if (abs >= 1e3) return `${trim(value / 1e3)}K`;
72 return INTEGER.format(Math.round(value));
74​
75function trim(value: number): string {
76 const abs = Math.abs(value);
77 const digits = abs >= 100 ? 0 : abs >= 10 ? 1 : 2;
78 return value.toFixed(digits).replace(/\.0+$/, "");
80​
81export function formatPercent(share: number, digits = 1): string {
82 const percent = share * 100;
83 const smallest = 10 ** -digits;
84 if (percent > 0 && percent < smallest) return `<${smallest.toFixed(digits)}%`;
85 return `${percent.toFixed(digits)}%`;
87​
88export function formatUsageContractMismatch(
89 environmentLabel: string,
90 mismatch: Pick<UsageContractMismatch, "direction">,
91): string {
92 return mismatch.direction === "serverBehind"
93 ? `${environmentLabel} runs an older server version and is excluded from totals.`
94 : `This client is older than the server on ${environmentLabel}; its usage is excluded from totals.`;
96​
97export function formatDayShort(day: string): string {
98 const [year, month, dayOfMonth] = day.split("-").map((part) => Number(part));
99 if (year === undefined || month === undefined || dayOfMonth === undefined) return day;
100 const MONTHS = [
101 "Jan",
102 "Feb",
103 "Mar",
104 "Apr",
105 "May",
106 "Jun",
107 "Jul",
108 "Aug",
109 "Sep",
110 "Oct",
111 "Nov",
112 "Dec",
113 ];
114 return `${MONTHS[month - 1] ?? ""} ${dayOfMonth}`;
116​
117export function enumerateDays(sinceDay: string, untilDay: string): readonly string[] {
118 const days: string[] = [];
119 const start = Date.parse(`${sinceDay}T00:00:00Z`);
120 const end = Date.parse(`${untilDay}T00:00:00Z`);
121 if (Number.isNaN(start) || Number.isNaN(end) || end < start) return days;
122​
123 for (let cursor = start; cursor <= end; cursor += 86_400_000) {
124 days.push(new Date(cursor).toISOString().slice(0, 10));
125 }
126 return days;
128​
129const HOUR_MS = 60 * 60 * 1000;
130​
131const dateTimeFormatters = new Map<string, Intl.DateTimeFormat>();
132​
133function dateTimeFormatter(
134 locale: string,
135 options: Intl.DateTimeFormatOptions,
136): Intl.DateTimeFormat {
137 if (options.timeZone === undefined) return new Intl.DateTimeFormat(locale, options);
138 const key = JSON.stringify([locale, options]);
139 let formatter = dateTimeFormatters.get(key);
140 if (formatter === undefined) {
141 formatter = new Intl.DateTimeFormat(locale, options);
142 if (dateTimeFormatters.size >= 16) dateTimeFormatters.clear();
143 dateTimeFormatters.set(key, formatter);
144 }
145 return formatter;
147​
148export function enumerateHourStarts(sinceTime: string, untilTime: string): readonly string[] {
149 const starts: string[] = [];
150 const start = Date.parse(sinceTime);
151 const end = Date.parse(untilTime);
152 if (Number.isNaN(start) || Number.isNaN(end) || end <= start) return starts;
153​
154 for (let cursor = start; cursor < end; cursor += HOUR_MS) {
155 starts.push(new Date(cursor).toISOString());
156 }
157 return starts;
159​
160function hourOptions(hour12: boolean): Intl.DateTimeFormatOptions {
161 return hour12 ? { hour: "numeric" } : { hour: "2-digit", hourCycle: "h23" };
163​
164function formatHour(formatter: Intl.DateTimeFormat, date: Date, hour12: boolean): string {
165 if (hour12) return formatter.format(date);
166 // Rolling buckets can start between whole hours, but their labels always name the hour.
167 return formatter
168 .formatToParts(date)
169 .map((part) => (part.type === "hour" ? `${part.value}:00` : part.value))
170 .join("");
172​
173/**
174 * Repeated wall-clock hours during a fall-back transition include their short zone name so the
175 * two distinct buckets remain distinguishable.
176 */
177export function formatHourShort(hourStart: string, timeZone?: string, hour12 = true): string {
178 const instant = new Date(hourStart);
179 if (Number.isNaN(instant.getTime())) return hourStart;
180 const options = timeZone === undefined ? {} : { timeZone };
181 const hourFormat = dateTimeFormatter("en-US", {
182 ...options,
183 ...hourOptions(hour12),
184 });
185 const wallHourFormat = dateTimeFormatter("en-CA", {
186 ...options,
187 year: "numeric",
188 month: "2-digit",
189 day: "2-digit",
190 hour: "2-digit",
191 hourCycle: "h23",
192 });
193 const wallHour = wallHourFormat.format(instant);
194 const isRepeatedHour = [-HOUR_MS, HOUR_MS].some(
195 (offset) => wallHourFormat.format(new Date(instant.getTime() + offset)) === wallHour,
196 );
197​
198 if (!isRepeatedHour) return formatHour(hourFormat, instant, hour12);
199 const zonedHourFormat = dateTimeFormatter("en-US", {
200 ...options,
201 ...hourOptions(hour12),
202 timeZoneName: "short",
203 });
204 return formatHour(zonedHourFormat, instant, hour12);
206​
207export function formatDateTimeShort(instant: string, timeZone?: string, hour12 = true): string {
208 const date = new Date(instant);
209 if (Number.isNaN(date.getTime())) return instant;
210 const dateHourFormat = dateTimeFormatter("en-US", {
211 ...(timeZone === undefined ? {} : { timeZone }),
212 month: "short",
213 day: "numeric",
214 ...hourOptions(hour12),
215 });
216 return formatHour(dateHourFormat, date, hour12);
218​
219export function formatRelativeHourShort(
220 hourStart: string,
221 relativeTo: string,
222 timeZone?: string,
223 hour12 = true,
224): string {
225 const instant = new Date(hourStart);
226 const reference = new Date(relativeTo);
227 if (Number.isNaN(instant.getTime()) || Number.isNaN(reference.getTime())) {
228 return formatDateTimeShort(hourStart, timeZone, hour12);
229 }
230​
231 const dayFormat = dateTimeFormatter("en-CA", {
232 ...(timeZone === undefined ? {} : { timeZone }),
233 year: "numeric",
234 month: "2-digit",
235 day: "2-digit",
236 });
237 const instantDay = Date.parse(`${dayFormat.format(instant)}T00:00:00Z`);
238 const referenceDay = Date.parse(`${dayFormat.format(reference)}T00:00:00Z`);
239 const calendarDaysAgo = Math.round((referenceDay - instantDay) / (24 * HOUR_MS));
240 const hour = formatHourShort(hourStart, timeZone, hour12);
241​
242 if (calendarDaysAgo === 0) return `${hour} today`;
243 if (calendarDaysAgo === 1) return `${hour} yesterday`;
244 return formatDateTimeShort(hourStart, timeZone, hour12);
246​
247export function makeWindow(
248 days: number,
249 now = new Date(),
250 resolution: UsageResolution = "day",
251): UsageSummaryInput {
252 let timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
253 let format: Intl.DateTimeFormat;
254 try {
255 format = new Intl.DateTimeFormat("en-CA", {
256 timeZone,
257 year: "numeric",
258 month: "2-digit",
259 day: "2-digit",
260 });
261 } catch {
262 timeZone = "UTC";
263 format = new Intl.DateTimeFormat("en-CA", {
264 timeZone: "UTC",
265 year: "numeric",
266 month: "2-digit",
267 day: "2-digit",
268 });
269 }
270 const untilDay = format.format(now);
271 if (resolution === "hour") {
272 const untilTimeMs = Math.floor(now.getTime() / 60_000) * 60_000;
273 const sinceTimeMs = untilTimeMs - 24 * HOUR_MS;
274 const sinceTime = new Date(sinceTimeMs);
275 const untilTime = new Date(untilTimeMs);
276 return {
277 sinceDay: UsageDay.make(format.format(sinceTime)),
278 untilDay: UsageDay.make(format.format(untilTime)),
279 timeZone,
280 resolution,
281 sinceTime: sinceTime.toISOString(),
282 untilTime: untilTime.toISOString(),
283 };
284 }
285 // Use calendar arithmetic in UTC; fixed millisecond subtraction crosses DST days incorrectly.
286 const [year = 0, month = 1, dayOfMonth = 1] = untilDay
287 .split("-")
288 .map((part) => Number.parseInt(part, 10));
289 const start = new Date(Date.UTC(year, month - 1, dayOfMonth - (days - 1)));
290 return {
291 sinceDay: UsageDay.make(start.toISOString().slice(0, 10)),
292 untilDay: UsageDay.make(untilDay),
293 timeZone,
294 resolution,
295 };