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
23function resolveTimestampLocale(systemLocale: string | null | undefined): string | undefined {
24 const tag = systemLocale?.trim();
25 if (!tag) return undefined;
26
27 try {
28 // Reject structurally invalid tags; let ICU fall back for valid tags it lacks data for.
29 Intl.DateTimeFormat.supportedLocalesOf([tag]);
30 return tag;
31 } catch {
32 return undefined;
33 }
34}
35
36function readHostSystemLocale(): string | null {
37 if (typeof window === "undefined") return null;
38 return window.desktopBridge?.getSystemLocale() ?? null;
39}
40
41const timestampLocale = resolveTimestampLocale(readHostSystemLocale());
42
43const WEEKDAY_INDEXES = [0, 1, 2, 3, 4, 5, 6] as const;
44type WeekdayIndex = (typeof WEEKDAY_INDEXES)[number];
45
46type LocaleWithWeekInfo = Intl.Locale & {
47 readonly weekInfo?: { readonly firstDay: number };
48 getWeekInfo?: () => { readonly firstDay: number };
49};
50
51/**
52 * First weekday of a locale as a `Date#getDay` index (0 is Sunday), or
53 * `undefined` when the runtime has no week data, so callers keep their own
54 * default.
55 */
56function resolveWeekStartsOn(locale: string | undefined): WeekdayIndex | undefined {
57 try {
58 const resolved: LocaleWithWeekInfo = new Intl.Locale(
59 locale ?? Intl.DateTimeFormat().resolvedOptions().locale,
60 );
61 // Week info counts Monday as 1 and Sunday as 7.
62 const firstDay = resolved.getWeekInfo?.().firstDay ?? resolved.weekInfo?.firstDay;
63 return firstDay === undefined ? undefined : WEEKDAY_INDEXES[firstDay % 7];
64 } catch {
65 return undefined;
66 }
67}
68
69export const weekStartsOn = resolveWeekStartsOn(timestampLocale);
70
71const timestampFormatterCache = new Map<string, Intl.DateTimeFormat>();
72
73function getTimestampFormatter(
74 timestampFormat: TimestampFormat,
75 includeSeconds: boolean,
76 timeZone?: string,
77): Intl.DateTimeFormat {
78 const cacheKey = `${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}:${timeZone ?? ""}`;
79 const cachedFormatter = timestampFormatterCache.get(cacheKey);
80 if (cachedFormatter) {
81 return cachedFormatter;
82 }
83
84 const formatter = new Intl.DateTimeFormat(timestampLocale, {
85 ...getTimestampFormatOptions(timestampFormat, includeSeconds),
86 ...(timeZone ? { timeZone } : {}),
87 });
88 timestampFormatterCache.set(cacheKey, formatter);
89 return formatter;
90}
91
92export function resolveHour12(timestampFormat: TimestampFormat): boolean {
93 return getTimestampFormatter(timestampFormat, false).resolvedOptions().hour12 ?? false;
94}
95
96export function parseTimestampDate(isoDate: string): Date | null {
97 const date = new Date(isoDate);
98 return Number.isNaN(date.getTime()) ? null : date;
99}
100
101// Deliberately not the host locale: the tooltip's ordinal suffix and
102// day-before-month order below are English, so a localized month alone would
103// read "4th Juni 2026".
104const monthNameFormatter = new Intl.DateTimeFormat("en-US", { month: "long" });
105
106function ordinalSuffix(day: number): string {
107 const lastTwo = day % 100;
108 if (lastTwo >= 11 && lastTwo <= 13) return "th";
109 switch (day % 10) {
110 case 1:
111 return "st";
112 case 2:
113 return "nd";
114 case 3:
115 return "rd";
116 default:
117 return "th";
118 }
119}
120
121export function formatChatTimestampTooltip(
122 isoDate: string,
123 timestampFormat: TimestampFormat,
124): string {
125 const date = parseTimestampDate(isoDate);
126 if (!date) return "";
127 const time = formatShortTimestamp(isoDate, timestampFormat);
128 const day = date.getDate();
129 const month = monthNameFormatter.format(date);
130 const year = date.getFullYear();
131 return `${time}, ${day}${ordinalSuffix(day)} ${month} ${year}`;
132}
133
134export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string {
135 const date = parseTimestampDate(isoDate);
136 if (!date) return "";
137 return getTimestampFormatter(timestampFormat, false).format(date);
138}
139
140const numericDateOptions: Intl.DateTimeFormatOptions = { month: "numeric", day: "numeric" };
141const numericDateWithYearOptions: Intl.DateTimeFormatOptions = {
142 ...numericDateOptions,
143 year: "numeric",
144};
145const numericDateFormatter = new Intl.DateTimeFormat(timestampLocale, numericDateOptions);
146const numericDateWithYearFormatter = new Intl.DateTimeFormat(
147 timestampLocale,
148 numericDateWithYearOptions,
149);
150
151interface CalendarDay {
152 year: number;
153 /** Days since the epoch, so differences are whole days across DST shifts. */
154 index: number;
155}
156
157function calendarDay(date: Date, timeZone: string | undefined): CalendarDay {
158 if (!timeZone) {
159 const year = date.getFullYear();
160 return { year, index: Date.UTC(year, date.getMonth(), date.getDate()) / 86_400_000 };
161 }
162 const parts = new Intl.DateTimeFormat("en-US", {
163 timeZone,
164 year: "numeric",
165 month: "numeric",
166 day: "numeric",
167 }).formatToParts(date);
168 const part = (type: Intl.DateTimeFormatPartTypes) =>
169 Number(parts.find((entry) => entry.type === type)?.value);
170 const year = part("year");
171 return { year, index: Date.UTC(year, part("month") - 1, part("day")) / 86_400_000 };
172}
173
174function formatNumericDate(date: Date, sameYear: boolean, timeZone: string | undefined): string {
175 if (!timeZone) {
176 return (sameYear ? numericDateFormatter : numericDateWithYearFormatter).format(date);
177 }
178 return new Intl.DateTimeFormat(timestampLocale, {
179 ...(sameYear ? numericDateOptions : numericDateWithYearOptions),
180 timeZone,
181 }).format(date);
182}
183
184export function formatDayAwareTimestamp(
185 isoDate: string,
186 timestampFormat: TimestampFormat,
187 nowMs: number = Date.now(),
188 timeZone?: string,
189): string {
190 const date = parseTimestampDate(isoDate);
191 if (!date) return "";
192 const time = getTimestampFormatter(timestampFormat, false, timeZone).format(date);
193
194 const today = calendarDay(new Date(nowMs), timeZone);
195 const messageDay = calendarDay(date, timeZone);
196 const dayDiff = today.index - messageDay.index;
197
198 if (dayDiff <= 0) return time;
199 if (dayDiff === 1) return `yesterday at ${time}`;
200 return `${formatNumericDate(date, messageDay.year === today.year, timeZone)} ${time}`;
201}
202
203/** `timeZone` overrides the zone the formatters cached when they were built. */
204export function formatUpcomingTimestamp(
205 isoDate: string,
206 timestampFormat: TimestampFormat,
207 nowMs: number = Date.now(),
208 timeZone?: string,
209): string {
210 const date = parseTimestampDate(isoDate);
211 if (!date) return "";
212 const time = getTimestampFormatter(timestampFormat, false, timeZone).format(date);
213
214 const today = calendarDay(new Date(nowMs), timeZone);
215 const targetDay = calendarDay(date, timeZone);
216 const dayDiff = targetDay.index - today.index;
217
218 if (dayDiff < 0) return formatDayAwareTimestamp(isoDate, timestampFormat, nowMs, timeZone);
219 if (dayDiff === 0) return time;
220 if (dayDiff === 1) return `tomorrow at ${time}`;
221 return `${formatNumericDate(date, targetDay.year === today.year, timeZone)} ${time}`;
222}
223
224type RelativeTimeParts = { value: string; suffix: string | null };
225export type RelativeTimeState =
226 | { status: "missing" }
227 | { status: "invalid" }
228 | { status: "relative"; value: string; suffix: string | null };
229
230const MINUTE_MS = 60_000;
231const HOUR_MS = 3_600_000;
232const DAY_MS = 86_400_000;
233
234function relativeTimeUnitMs(elapsedMs: number): number {
235 if (elapsedMs < HOUR_MS) return MINUTE_MS;
236 if (elapsedMs < DAY_MS) return HOUR_MS;
237 return DAY_MS;
238}
239
240/** Elapsed time floored to formatRelativeTime precision; equal labels yield equal values. */
241export function relativeTimeElapsedMs(isoDate: string, nowMs: number = Date.now()): number | null {
242 const date = parseTimestampDate(isoDate);
243 if (!date) return null;
244 const elapsedMs = Math.max(0, nowMs - date.getTime());
245 const unitMs = relativeTimeUnitMs(elapsedMs);
246 return Math.floor(elapsedMs / unitMs) * unitMs;
247}
248
249export function msUntilRelativeTimeChange(
250 isoDate: string,
251 nowMs: number = Date.now(),
252): number | null {
253 const date = parseTimestampDate(isoDate);
254 if (!date) return null;
255 const elapsedMs = Math.max(0, nowMs - date.getTime());
256 const unitMs = relativeTimeUnitMs(elapsedMs);
257 const nextChangeMs = date.getTime() + (Math.floor(elapsedMs / unitMs) + 1) * unitMs;
258 // Delays above 2^31-1 fire immediately; cap safely and let callers reschedule.
259 return Math.min(nextChangeMs - nowMs, DAY_MS);
260}
261
262export function formatRelativeElapsed(elapsedMs: number): RelativeTimeParts {
263 const seconds = Math.floor(elapsedMs / 1000);
264 if (seconds < 60) return { value: "just now", suffix: null };
265 const minutes = Math.floor(seconds / 60);
266 if (minutes < 60) return { value: `${minutes}m`, suffix: "ago" };
267 const hours = Math.floor(minutes / 60);
268 if (hours < 24) return { value: `${hours}h`, suffix: "ago" };
269 const days = Math.floor(hours / 24);
270 return { value: `${days}d`, suffix: "ago" };
271}
272
273export function formatRelativeTime(
274 isoDate: string,
275 nowMs: number = Date.now(),
276): RelativeTimeParts | null {
277 const elapsedMs = relativeTimeElapsedMs(isoDate, nowMs);
278 return elapsedMs === null ? null : formatRelativeElapsed(elapsedMs);
279}
280
281export function formatRelativeTimeLabel(isoDate: string, nowMs: number = Date.now()) {
282 const relative = formatRelativeTime(isoDate, nowMs);
283 if (!relative) return "";
284 return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value;
285}
286
287export function formatElapsedDurationLabel(isoDate: string, nowMs: number = Date.now()): string {
288 const date = parseTimestampDate(isoDate);
289 if (!date) return "";
290 const diffMs = nowMs - date.getTime();
291 if (diffMs <= 0) return "just now";
292
293 const seconds = Math.floor(diffMs / 1000);
294 if (seconds < 5) return "just now";
295 if (seconds < 60) return `${seconds}s`;
296
297 const minutes = Math.floor(seconds / 60);
298 if (minutes < 60) return `${minutes}m`;
299
300 const hours = Math.floor(minutes / 60);
301 if (hours < 24) return `${hours}h`;
302
303 const days = Math.floor(hours / 24);
304 return `${days}d`;
305}
306
307/** Pass nowMs to match a parent-driven render tick. */
308export function formatExpiresInLabel(isoDate: string, nowMs: number = Date.now()): string {
309 const date = parseTimestampDate(isoDate);
310 if (!date) return "";
311 const diffMs = date.getTime() - nowMs;
312 if (diffMs <= 0) return "Expired";
313
314 const totalSeconds = Math.floor(diffMs / 1000);
315 if (totalSeconds < 5) return "Expires in a moment";
316 if (totalSeconds < 60) return `Expires in ${totalSeconds}s`;
317
318 if (totalSeconds < 3600) {
319 const minutes = Math.floor(totalSeconds / 60);
320 const seconds = totalSeconds % 60;
321 return seconds === 0 ? `Expires in ${minutes}m` : `Expires in ${minutes}m ${seconds}s`;
322 }
323
324 if (totalSeconds < 86_400) {
325 const hours = Math.floor(totalSeconds / 3600);
326 const rem = totalSeconds % 3600;
327 const minutes = Math.floor(rem / 60);
328 const seconds = rem % 60;
329 const parts = [`${hours}h`];
330 if (minutes > 0) parts.push(`${minutes}m`);
331 if (seconds > 0) parts.push(`${seconds}s`);
332 return `Expires in ${parts.join(" ")}`;
333 }
334
335 const days = Math.floor(totalSeconds / 86_400);
336 const remAfterDays = totalSeconds % 86_400;
337 if (remAfterDays === 0) return `Expires in ${days}d`;
338 const hours = Math.floor(remAfterDays / 3600);
339 const rem = remAfterDays % 3600;
340 const minutes = Math.floor(rem / 60);
341 const seconds = rem % 60;
342 const tail: string[] = [];
343 if (hours > 0) tail.push(`${hours}h`);
344 if (minutes > 0) tail.push(`${minutes}m`);
345 if (seconds > 0) tail.push(`${seconds}s`);
346 return tail.length > 0 ? `Expires in ${days}d ${tail.join(" ")}` : `Expires in ${days}d`;
347}