Skip to content
Source
Sign inSign up

timestampFormat.ts

347 lines11.6 KB

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 };
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 }
35​
36function readHostSystemLocale(): string | null {
37 if (typeof window === "undefined") return null;
38 return window.desktopBridge?.getSystemLocale() ?? null;
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 }
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;
91​
92export function resolveHour12(timestampFormat: TimestampFormat): boolean {
93 return getTimestampFormatter(timestampFormat, false).resolvedOptions().hour12 ?? false;
95​
96export function parseTimestampDate(isoDate: string): Date | null {
97 const date = new Date(isoDate);
98 return Number.isNaN(date.getTime()) ? null : date;
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 }
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}`;
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);
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;
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 };
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);
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}`;
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}`;
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;
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;
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);
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" };
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);
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;
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`;
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`;