date.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. /**
  2. * Date utilities for handling UTC timestamps from the backend.
  3. *
  4. * The backend stores all timestamps in UTC without timezone indicators.
  5. * These utilities ensure dates are properly interpreted as UTC and
  6. * displayed in the user's local timezone.
  7. */
  8. export type TimeFormat = 'system' | '12h' | '24h';
  9. export type DateFormat = 'system' | 'us' | 'eu' | 'iso';
  10. /**
  11. * Get the date input placeholder based on format setting.
  12. */
  13. export function getDatePlaceholder(dateFormat: DateFormat = 'system'): string {
  14. switch (dateFormat) {
  15. case 'us':
  16. return 'MM/DD/YYYY';
  17. case 'eu':
  18. return 'DD/MM/YYYY';
  19. case 'iso':
  20. return 'YYYY-MM-DD';
  21. case 'system':
  22. default: {
  23. // Try to detect system format
  24. const testDate = new Date(2000, 11, 31); // Dec 31, 2000
  25. const formatted = testDate.toLocaleDateString();
  26. if (formatted.startsWith('12')) return 'MM/DD/YYYY';
  27. if (formatted.startsWith('31')) return 'DD/MM/YYYY';
  28. return 'YYYY-MM-DD';
  29. }
  30. }
  31. }
  32. /**
  33. * Get the time input placeholder based on format setting.
  34. */
  35. export function getTimePlaceholder(timeFormat: TimeFormat = 'system'): string {
  36. switch (timeFormat) {
  37. case '12h':
  38. return 'HH:MM AM/PM';
  39. case '24h':
  40. return 'HH:MM';
  41. case 'system':
  42. default: {
  43. // Try to detect system format
  44. const testDate = new Date(2000, 0, 1, 14, 30);
  45. const formatted = testDate.toLocaleTimeString();
  46. if (formatted.includes('PM') || formatted.includes('AM')) return 'HH:MM AM/PM';
  47. return 'HH:MM';
  48. }
  49. }
  50. }
  51. /**
  52. * Format a Date object to a date string based on format setting.
  53. */
  54. export function formatDateInput(date: Date, dateFormat: DateFormat = 'system'): string {
  55. const day = String(date.getDate()).padStart(2, '0');
  56. const month = String(date.getMonth() + 1).padStart(2, '0');
  57. const year = date.getFullYear();
  58. switch (dateFormat) {
  59. case 'us':
  60. return `${month}/${day}/${year}`;
  61. case 'eu':
  62. return `${day}/${month}/${year}`;
  63. case 'iso':
  64. return `${year}-${month}-${day}`;
  65. case 'system':
  66. default:
  67. return date.toLocaleDateString();
  68. }
  69. }
  70. /**
  71. * Format a Date object to a time string based on format setting.
  72. */
  73. export function formatTimeInput(date: Date, timeFormat: TimeFormat = 'system'): string {
  74. const hours24 = date.getHours();
  75. const minutes = String(date.getMinutes()).padStart(2, '0');
  76. switch (timeFormat) {
  77. case '12h': {
  78. const hours12 = hours24 % 12 || 12;
  79. const ampm = hours24 < 12 ? 'AM' : 'PM';
  80. return `${hours12}:${minutes} ${ampm}`;
  81. }
  82. case '24h':
  83. return `${String(hours24).padStart(2, '0')}:${minutes}`;
  84. case 'system':
  85. default:
  86. return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
  87. }
  88. }
  89. /**
  90. * Split a date string by common separators (/, ., -).
  91. */
  92. function splitDateParts(value: string): string[] | null {
  93. // Try common separators: /, ., -
  94. for (const sep of ['/', '.', '-']) {
  95. const parts = value.split(sep);
  96. if (parts.length === 3) return parts;
  97. }
  98. return null;
  99. }
  100. /**
  101. * Parse a date string based on format setting.
  102. * Returns null if parsing fails.
  103. * Supports common separators: / . -
  104. */
  105. export function parseDateInput(value: string, dateFormat: DateFormat = 'system'): Date | null {
  106. if (!value) return null;
  107. let day: number, month: number, year: number;
  108. try {
  109. switch (dateFormat) {
  110. case 'us': {
  111. // MM/DD/YYYY (also accepts . and - separators)
  112. const parts = splitDateParts(value);
  113. if (!parts) return null;
  114. month = parseInt(parts[0], 10);
  115. day = parseInt(parts[1], 10);
  116. year = parseInt(parts[2], 10);
  117. break;
  118. }
  119. case 'eu': {
  120. // DD/MM/YYYY (also accepts . and - separators)
  121. const parts = splitDateParts(value);
  122. if (!parts) return null;
  123. day = parseInt(parts[0], 10);
  124. month = parseInt(parts[1], 10);
  125. year = parseInt(parts[2], 10);
  126. break;
  127. }
  128. case 'iso': {
  129. // YYYY-MM-DD (also accepts . and / separators)
  130. const parts = splitDateParts(value);
  131. if (!parts) return null;
  132. year = parseInt(parts[0], 10);
  133. month = parseInt(parts[1], 10);
  134. day = parseInt(parts[2], 10);
  135. break;
  136. }
  137. case 'system':
  138. default: {
  139. // Detect system format and parse accordingly
  140. const testDate = new Date(2000, 11, 31); // Dec 31, 2000
  141. const formatted = testDate.toLocaleDateString();
  142. const parts = splitDateParts(value);
  143. if (parts) {
  144. // Detect format from system locale
  145. if (formatted.startsWith('12')) {
  146. // US format: MM/DD/YYYY
  147. month = parseInt(parts[0], 10);
  148. day = parseInt(parts[1], 10);
  149. year = parseInt(parts[2], 10);
  150. } else if (formatted.startsWith('31')) {
  151. // EU format: DD/MM/YYYY
  152. day = parseInt(parts[0], 10);
  153. month = parseInt(parts[1], 10);
  154. year = parseInt(parts[2], 10);
  155. } else {
  156. // ISO format: YYYY-MM-DD
  157. year = parseInt(parts[0], 10);
  158. month = parseInt(parts[1], 10);
  159. day = parseInt(parts[2], 10);
  160. }
  161. break;
  162. }
  163. return null;
  164. }
  165. }
  166. if (isNaN(day) || isNaN(month) || isNaN(year)) return null;
  167. if (month < 1 || month > 12) return null;
  168. if (day < 1 || day > 31) return null;
  169. if (year < 1900 || year > 2100) return null;
  170. return new Date(year, month - 1, day);
  171. } catch {
  172. return null;
  173. }
  174. }
  175. /**
  176. * Parse a time string. Handles both 12h (with AM/PM) and 24h formats.
  177. * Returns { hours, minutes } or null if parsing fails.
  178. */
  179. export function parseTimeInput(value: string): { hours: number; minutes: number } | null {
  180. if (!value) return null;
  181. try {
  182. const trimmed = value.trim().toUpperCase();
  183. // Check for 12h format with AM/PM
  184. const ampmMatch = trimmed.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)?$/i);
  185. if (ampmMatch) {
  186. let hours = parseInt(ampmMatch[1], 10);
  187. const minutes = parseInt(ampmMatch[2], 10);
  188. const ampm = ampmMatch[3]?.toUpperCase();
  189. if (ampm === 'PM' && hours < 12) hours += 12;
  190. if (ampm === 'AM' && hours === 12) hours = 0;
  191. if (hours < 0 || hours > 23) return null;
  192. if (minutes < 0 || minutes > 59) return null;
  193. return { hours, minutes };
  194. }
  195. // Try 24h format HH:MM
  196. const match24 = trimmed.match(/^(\d{1,2}):(\d{2})$/);
  197. if (match24) {
  198. const hours = parseInt(match24[1], 10);
  199. const minutes = parseInt(match24[2], 10);
  200. if (hours < 0 || hours > 23) return null;
  201. if (minutes < 0 || minutes > 59) return null;
  202. return { hours, minutes };
  203. }
  204. return null;
  205. } catch {
  206. return null;
  207. }
  208. }
  209. /**
  210. * Convert a Date object to datetime-local input value (ISO format).
  211. */
  212. export function toDateTimeLocalValue(date: Date): string {
  213. const year = date.getFullYear();
  214. const month = String(date.getMonth() + 1).padStart(2, '0');
  215. const day = String(date.getDate()).padStart(2, '0');
  216. const hours = String(date.getHours()).padStart(2, '0');
  217. const minutes = String(date.getMinutes()).padStart(2, '0');
  218. return `${year}-${month}-${day}T${hours}:${minutes}`;
  219. }
  220. /**
  221. * Apply time format setting to Intl.DateTimeFormatOptions.
  222. * This modifies the options object in place and returns it.
  223. */
  224. export function applyTimeFormat(
  225. options: Intl.DateTimeFormatOptions,
  226. timeFormat: TimeFormat = 'system'
  227. ): Intl.DateTimeFormatOptions {
  228. if (timeFormat === '12h') {
  229. options.hour12 = true;
  230. } else if (timeFormat === '24h') {
  231. options.hour12 = false;
  232. }
  233. // 'system' leaves hour12 undefined, letting the browser decide
  234. return options;
  235. }
  236. /**
  237. * Parse a date string from the backend as UTC.
  238. * Handles ISO 8601 strings with or without timezone indicators.
  239. *
  240. * @param dateStr - Date string from backend (e.g., "2026-01-09T12:03:36.288768")
  241. * @returns Date object in local timezone
  242. */
  243. export function parseUTCDate(dateStr: string | null | undefined): Date | null {
  244. if (!dateStr) return null;
  245. // If the string already has a timezone indicator, parse as-is
  246. if (dateStr.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(dateStr)) {
  247. return new Date(dateStr);
  248. }
  249. // Otherwise, append 'Z' to interpret as UTC
  250. return new Date(dateStr + 'Z');
  251. }
  252. /**
  253. * Format a UTC date string to a localized date/time string.
  254. *
  255. * @param dateStr - Date string from backend
  256. * @param options - Intl.DateTimeFormat options (defaults to showing date and time)
  257. * @returns Formatted date string in user's locale and timezone
  258. */
  259. export function formatDate(
  260. dateStr: string | null | undefined,
  261. options?: Intl.DateTimeFormatOptions
  262. ): string {
  263. const date = parseUTCDate(dateStr);
  264. if (!date) return '';
  265. const defaultOptions: Intl.DateTimeFormatOptions = {
  266. year: 'numeric',
  267. month: 'short',
  268. day: 'numeric',
  269. hour: '2-digit',
  270. minute: '2-digit',
  271. };
  272. return date.toLocaleString(undefined, options ?? defaultOptions);
  273. }
  274. /**
  275. * Format a UTC date string to a localized date-only string.
  276. *
  277. * @param dateStr - Date string from backend
  278. * @param options - Intl.DateTimeFormat options
  279. * @returns Formatted date string in user's locale and timezone
  280. */
  281. export function formatDateOnly(
  282. dateStr: string | null | undefined,
  283. options?: Intl.DateTimeFormatOptions
  284. ): string {
  285. const date = parseUTCDate(dateStr);
  286. if (!date) return '';
  287. const defaultOptions: Intl.DateTimeFormatOptions = {
  288. year: 'numeric',
  289. month: 'short',
  290. day: 'numeric',
  291. };
  292. return date.toLocaleDateString(undefined, options ?? defaultOptions);
  293. }
  294. /**
  295. * Format a UTC date string to a localized date/time string with time format support.
  296. *
  297. * @param dateStr - Date string from backend
  298. * @param timeFormat - Time format setting ('system', '12h', '24h')
  299. * @param options - Intl.DateTimeFormat options (defaults to showing date and time)
  300. * @returns Formatted date string in user's locale and timezone
  301. */
  302. export function formatDateTime(
  303. dateStr: string | null | undefined,
  304. timeFormat: TimeFormat = 'system',
  305. options?: Intl.DateTimeFormatOptions
  306. ): string {
  307. const date = parseUTCDate(dateStr);
  308. if (!date) return '';
  309. const defaultOptions: Intl.DateTimeFormatOptions = {
  310. year: 'numeric',
  311. month: 'short',
  312. day: 'numeric',
  313. hour: '2-digit',
  314. minute: '2-digit',
  315. };
  316. const finalOptions = applyTimeFormat(options ?? defaultOptions, timeFormat);
  317. return date.toLocaleString(undefined, finalOptions);
  318. }
  319. /**
  320. * Format a Date object to a localized time string with time format support.
  321. *
  322. * @param date - Date object
  323. * @param timeFormat - Time format setting ('system', '12h', '24h')
  324. * @param options - Additional Intl.DateTimeFormat options
  325. * @returns Formatted time string
  326. */
  327. export function formatTimeOnly(
  328. date: Date,
  329. timeFormat: TimeFormat = 'system',
  330. options?: Intl.DateTimeFormatOptions
  331. ): string {
  332. const defaultOptions: Intl.DateTimeFormatOptions = {
  333. hour: '2-digit',
  334. minute: '2-digit',
  335. };
  336. const finalOptions = applyTimeFormat({ ...defaultOptions, ...options }, timeFormat);
  337. return date.toLocaleTimeString([], finalOptions);
  338. }
  339. /**
  340. * Calculate and format an ETA based on remaining minutes from now.
  341. *
  342. * @param remainingMinutes - Minutes until completion
  343. * @param timeFormat - Time format setting ('system', '12h', '24h')
  344. * @param t - Optional i18n translation function
  345. * @returns Formatted ETA string (e.g., "3:45 PM", "Tomorrow 9:30 AM", "Wed 2:00 PM")
  346. */
  347. export function formatETA(
  348. remainingMinutes: number,
  349. timeFormat: 'system' | '12h' | '24h' = 'system',
  350. t?: (key: string) => string
  351. ): string {
  352. const now = new Date();
  353. const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
  354. const today = new Date();
  355. today.setHours(0, 0, 0, 0);
  356. const etaDay = new Date(eta);
  357. etaDay.setHours(0, 0, 0, 0);
  358. const timeOptions: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
  359. if (timeFormat === '12h') timeOptions.hour12 = true;
  360. else if (timeFormat === '24h') timeOptions.hour12 = false;
  361. const timeStr = eta.toLocaleTimeString([], timeOptions);
  362. const dayDiff = Math.floor((etaDay.getTime() - today.getTime()) / 86400000);
  363. if (dayDiff === 0) return timeStr;
  364. if (dayDiff === 1) return `${t?.('common.tomorrow') ?? 'Tomorrow'} ${timeStr}`;
  365. return `${eta.toLocaleDateString([], { weekday: 'short' })} ${timeStr}`;
  366. }
  367. /**
  368. * Format a duration in seconds to a human-readable string, with null handling.
  369. *
  370. * @param seconds - Duration in seconds, or null/undefined
  371. * @returns Formatted string (e.g., "2h 30m", "45m") or "--" if no value
  372. */
  373. export function formatDuration(seconds: number | null | undefined): string {
  374. if (seconds == null || seconds < 0) return '--';
  375. const hours = Math.floor(seconds / 3600);
  376. const minutes = Math.floor((seconds % 3600) / 60);
  377. return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
  378. }