date.ts 13 KB

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