1import { type TimestampFormat } from "@t3tools/contracts/settings";
2
3function getTimestampFormatOptions(
4 timestampFormat: TimestampFormat,
5 includeSeconds: boolean,
6): Intl.DateTimeFormatOptions {
7 const baseOptions: Intl.DateTimeFormatOptions = {
8 hour: "numeric",
9 minute: "2-digit",
10 ...(includeSeconds ? { second: "2-digit" } : {}),
11 };
12
13 if (timestampFormat === "locale") {
14 return baseOptions;
15 }
16
17 return {
18 ...baseOptions,
19 hour12: timestampFormat === "12-hour",
20 };
21}
22
23/**
24 * Pick the locale to format wall-clock times in, given the locale the host
25 * reports. Hosts that report nothing fall back to `undefined`, which is the
26 * runtime default and the right answer in a browser.
27 *
28 * A host reports a locale only when it knows better than the runtime does —
29 * see `getSystemLocale` on the desktop bridge for why desktop does.
30 */
31export function resolveTimestampLocale(
32 systemLocale: string | null | undefined,
33): string | undefined {
34 const tag = systemLocale?.trim();
35 if (!tag) return undefined;
36
37 try {
38 // Every timestamp in the UI runs through this formatter, so a tag the host
39 // could not normalize falls back rather than throwing. Throws on a
40 // structurally invalid tag; a well-formed tag ICU has no data for resolves
41 // here and is left to ICU's own fallback.
42 Intl.DateTimeFormat.supportedLocalesOf([tag]);
43 return tag;
44 } catch {
45 return undefined;
46 }
47}
48
49function readHostSystemLocale(): string | null {
50 if (typeof window === "undefined") return null;
51 return window.desktopBridge?.getSystemLocale?.() ?? null;
52}
53
54const timestampLocale = resolveTimestampLocale(readHostSystemLocale());
55
56const WEEKDAY_INDEXES = [0, 1, 2, 3, 4, 5, 6] as const;
57type WeekdayIndex = (typeof WEEKDAY_INDEXES)[number];
58
59type LocaleWithWeekInfo = Intl.Locale & {
60 readonly weekInfo?: { readonly firstDay: number };
61 getWeekInfo?: () => { readonly firstDay: number };
62};
63
64/**
65 * First weekday of a locale as a `Date#getDay` index (0 is Sunday), or
66 * `undefined` when the runtime has no week data, so callers keep their own
67 * default. Without a locale it reads the runtime's.
68 */
69export function resolveWeekStartsOn(locale: string | undefined): WeekdayIndex | undefined {
70 try {
71 const resolved: LocaleWithWeekInfo = new Intl.Locale(
72 locale ?? Intl.DateTimeFormat().resolvedOptions().locale,
73 );
74 // Week info counts Monday as 1 and Sunday as 7.
75 const firstDay = resolved.getWeekInfo?.().firstDay ?? resolved.weekInfo?.firstDay;
76 return firstDay === undefined ? undefined : WEEKDAY_INDEXES[firstDay % 7];
77 } catch {
78 return undefined;
79 }
80}
81
82/** Week start for calendars, from the same locale timestamps are shown in. */
83export const weekStartsOn = resolveWeekStartsOn(timestampLocale);
84
85const timestampFormatterCache = new Map<string, Intl.DateTimeFormat>();
86
87function getTimestampFormatter(
88 timestampFormat: TimestampFormat,
89 includeSeconds: boolean,
90): Intl.DateTimeFormat {
91 const cacheKey = `${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}`;
92 const cachedFormatter = timestampFormatterCache.get(cacheKey);
93 if (cachedFormatter) {
94 return cachedFormatter;
95 }
96
97 const formatter = new Intl.DateTimeFormat(
98 timestampLocale,
99 getTimestampFormatOptions(timestampFormat, includeSeconds),
100 );
101 timestampFormatterCache.set(cacheKey, formatter);
102 return formatter;
103}
104
105export function parseTimestampDate(isoDate: string): Date | null {
106 const date = new Date(isoDate);
107 return Number.isNaN(date.getTime()) ? null : date;
108}
109
110export function formatTimestamp(isoDate: string, timestampFormat: TimestampFormat): string {
111 const date = parseTimestampDate(isoDate);
112 if (!date) return "";
113 return getTimestampFormatter(timestampFormat, true).format(date);
114}
115
116// Deliberately not the host locale: the tooltip's ordinal suffix and
117// day-before-month order below are English, so a localized month alone would
118// read "4th Juni 2026". Localizing the whole label is a separate change.
119const monthNameFormatter = new Intl.DateTimeFormat("en-US", { month: "long" });
120
121function ordinalSuffix(day: number): string {
122 const lastTwo = day % 100;
123 if (lastTwo >= 11 && lastTwo <= 13) return "th";
124 switch (day % 10) {
125 case 1:
126 return "st";
127 case 2:
128 return "nd";
129 case 3:
130 return "rd";
131 default:
132 return "th";
133 }
134}
135
136/**
137 * Long-form tooltip label, e.g. `12:04, 4th June`.
138 * Renders the wall-clock time without seconds followed by the ordinal day and month name.
139 */
140export function formatChatTimestampTooltip(
141 isoDate: string,
142 timestampFormat: TimestampFormat,
143): string {
144 const date = parseTimestampDate(isoDate);
145 if (!date) return "";
146 const time = formatShortTimestamp(isoDate, timestampFormat);
147 const day = date.getDate();
148 const month = monthNameFormatter.format(date);
149 const year = date.getFullYear();
150 return `${time}, ${day}${ordinalSuffix(day)} ${month} ${year}`;
151}
152
153export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string {
154 const date = parseTimestampDate(isoDate);
155 if (!date) return "";
156 return getTimestampFormatter(timestampFormat, false).format(date);
157}
158
159const numericDateFormatter = new Intl.DateTimeFormat(timestampLocale, {
160 month: "numeric",
161 day: "numeric",
162});
163const numericDateWithYearFormatter = new Intl.DateTimeFormat(timestampLocale, {
164 month: "numeric",
165 day: "numeric",
166 year: "numeric",
167});
168
169/**
170 * Chat timestamp that adds the date once the message is no longer from today:
171 * today `12:34 PM`, yesterday `yesterday at 12:34 PM`, older `8/13 12:34 PM`
172 * (locale digit order), with the year included once the calendar year differs.
173 * Boundaries are local calendar days, not 24-hour windows.
174 */
175export function formatDayAwareTimestamp(
176 isoDate: string,
177 timestampFormat: TimestampFormat,
178 nowMs: number = Date.now(),
179): string {
180 const date = parseTimestampDate(isoDate);
181 if (!date) return "";
182 const time = getTimestampFormatter(timestampFormat, false).format(date);
183
184 const now = new Date(nowMs);
185 const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
186 const startOfMessageDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
187 // Round so DST-shifted 23/25 hour days still count as whole days.
188 const dayDiff = Math.round((startOfToday - startOfMessageDay) / 86_400_000);
189
190 if (dayDiff <= 0) return time;
191 if (dayDiff === 1) return `yesterday at ${time}`;
192 const dateFormatter =
193 date.getFullYear() === now.getFullYear() ? numericDateFormatter : numericDateWithYearFormatter;
194 return `${dateFormatter.format(date)} ${time}`;
195}
196
197/**
198 * The forward-looking counterpart of {@link formatDayAwareTimestamp} for an
199 * instant that has not happened yet (a usage-limit reset): today `12:34 PM`,
200 * tomorrow `tomorrow at 12:34 PM`, later `8/13 12:34 PM`.
201 */
202export function formatUpcomingTimestamp(
203 isoDate: string,
204 timestampFormat: TimestampFormat,
205 nowMs: number = Date.now(),
206): string {
207 const date = parseTimestampDate(isoDate);
208 if (!date) return "";
209 const time = getTimestampFormatter(timestampFormat, false).format(date);
210
211 const now = new Date(nowMs);
212 const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
213 const startOfTargetDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
214 const dayDiff = Math.round((startOfTargetDay - startOfToday) / 86_400_000);
215
216 if (dayDiff < 0) return formatDayAwareTimestamp(isoDate, timestampFormat, nowMs);
217 if (dayDiff === 0) return time;
218 if (dayDiff === 1) return `tomorrow at ${time}`;
219 const dateFormatter =
220 date.getFullYear() === now.getFullYear() ? numericDateFormatter : numericDateWithYearFormatter;
221 return `${dateFormatter.format(date)} ${time}`;
222}
223
224/**
225 * Format a relative time string from an ISO date.
226 * Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }`
227 * so callers can style the numeric portion independently.
228 */
229type RelativeTimeParts = { value: string; suffix: string | null };
230export type RelativeTimeState =
231 | { status: "missing" }
232 | { status: "invalid" }
233 | { status: "relative"; value: string; suffix: string | null };
234
235export function formatRelativeTime(isoDate: string): RelativeTimeParts | null {
236 const date = parseTimestampDate(isoDate);
237 if (!date) return null;
238 const diffMs = Date.now() - date.getTime();
239 if (diffMs < 0) return { value: "just now", suffix: null };
240 const seconds = Math.floor(diffMs / 1000);
241 if (seconds < 60) return { value: "just now", suffix: null };
242 const minutes = Math.floor(seconds / 60);
243 if (minutes < 60) return { value: `${minutes}m`, suffix: "ago" };
244 const hours = Math.floor(minutes / 60);
245 if (hours < 24) return { value: `${hours}h`, suffix: "ago" };
246 const days = Math.floor(hours / 24);
247 return { value: `${days}d`, suffix: "ago" };
248}
249
250export function formatRelativeTimeLabel(isoDate: string) {
251 const relative = formatRelativeTime(isoDate);
252 if (!relative) return "";
253 return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value;
254}
255
256export function getRelativeTimeState(isoDate: string | null): RelativeTimeState {
257 if (!isoDate) return { status: "missing" };
258 const relative = formatRelativeTime(isoDate);
259 if (!relative) return { status: "invalid" };
260 return { status: "relative", ...relative };
261}
262
263/**
264 * Relative elapsed duration since an ISO instant, without an "ago" suffix.
265 * Useful for labels like "Connected for 3m".
266 */
267export function formatElapsedDurationLabel(isoDate: string, nowMs: number = Date.now()): string {
268 const date = parseTimestampDate(isoDate);
269 if (!date) return "";
270 const diffMs = nowMs - date.getTime();
271 if (diffMs <= 0) return "just now";
272
273 const seconds = Math.floor(diffMs / 1000);
274 if (seconds < 5) return "just now";
275 if (seconds < 60) return `${seconds}s`;
276
277 const minutes = Math.floor(seconds / 60);
278 if (minutes < 60) return `${minutes}m`;
279
280 const hours = Math.floor(minutes / 60);
281 if (hours < 24) return `${hours}h`;
282
283 const days = Math.floor(hours / 24);
284 return `${days}d`;
285}
286
287/**
288 * Relative time until an ISO instant (e.g. expiry). Mirrors {@link formatRelativeTime} but for future times.
289 */
290export function formatRelativeTimeUntil(isoDate: string): RelativeTimeParts | null {
291 const date = parseTimestampDate(isoDate);
292 if (!date) return null;
293 const diffMs = date.getTime() - Date.now();
294 if (diffMs <= 0) return { value: "Expired", suffix: null };
295 const seconds = Math.floor(diffMs / 1000);
296 if (seconds < 5) return { value: "Soon", suffix: null };
297 if (seconds < 60) return { value: `${seconds}s`, suffix: "left" };
298 const minutes = Math.floor(seconds / 60);
299 if (minutes < 60) return { value: `${minutes}m`, suffix: "left" };
300 const hours = Math.floor(minutes / 60);
301 if (hours < 24) return { value: `${hours}h`, suffix: "left" };
302 const days = Math.floor(hours / 24);
303 return { value: `${days}d`, suffix: "left" };
304}
305
306export function formatRelativeTimeUntilLabel(isoDate: string): string {
307 const relative = formatRelativeTimeUntil(isoDate);
308 if (!relative) return "";
309 return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value;
310}
311
312/**
313 * Countdown for a future instant (e.g. link expiry): "Expires in 4m 12s", with second precision under one hour.
314 * Pass `nowMs` when a parent tick drives re-renders so the diff matches that snapshot.
315 */
316export function formatExpiresInLabel(isoDate: string, nowMs: number = Date.now()): string {
317 const date = parseTimestampDate(isoDate);
318 if (!date) return "";
319 const diffMs = date.getTime() - nowMs;
320 if (diffMs <= 0) return "Expired";
321
322 const totalSeconds = Math.floor(diffMs / 1000);
323 if (totalSeconds < 5) return "Expires in a moment";
324 if (totalSeconds < 60) return `Expires in ${totalSeconds}s`;
325
326 if (totalSeconds < 3600) {
327 const minutes = Math.floor(totalSeconds / 60);
328 const seconds = totalSeconds % 60;
329 return seconds === 0 ? `Expires in ${minutes}m` : `Expires in ${minutes}m ${seconds}s`;
330 }
331
332 if (totalSeconds < 86_400) {
333 const hours = Math.floor(totalSeconds / 3600);
334 const rem = totalSeconds % 3600;
335 const minutes = Math.floor(rem / 60);
336 const seconds = rem % 60;
337 const parts = [`${hours}h`];
338 if (minutes > 0) parts.push(`${minutes}m`);
339 if (seconds > 0) parts.push(`${seconds}s`);
340 return `Expires in ${parts.join(" ")}`;
341 }
342
343 const days = Math.floor(totalSeconds / 86_400);
344 const remAfterDays = totalSeconds % 86_400;
345 if (remAfterDays === 0) return `Expires in ${days}d`;
346 const hours = Math.floor(remAfterDays / 3600);
347 const rem = remAfterDays % 3600;
348 const minutes = Math.floor(rem / 60);
349 const seconds = rem % 60;
350 const tail: string[] = [];
351 if (hours > 0) tail.push(`${hours}h`);
352 if (minutes > 0) tail.push(`${minutes}m`);
353 if (seconds > 0) tail.push(`${seconds}s`);
354 return tail.length > 0 ? `Expires in ${days}d ${tail.join(" ")}` : `Expires in ${days}d`;
355}