File size: 8,634 Bytes
6918275
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
/**
 * Time utilities for handling user-local time conversion.
 */

const TIME_FORMAT_12H = "12h";
const TIME_FORMAT_24H = "24h";

/**
 * Convert an ISO string to a local time string
 * @param {string} utcIsoString - ISO time string
 * @param {Object} options - Formatting options for Intl.DateTimeFormat
 * @returns {string} Formatted local time string
 */
export function toLocalTime(utcIsoString, options = {}) {
  if (!utcIsoString) return '';

  const date = utcIsoString instanceof Date ? utcIsoString : new Date(utcIsoString);
  const defaultOptions = {
    dateStyle: 'medium',
    timeStyle: 'medium',
    timeZone: getUserTimezone(),
  };

  return new Intl.DateTimeFormat(
    undefined, // Use browser's locale
    withUserTimeFormatOptions({ ...defaultOptions, ...options })
  ).format(date);
}

/**
 * Convert a Date object to a UTC ISO string.
 * @param {Date} date - Date object in local time
 * @returns {string} UTC ISO string
 */
export function toUTCISOString(date) {
  if (!date) return '';
  return date.toISOString();
}

/**
 * Get current time as a UTC ISO string.
 * @returns {string} Current UTC time in ISO format
 */
export function getCurrentUTCISOString() {
  return new Date().toISOString();
}

function padNumber(value, width = 2) {
  return String(value).padStart(width, "0");
}

function getTimeZoneParts(date, timeZone) {
  const formatter = new Intl.DateTimeFormat("en-US", {
    timeZone,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
    hour: "2-digit",
    minute: "2-digit",
    second: "2-digit",
    hourCycle: "h23",
  });
  const parts = Object.fromEntries(
    formatter.formatToParts(date)
      .filter((part) => part.type !== "literal")
      .map((part) => [part.type, part.value])
  );
  return {
    year: Number(parts.year),
    month: Number(parts.month),
    day: Number(parts.day),
    hour: Number(parts.hour),
    minute: Number(parts.minute),
    second: Number(parts.second),
    millisecond: date.getMilliseconds(),
  };
}

export function getUserDateTimeParts(date = new Date()) {
  return getTimeZoneParts(date, getUserTimezone());
}

function getTimeZoneOffsetMinutes(date, timeZone, parts = getTimeZoneParts(date, timeZone)) {
  const asUtc = Date.UTC(
    parts.year,
    parts.month - 1,
    parts.day,
    parts.hour,
    parts.minute,
    parts.second,
    parts.millisecond,
  );
  return Math.round((asUtc - date.getTime()) / 60_000);
}

function formatOffset(offsetMinutes) {
  const sign = offsetMinutes >= 0 ? "+" : "-";
  const absOffset = Math.abs(offsetMinutes);
  return `${sign}${padNumber(Math.floor(absOffset / 60))}:${padNumber(absOffset % 60)}`;
}

function formatPartsAsIso(parts, offsetMinutes) {
  return [
    parts.year,
    "-",
    padNumber(parts.month),
    "-",
    padNumber(parts.day),
    "T",
    padNumber(parts.hour),
    ":",
    padNumber(parts.minute),
    ":",
    padNumber(parts.second),
    ".",
    padNumber(parts.millisecond, 3),
    formatOffset(offsetMinutes),
  ].join("");
}

function getLocalDateParts(date) {
  return {
    year: date.getFullYear(),
    month: date.getMonth() + 1,
    day: date.getDate(),
    hour: date.getHours(),
    minute: date.getMinutes(),
    second: date.getSeconds(),
    millisecond: date.getMilliseconds(),
  };
}

function getWallClockOffsetMinutes(parts, timeZone) {
  const wallClockUtc = Date.UTC(
    parts.year,
    parts.month - 1,
    parts.day,
    parts.hour,
    parts.minute,
    parts.second,
    parts.millisecond,
  );
  let offsetMinutes = getTimeZoneOffsetMinutes(new Date(wallClockUtc), timeZone);
  for (let attempt = 0; attempt < 3; attempt += 1) {
    const instant = new Date(wallClockUtc - offsetMinutes * 60_000);
    const nextOffset = getTimeZoneOffsetMinutes(instant, timeZone);
    if (nextOffset === offsetMinutes) return offsetMinutes;
    offsetMinutes = nextOffset;
  }
  return offsetMinutes;
}

/**
 * Convert a Date object to an ISO string with the user's local UTC offset.
 * @param {Date} date - Date object in local time
 * @returns {string} Local ISO string, e.g. 2026-05-03T10:15:30.000+02:00
 */
