main
js 311 lines 8.43 KB
Raw
1 /**
2 * Time utilities for handling user-local time conversion.
3 */
4
5 const TIME_FORMAT_12H = "12h";
6 const TIME_FORMAT_24H = "24h";
7
8 /**
9 * Convert an ISO string to a local time string
10 * @param {string} utcIsoString - ISO time string
11 * @param {Object} options - Formatting options for Intl.DateTimeFormat
12 * @returns {string} Formatted local time string
13 */
14 export function toLocalTime(utcIsoString, options = {}) {
15 if (!utcIsoString) return '';
16
17 const date = utcIsoString instanceof Date ? utcIsoString : new Date(utcIsoString);
18 const defaultOptions = {
19 dateStyle: 'medium',
20 timeStyle: 'medium',
21 timeZone: getUserTimezone(),
22 };
23
24 return new Intl.DateTimeFormat(
25 undefined, // Use browser's locale
26 withUserTimeFormatOptions({ ...defaultOptions, ...options })
27 ).format(date);
28 }
29
30 /**
31 * Convert a Date object to a UTC ISO string.
32 * @param {Date} date - Date object in local time
33 * @returns {string} UTC ISO string
34 */
35 export function toUTCISOString(date) {
36 if (!date) return '';
37 return date.toISOString();
38 }
39
40 /**
41 * Get current time as a UTC ISO string.
42 * @returns {string} Current UTC time in ISO format
43 */
44 export function getCurrentUTCISOString() {
45 return new Date().toISOString();
46 }
47
48 function padNumber(value, width = 2) {
49 return String(value).padStart(width, "0");
50 }
51
52 function getTimeZoneParts(date, timeZone) {
53 const formatter = new Intl.DateTimeFormat("en-US", {
54 timeZone,
55 year: "numeric",
56 month: "2-digit",
57 day: "2-digit",
58 hour: "2-digit",
59 minute: "2-digit",
60 second: "2-digit",
61 hourCycle: "h23",
62 });
63 const parts = Object.fromEntries(
64 formatter.formatToParts(date)
65 .filter((part) => part.type !== "literal")
66 .map((part) => [part.type, part.value])
67 );
68 return {
69 year: Number(parts.year),
70 month: Number(parts.month),
71 day: Number(parts.day),
72 hour: Number(parts.hour),
73 minute: Number(parts.minute),
74 second: Number(parts.second),
75 millisecond: date.getMilliseconds(),
76 };
77 }
78
79 export function getUserDateTimeParts(date = new Date()) {
80 return getTimeZoneParts(date, getUserTimezone());
81 }
82
83 function getTimeZoneOffsetMinutes(date, timeZone, parts = getTimeZoneParts(date, timeZone)) {
84 const asUtc = Date.UTC(
85 parts.year,
86 parts.month - 1,
87 parts.day,
88 parts.hour,
89 parts.minute,
90 parts.second,
91 parts.millisecond,
92 );
93 return Math.round((asUtc - date.getTime()) / 60_000);
94 }
95
96 function formatOffset(offsetMinutes) {
97 const sign = offsetMinutes >= 0 ? "+" : "-";
98 const absOffset = Math.abs(offsetMinutes);
99 return `${sign}${padNumber(Math.floor(absOffset / 60))}:${padNumber(absOffset % 60)}`;
100 }
101
102 function formatPartsAsIso(parts, offsetMinutes) {
103 return [
104 parts.year,
105 "-",
106 padNumber(parts.month),
107 "-",
108 padNumber(parts.day),
109 "T",
110 padNumber(parts.hour),
111 ":",
112 padNumber(parts.minute),
113 ":",
114 padNumber(parts.second),
115 ".",
116 padNumber(parts.millisecond, 3),
117 formatOffset(offsetMinutes),
118 ].join("");
119 }
120
121 function getLocalDateParts(date) {
122 return {
123 year: date.getFullYear(),
124 month: date.getMonth() + 1,
125 day: date.getDate(),
126 hour: date.getHours(),
127 minute: date.getMinutes(),
128 second: date.getSeconds(),
129 millisecond: date.getMilliseconds(),
130 };
131 }
132
133 function getWallClockOffsetMinutes(parts, timeZone) {
134 const wallClockUtc = Date.UTC(
135 parts.year,
136 parts.month - 1,
137 parts.day,
138 parts.hour,
139 parts.minute,
140 parts.second,
141 parts.millisecond,
142 );
143 let offsetMinutes = getTimeZoneOffsetMinutes(new Date(wallClockUtc), timeZone);
144 for (let attempt = 0; attempt < 3; attempt += 1) {
145 const instant = new Date(wallClockUtc - offsetMinutes * 60_000);
146 const nextOffset = getTimeZoneOffsetMinutes(instant, timeZone);
147 if (nextOffset === offsetMinutes) return offsetMinutes;
148 offsetMinutes = nextOffset;
149 }
150 return offsetMinutes;
151 }
152
153 /**
154 * Convert a Date object to an ISO string with the user's local UTC offset.
155 * @param {Date} date - Date object in local time
156 * @returns {string} Local ISO string, e.g. 2026-05-03T10:15:30.000+02:00
157 */
158 export function toUserISOString(date = new Date()) {
159 if (!date) return "";
160 const timeZone = getUserTimezone();
161 const parts = getTimeZoneParts(date, timeZone);
162 const offsetMinutes = getTimeZoneOffsetMinutes(date, timeZone, parts);
163 return formatPartsAsIso(parts, offsetMinutes);
164 }
165
166 /**
167 * Interpret a browser-local Date's visible wall-clock fields in the configured user timezone.
168 * Use this for date/time picker values where the selected calendar fields matter more than
169 * the browser's local instant.
170 * @param {Date} date - Date object whose local fields came from user input
171 * @returns {string} User-timezone ISO string preserving the selected wall-clock fields
172 */
173 export function toUserWallClockISOString(date = new Date()) {
174 if (!date) return "";
175 const timeZone = getUserTimezone();
176 const parts = getLocalDateParts(date);
177 const offsetMinutes = getWallClockOffsetMinutes(parts, timeZone);
178 return formatPartsAsIso(parts, offsetMinutes);
179 }
180
181 /**
182 * Get current time as an ISO string with the user's local UTC offset.
183 * @returns {string}
184 */
185 export function getCurrentUserISOString() {
186 return toUserISOString(new Date());
187 }
188
189 /**
190 * Get current user-local calendar date as YYYY-MM-DD.
191 * @returns {string}
192 */
193 export function getCurrentUserDateString() {
194 const now = new Date();
195 const parts = getTimeZoneParts(now, getUserTimezone());
196 return [
197 parts.year,
198 padNumber(parts.month),
199 padNumber(parts.day),
200 ].join("-");
201 }
202
203 /**
204 * Format an ISO string for display in local time with configurable format
205 * @param {string} utcIsoString - ISO time string
206 * @param {string} format - Format type ('full', 'date', 'time', 'short')
207 * @returns {string} Formatted local time string
208 */
209 export function formatDateTime(utcIsoString, format = 'full') {
210 if (!utcIsoString) return '';
211
212 const date = new Date(utcIsoString);
213 if (Number.isNaN(date.getTime())) return String(utcIsoString);
214
215 const formatOptions = {
216 full: { dateStyle: 'medium', timeStyle: 'medium' },
217 date: { dateStyle: 'medium' },
218 time: { timeStyle: 'medium' },
219 short: { dateStyle: 'short', timeStyle: 'short' }
220 };
221
222 return toLocalTime(date, formatOptions[format] || formatOptions.full);
223 }
224
225 /**
226 * Get the user's local timezone name
227 * @returns {string} Timezone name (e.g., 'America/New_York')
228 */
229 export function getUserTimezone() {
230 const configured = String(globalThis.runtimeInfo?.timezone || "").trim();
231 if (configured && configured !== "auto") return configured;
232 return getBrowserTimezone();
233 }
234
235 export function getBrowserTimezone() {
236 return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
237 }
238
239 export function setConfiguredTimezone(timezone) {
240 globalThis.runtimeInfo = {
241 ...(globalThis.runtimeInfo || {}),
242 timezone: String(timezone || "auto").trim() || "auto",
243 };
244 }
245
246 function normalizeTimeFormat(timeFormat) {
247 return String(timeFormat || "")
248 .trim()
249 .toLowerCase() === TIME_FORMAT_24H
250 ? TIME_FORMAT_24H
251 : TIME_FORMAT_12H;
252 }
253
254 /**
255 * Get the preferred clock display format.
256 * @returns {"12h" | "24h"}
257 */
258 export function getUserTimeFormat() {
259 return normalizeTimeFormat(
260 globalThis.runtimeInfo?.timeFormat || globalThis.runtimeInfo?.time_format
261 );
262 }
263
264 /**
265 * Return whether user-facing times should use AM/PM.
266 * @returns {boolean}
267 */
268 export function getUserHour12() {
269 return getUserTimeFormat() === TIME_FORMAT_12H;
270 }
271
272 export function setConfiguredTimeFormat(timeFormat) {
273 globalThis.runtimeInfo = {
274 ...(globalThis.runtimeInfo || {}),
275 timeFormat: normalizeTimeFormat(timeFormat),
276 };
277 }
278
279 export function withUserTimeFormatOptions(options = {}) {
280 const formatted = { ...options };
281 if (
282 formatted.timeStyle ||
283 formatted.hour ||
284 formatted.minute ||
285 formatted.second
286 ) {
287 formatted.hour12 = getUserHour12();
288 }
289 return formatted;
290 }
291
292 /**
293 * Format a duration in milliseconds to a human-readable string
294 * @param {number} durationMs - Duration in milliseconds
295 * @returns {string} Formatted duration (e.g., '45s', '2m30s', '1h3m2s')
296 */
297 export function formatDuration(durationMs) {
298 if (durationMs == null || durationMs < 0) return '0s';
299
300 // Round total seconds first to avoid "1m60s" when seconds round up to 60
301 const totalSecs = Math.round(durationMs / 1000);
302
303 if (totalSecs < 60) {
304 return `${totalSecs}s`;
305 }
306
307 const hours = Math.floor(totalSecs / 3600);
308 const mins = Math.floor((totalSecs % 3600) / 60);
309 const secs = totalSecs % 60;
310 return hours ? `${hours}h${mins}m${secs}s` : `${mins}m${secs}s`;
311 }