date.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
  2. import {
  3. getDatePlaceholder,
  4. getTimePlaceholder,
  5. formatDateInput,
  6. formatTimeInput,
  7. parseDateInput,
  8. parseTimeInput,
  9. toDateTimeLocalValue,
  10. applyTimeFormat,
  11. parseUTCDate,
  12. formatDate,
  13. formatDateOnly,
  14. formatDateTime,
  15. formatTimeOnly,
  16. formatETA,
  17. formatDuration,
  18. formatRelativeTime,
  19. localDateKey,
  20. } from '../../utils/date';
  21. describe('getDatePlaceholder', () => {
  22. it('returns MM/DD/YYYY for us format', () => {
  23. expect(getDatePlaceholder('us')).toBe('MM/DD/YYYY');
  24. });
  25. it('returns DD/MM/YYYY for eu format', () => {
  26. expect(getDatePlaceholder('eu')).toBe('DD/MM/YYYY');
  27. });
  28. it('returns YYYY-MM-DD for iso format', () => {
  29. expect(getDatePlaceholder('iso')).toBe('YYYY-MM-DD');
  30. });
  31. it('returns a placeholder for system format', () => {
  32. const result = getDatePlaceholder('system');
  33. expect(['MM/DD/YYYY', 'DD/MM/YYYY', 'YYYY-MM-DD']).toContain(result);
  34. });
  35. });
  36. describe('getTimePlaceholder', () => {
  37. it('returns HH:MM AM/PM for 12h format', () => {
  38. expect(getTimePlaceholder('12h')).toBe('HH:MM AM/PM');
  39. });
  40. it('returns HH:MM for 24h format', () => {
  41. expect(getTimePlaceholder('24h')).toBe('HH:MM');
  42. });
  43. it('returns a placeholder for system format', () => {
  44. const result = getTimePlaceholder('system');
  45. expect(['HH:MM AM/PM', 'HH:MM']).toContain(result);
  46. });
  47. });
  48. describe('formatDateInput', () => {
  49. const date = new Date(2025, 5, 15); // June 15, 2025
  50. it('formats as MM/DD/YYYY for us format', () => {
  51. expect(formatDateInput(date, 'us')).toBe('06/15/2025');
  52. });
  53. it('formats as DD/MM/YYYY for eu format', () => {
  54. expect(formatDateInput(date, 'eu')).toBe('15/06/2025');
  55. });
  56. it('formats as YYYY-MM-DD for iso format', () => {
  57. expect(formatDateInput(date, 'iso')).toBe('2025-06-15');
  58. });
  59. it('uses toLocaleDateString for system format', () => {
  60. const result = formatDateInput(date, 'system');
  61. expect(result).toBeTruthy();
  62. });
  63. });
  64. describe('formatTimeInput', () => {
  65. it('formats as 12h with AM', () => {
  66. const date = new Date(2025, 0, 1, 9, 30);
  67. expect(formatTimeInput(date, '12h')).toBe('9:30 AM');
  68. });
  69. it('formats as 12h with PM', () => {
  70. const date = new Date(2025, 0, 1, 14, 45);
  71. expect(formatTimeInput(date, '12h')).toBe('2:45 PM');
  72. });
  73. it('formats 12:00 as 12:00 PM', () => {
  74. const date = new Date(2025, 0, 1, 12, 0);
  75. expect(formatTimeInput(date, '12h')).toBe('12:00 PM');
  76. });
  77. it('formats 00:00 as 12:00 AM', () => {
  78. const date = new Date(2025, 0, 1, 0, 0);
  79. expect(formatTimeInput(date, '12h')).toBe('12:00 AM');
  80. });
  81. it('formats as 24h', () => {
  82. const date = new Date(2025, 0, 1, 14, 30);
  83. expect(formatTimeInput(date, '24h')).toBe('14:30');
  84. });
  85. it('pads hours in 24h format', () => {
  86. const date = new Date(2025, 0, 1, 9, 5);
  87. expect(formatTimeInput(date, '24h')).toBe('09:05');
  88. });
  89. });
  90. describe('parseDateInput', () => {
  91. it('parses us format MM/DD/YYYY', () => {
  92. const result = parseDateInput('06/15/2025', 'us');
  93. expect(result?.getFullYear()).toBe(2025);
  94. expect(result?.getMonth()).toBe(5); // June
  95. expect(result?.getDate()).toBe(15);
  96. });
  97. it('parses eu format DD/MM/YYYY', () => {
  98. const result = parseDateInput('15/06/2025', 'eu');
  99. expect(result?.getFullYear()).toBe(2025);
  100. expect(result?.getMonth()).toBe(5);
  101. expect(result?.getDate()).toBe(15);
  102. });
  103. it('parses iso format YYYY-MM-DD', () => {
  104. const result = parseDateInput('2025-06-15', 'iso');
  105. expect(result?.getFullYear()).toBe(2025);
  106. expect(result?.getMonth()).toBe(5);
  107. expect(result?.getDate()).toBe(15);
  108. });
  109. it('accepts different separators', () => {
  110. expect(parseDateInput('06-15-2025', 'us')?.getDate()).toBe(15);
  111. expect(parseDateInput('15.06.2025', 'eu')?.getDate()).toBe(15);
  112. expect(parseDateInput('2025/06/15', 'iso')?.getDate()).toBe(15);
  113. });
  114. it('returns null for invalid input', () => {
  115. expect(parseDateInput('', 'us')).toBeNull();
  116. expect(parseDateInput('invalid', 'us')).toBeNull();
  117. expect(parseDateInput('13/32/2025', 'us')).toBeNull(); // invalid month
  118. expect(parseDateInput('01/01/1800', 'us')).toBeNull(); // year out of range
  119. });
  120. it('returns null for invalid month', () => {
  121. expect(parseDateInput('13/01/2025', 'us')).toBeNull();
  122. expect(parseDateInput('00/01/2025', 'us')).toBeNull();
  123. });
  124. it('returns null for invalid day', () => {
  125. expect(parseDateInput('01/32/2025', 'us')).toBeNull();
  126. expect(parseDateInput('01/00/2025', 'us')).toBeNull();
  127. });
  128. });
  129. describe('parseTimeInput', () => {
  130. it('parses 24h format', () => {
  131. expect(parseTimeInput('14:30')).toEqual({ hours: 14, minutes: 30 });
  132. expect(parseTimeInput('09:05')).toEqual({ hours: 9, minutes: 5 });
  133. expect(parseTimeInput('0:00')).toEqual({ hours: 0, minutes: 0 });
  134. });
  135. it('parses 12h format with AM', () => {
  136. expect(parseTimeInput('9:30 AM')).toEqual({ hours: 9, minutes: 30 });
  137. expect(parseTimeInput('12:00 AM')).toEqual({ hours: 0, minutes: 0 });
  138. });
  139. it('parses 12h format with PM', () => {
  140. expect(parseTimeInput('2:45 PM')).toEqual({ hours: 14, minutes: 45 });
  141. expect(parseTimeInput('12:00 PM')).toEqual({ hours: 12, minutes: 0 });
  142. });
  143. it('is case insensitive for AM/PM', () => {
  144. expect(parseTimeInput('9:30 am')).toEqual({ hours: 9, minutes: 30 });
  145. expect(parseTimeInput('2:45 pm')).toEqual({ hours: 14, minutes: 45 });
  146. });
  147. it('returns null for invalid input', () => {
  148. expect(parseTimeInput('')).toBeNull();
  149. expect(parseTimeInput('invalid')).toBeNull();
  150. expect(parseTimeInput('25:00')).toBeNull();
  151. expect(parseTimeInput('12:60')).toBeNull();
  152. expect(parseTimeInput('-1:00')).toBeNull();
  153. });
  154. });
  155. describe('toDateTimeLocalValue', () => {
  156. it('formats date to datetime-local value', () => {
  157. const date = new Date(2025, 5, 15, 14, 30);
  158. expect(toDateTimeLocalValue(date)).toBe('2025-06-15T14:30');
  159. });
  160. it('pads single digit values', () => {
  161. const date = new Date(2025, 0, 5, 9, 5);
  162. expect(toDateTimeLocalValue(date)).toBe('2025-01-05T09:05');
  163. });
  164. });
  165. describe('applyTimeFormat', () => {
  166. it('sets hour12 true for 12h format', () => {
  167. const options: Intl.DateTimeFormatOptions = {};
  168. applyTimeFormat(options, '12h');
  169. expect(options.hour12).toBe(true);
  170. });
  171. it('sets hour12 false for 24h format', () => {
  172. const options: Intl.DateTimeFormatOptions = {};
  173. applyTimeFormat(options, '24h');
  174. expect(options.hour12).toBe(false);
  175. });
  176. it('leaves hour12 undefined for system format', () => {
  177. const options: Intl.DateTimeFormatOptions = {};
  178. applyTimeFormat(options, 'system');
  179. expect(options.hour12).toBeUndefined();
  180. });
  181. it('returns the modified options object', () => {
  182. const options: Intl.DateTimeFormatOptions = { hour: '2-digit' };
  183. const result = applyTimeFormat(options, '12h');
  184. expect(result).toBe(options);
  185. expect(result.hour).toBe('2-digit');
  186. });
  187. });
  188. describe('parseUTCDate', () => {
  189. it('returns null for null/undefined input', () => {
  190. expect(parseUTCDate(null)).toBeNull();
  191. expect(parseUTCDate(undefined)).toBeNull();
  192. expect(parseUTCDate('')).toBeNull();
  193. });
  194. it('parses ISO string with Z suffix as-is', () => {
  195. const result = parseUTCDate('2025-06-15T12:00:00Z');
  196. expect(result).toBeInstanceOf(Date);
  197. expect(result?.getUTCHours()).toBe(12);
  198. });
  199. it('parses ISO string with timezone offset as-is', () => {
  200. const result = parseUTCDate('2025-06-15T12:00:00+05:00');
  201. expect(result).toBeInstanceOf(Date);
  202. });
  203. it('appends Z to strings without timezone indicator', () => {
  204. const result = parseUTCDate('2025-06-15T12:00:00');
  205. expect(result).toBeInstanceOf(Date);
  206. expect(result?.getUTCHours()).toBe(12);
  207. });
  208. });
  209. describe('formatDate', () => {
  210. it('returns empty string for null input', () => {
  211. expect(formatDate(null)).toBe('');
  212. expect(formatDate(undefined)).toBe('');
  213. });
  214. it('formats a valid date string', () => {
  215. const result = formatDate('2025-06-15T12:00:00Z');
  216. expect(result).toBeTruthy();
  217. expect(result).toContain('2025');
  218. });
  219. it('accepts custom options', () => {
  220. const result = formatDate('2025-06-15T12:00:00Z', { year: 'numeric' });
  221. expect(result).toContain('2025');
  222. });
  223. });
  224. describe('formatDateOnly', () => {
  225. it('returns empty string for null input', () => {
  226. expect(formatDateOnly(null)).toBe('');
  227. });
  228. it('formats date without time', () => {
  229. const result = formatDateOnly('2025-06-15T12:00:00Z');
  230. expect(result).toBeTruthy();
  231. expect(result).toContain('2025');
  232. });
  233. });
  234. describe('formatDateTime', () => {
  235. it('returns empty string for null input', () => {
  236. expect(formatDateTime(null)).toBe('');
  237. });
  238. it('formats with 12h time format', () => {
  239. const result = formatDateTime('2025-06-15T14:00:00Z', '12h');
  240. expect(result).toBeTruthy();
  241. });
  242. it('formats with 24h time format', () => {
  243. const result = formatDateTime('2025-06-15T14:00:00Z', '24h');
  244. expect(result).toBeTruthy();
  245. });
  246. });
  247. describe('formatTimeOnly', () => {
  248. it('formats time with 12h format', () => {
  249. const date = new Date(2025, 5, 15, 14, 30);
  250. const result = formatTimeOnly(date, '12h');
  251. // Locale-agnostic: separator is "." in en_DK, " " (NBSP) in some, ":" elsewhere.
  252. expect(result).toMatch(/\b0?2\D+30\b/);
  253. expect(result.toUpperCase()).toContain('PM');
  254. });
  255. it('formats time with 24h format', () => {
  256. const date = new Date(2025, 5, 15, 14, 30);
  257. const result = formatTimeOnly(date, '24h');
  258. expect(result).toMatch(/\b14\D+30\b/);
  259. });
  260. });
  261. describe('formatETA', () => {
  262. beforeEach(() => {
  263. vi.useFakeTimers();
  264. vi.setSystemTime(new Date('2025-06-15T12:00:00Z'));
  265. });
  266. afterEach(() => {
  267. vi.useRealTimers();
  268. });
  269. it('returns time only for same day', () => {
  270. const result = formatETA(60); // 1 hour from now
  271. expect(result).toBeTruthy();
  272. });
  273. it('includes "Tomorrow" for next day', () => {
  274. const result = formatETA(60 * 24); // 24 hours from now
  275. expect(result).toContain('Tomorrow');
  276. });
  277. it('uses translation function for tomorrow', () => {
  278. const t = vi.fn((key: string) => (key === 'common.tomorrow' ? 'Demain' : key));
  279. const result = formatETA(60 * 24, 'system', t);
  280. expect(result).toContain('Demain');
  281. });
  282. it('shows weekday for dates beyond tomorrow', () => {
  283. const result = formatETA(60 * 48); // 48 hours from now
  284. expect(result).not.toContain('Tomorrow');
  285. });
  286. it('counts from baseTime when one is supplied', () => {
  287. const base = new Date('2025-06-15T12:00:00Z').getTime();
  288. // Same offset, two different starting instants: the results must differ by
  289. // exactly the gap between those instants, not track the system clock.
  290. const atNoon = formatETA(60, '24h', undefined, base);
  291. const anHourLater = formatETA(60, '24h', undefined, base + 60 * 60 * 1000);
  292. expect(atNoon).not.toBe(anHourLater);
  293. expect(formatETA(60, '24h', undefined, base)).toBe(atNoon);
  294. expect(formatETA(120, '24h', undefined, base)).toBe(anHourLater);
  295. });
  296. it('falls back to the system clock without baseTime', () => {
  297. const explicit = formatETA(60, '24h', undefined, Date.now());
  298. expect(formatETA(60, '24h')).toBe(explicit);
  299. });
  300. });
  301. describe('formatDuration', () => {
  302. it('returns "--" for null/undefined', () => {
  303. expect(formatDuration(null)).toBe('--');
  304. expect(formatDuration(undefined)).toBe('--');
  305. });
  306. it('returns "--" for negative values', () => {
  307. expect(formatDuration(-1)).toBe('--');
  308. });
  309. it('formats minutes only when under 1 hour', () => {
  310. expect(formatDuration(0)).toBe('0m');
  311. expect(formatDuration(60)).toBe('1m');
  312. expect(formatDuration(2700)).toBe('45m');
  313. });
  314. it('formats hours and minutes', () => {
  315. expect(formatDuration(3600)).toBe('1h 0m');
  316. expect(formatDuration(5400)).toBe('1h 30m');
  317. expect(formatDuration(9000)).toBe('2h 30m');
  318. });
  319. });
  320. describe('formatRelativeTime', () => {
  321. beforeEach(() => {
  322. vi.useFakeTimers();
  323. vi.setSystemTime(new Date('2025-06-15T12:00:00Z'));
  324. });
  325. afterEach(() => {
  326. vi.useRealTimers();
  327. });
  328. it('returns "-" for null input', () => {
  329. expect(formatRelativeTime(null)).toBe('-');
  330. });
  331. it('returns translated unknown for null with translation', () => {
  332. const t = vi.fn((key: string) => (key === 'time.unknown' ? 'Unknown' : key));
  333. expect(formatRelativeTime(null, 'system', t)).toBe('Unknown');
  334. });
  335. it('returns "Just now" for times less than 1 minute ago', () => {
  336. expect(formatRelativeTime('2025-06-15T11:59:30Z')).toBe('Just now');
  337. });
  338. it('returns "Now" for times less than 1 minute in future', () => {
  339. expect(formatRelativeTime('2025-06-15T12:00:30Z')).toBe('Now');
  340. });
  341. it('returns minutes ago for times under 1 hour ago', () => {
  342. expect(formatRelativeTime('2025-06-15T11:55:00Z')).toBe('5m ago');
  343. expect(formatRelativeTime('2025-06-15T11:30:00Z')).toBe('30m ago');
  344. });
  345. it('returns "in Xm" for times under 1 hour in future', () => {
  346. expect(formatRelativeTime('2025-06-15T12:05:00Z')).toBe('in 5m');
  347. expect(formatRelativeTime('2025-06-15T12:30:00Z')).toBe('in 30m');
  348. });
  349. it('returns hours ago for times under 1 day ago', () => {
  350. expect(formatRelativeTime('2025-06-15T10:00:00Z')).toBe('2h ago');
  351. expect(formatRelativeTime('2025-06-15T06:00:00Z')).toBe('6h ago');
  352. });
  353. it('returns "in Xh" for times under 1 day in future', () => {
  354. expect(formatRelativeTime('2025-06-15T14:00:00Z')).toBe('in 2h');
  355. expect(formatRelativeTime('2025-06-15T18:00:00Z')).toBe('in 6h');
  356. });
  357. it('returns days ago for times under 7 days ago', () => {
  358. expect(formatRelativeTime('2025-06-14T12:00:00Z')).toBe('1d ago');
  359. expect(formatRelativeTime('2025-06-10T12:00:00Z')).toBe('5d ago');
  360. });
  361. it('returns "in Xd" for times under 7 days in future', () => {
  362. expect(formatRelativeTime('2025-06-16T12:00:00Z')).toBe('in 1d');
  363. expect(formatRelativeTime('2025-06-20T12:00:00Z')).toBe('in 5d');
  364. });
  365. it('returns formatted date for times older than 7 days', () => {
  366. const result = formatRelativeTime('2025-06-01T12:00:00Z');
  367. expect(result).toContain('2025');
  368. });
  369. it('uses translation function when provided', () => {
  370. const t = vi.fn((key: string, options?: Record<string, unknown>) => {
  371. if (key === 'time.minsAgo') return `${options?.count} minutes ago`;
  372. if (key === 'time.inMins') return `in ${options?.count} minutes`;
  373. return key;
  374. });
  375. expect(formatRelativeTime('2025-06-15T11:55:00Z', 'system', t)).toBe('5 minutes ago');
  376. expect(formatRelativeTime('2025-06-15T12:05:00Z', 'system', t)).toBe('in 5 minutes');
  377. });
  378. });
  379. describe('localDateKey (#1446 — Print Activity heatmap bucketing)', () => {
  380. // We can't change Node's process.env.TZ at runtime (it's resolved once),
  381. // but localDateKey's whole point is to use the *local* tz getters on the
  382. // Date. So we feed it a Date object whose local components we control via
  383. // the Date constructor's local-time form — that's equivalent to "the user
  384. // is in tz X and the UTC timestamp lands on day Y locally."
  385. it('keys a Date by its local-tz date, not UTC', () => {
  386. // A Date whose local-tz representation is May 17, 2026 at 22:00. In CDT
  387. // (UTC-5) the UTC representation would be May 18 03:00 — but the bucket
  388. // key must follow the local view, which is what the heatmap renders.
  389. const localEvening = new Date(2026, 4, 17, 22, 0, 0); // months are 0-indexed
  390. expect(localDateKey(localEvening)).toBe('2026-05-17');
  391. });
  392. it('reproduces the #1446 repro: a UTC string that is "tomorrow UTC" but "today local" keys to today local', () => {
  393. // Reporter's row 30: stored as 2026-05-18 03:39:07 UTC, local was
  394. // 22:39 CDT May 17. The heatmap key must be 2026-05-17 in any local tz
  395. // whose offset puts that UTC moment back into May 17. We construct the
  396. // equivalent moment via a local-time Date object so the test is
  397. // independent of which tz the CI runner is in.
  398. const localMoment = new Date(2026, 4, 17, 22, 39, 7);
  399. expect(localDateKey(localMoment)).toBe('2026-05-17');
  400. });
  401. it('returns an empty string for null / undefined / empty input', () => {
  402. expect(localDateKey('')).toBe('');
  403. // null and undefined typed via the string overload are still defensively
  404. // handled by parseUTCDate, which returns null and we shortcut to ''.
  405. expect(localDateKey(null as unknown as string)).toBe('');
  406. expect(localDateKey(undefined as unknown as string)).toBe('');
  407. });
  408. it('pads single-digit month and day to two digits', () => {
  409. const jan3 = new Date(2026, 0, 3, 10, 0, 0);
  410. expect(localDateKey(jan3)).toBe('2026-01-03');
  411. });
  412. it('accepts an ISO string and parses it via parseUTCDate', () => {
  413. // parseUTCDate treats naked ISO as UTC and converts to local. Verify
  414. // the chain works end-to-end — a UTC-anchored input becomes a local
  415. // YYYY-MM-DD bucket key.
  416. const key = localDateKey('2026-05-17T16:27:23');
  417. // The exact local date depends on the CI runner's tz, but the result
  418. // must always be a well-formed YYYY-MM-DD (10 chars, two dashes).
  419. expect(key).toMatch(/^\d{4}-\d{2}-\d{2}$/);
  420. });
  421. });