export function toUserISOString(date = new Date()) {
  if (!date) return "";
  const timeZone = getUserTimezone();
  const parts = getTimeZoneParts(date, timeZone);
  const offsetMinutes = getTimeZoneOffsetMinutes(date, timeZone, parts);
  return formatPartsAsIso(parts, offsetMinutes);
}

/**
 * Interpret a browser-local Date's visible wall-clock fields in the configured user timezone.
 * Use this for date/time picker values where the selected calendar fields matter more than
 * the browser's local instant.
 * @param {Date} date - Date object whose local fields came from user input
 * @returns {string} User-timezone ISO string preserving the selected wall-clock fields
 */
export function toUserWallClockISOString(date = new Date()) {
  if (!date) return "";
  const timeZone = getUserTimezone();
  const parts = getLocalDateParts(date);
  const offsetMinutes = getWallClockOffsetMinutes(parts, timeZone);
  return formatPartsAsIso(parts, offsetMinutes);
}

/**
 * Get current time as an ISO string with the user's local UTC offset.
 * @returns {string}
 */
export function getCurrentUserISOString() {
  return toUserISOString(new Date());
}

/**
 * Get current user-local calendar date as YYYY-MM-DD.
 * @returns {string}
 */
export function getCurrentUserDateString() {
  const now = new Date();
  const parts = getTimeZoneParts(now, getUserTimezone());
  return [
    parts.year,
    padNumber(parts.month),
    padNumber(parts.day),
  ].join("-");
}

/**
 * Format an ISO string for display in local time with configurable format
 * @param {string} utcIsoString - ISO time string
 * @param {string} format - Format type ('full', 'date', 'time', 'short')
 * @returns {string} Formatted local time string
 */
export function formatDateTime(utcIsoString, format = 'full') {
  if (!utcIsoString) return '';

  const date = new Date(utcIsoString);
  if (Number.isNaN(date.getTime())) return String(utcIsoString);

  const formatOptions = {
    full: { dateStyle: 'medium', timeStyle: 'medium' },
    date: { dateStyle: 'medium' },
    time: { timeStyle: 'medium' },
    short: { dateStyle: 'short', timeStyle: 'short' }
  };

  return toLocalTime(date, formatOptions[format] || formatOptions.full);
}

/**
 * Get the user's local timezone name
 * @returns {string} Timezone name (e.g., 'America/New_York')
 */
export function getUserTimezone() {
  const configured = String(globalThis.runtimeInfo?.timezone || "").trim();
  if (configured && configured !== "auto") return configured;
  return getBrowserTimezone();
}

export function getBrowserTimezone() {
  return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
}

export function setConfiguredTimezone(timezone) {
  globalThis.runtimeInfo = {
    ...(globalThis.runtimeInfo || {}),
    timezone: String(timezone || "auto").trim() || "auto",
  };
}

function normalizeTimeFormat(timeFormat) {
  return String(timeFormat || "")
    .trim()
    .toLowerCase() === TIME_FORMAT_24H
    ? TIME_FORMAT_24H
    : TIME_FORMAT_12H;
}

/**
 * Get the preferred clock display format.
 * @returns {"12h" | "24h"}
 */
export function getUserTimeFormat() {
  return normalizeTimeFormat(
    globalThis.runtimeInfo?.timeFormat || globalThis.runtimeInfo?.time_format
  );
}

/**
 * Return whether user-facing times should use AM/PM.
 * @returns {boolean}
 */
export function getUserHour12() {
  return getUserTimeFormat() === TIME_FORMAT_12H;
}

export function setConfiguredTimeFormat(timeFormat) {
  globalThis.runtimeInfo = {
    ...(globalThis.runtimeInfo || {}),
    timeFormat: normalizeTimeFormat(timeFormat),
  };
}

export function withUserTimeFormatOptions(options = {}) {
  const formatted = { ...options };
  if (
    formatted.timeStyle ||
    formatted.hour ||
    formatted.minute ||
    formatted.second
  ) {
    formatted.hour12 = getUserHour12();
  }
  return formatted;
}

/**
 * Format a duration in milliseconds to a human-readable string
 * @param {number} durationMs - Duration in milliseconds
 * @returns {string} Formatted duration (e.g., '45s', '2m30s', '1h3m2s')
 */
export function formatDuration(durationMs) {
  if (durationMs == null || durationMs < 0) return '0s';

  // Round total seconds first to avoid "1m60s" when seconds round up to 60
  const totalSecs = Math.round(durationMs / 1000);

  if (totalSecs < 60) {
    return `${totalSecs}s`;
  }

  const hours = Math.floor(totalSecs / 3600);
  const mins = Math.floor((totalSecs % 3600) / 60);
  const secs = totalSecs % 60;
  return hours ? `${hours}h${mins}m${secs}s` : `${mins}m${secs}s`;
}