@openmrs/esm-utils 11.0.1-pre.5906 → 11.0.1-pre.5917

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,3 @@
1
- [0] Successfully compiled: 14 files with swc (289.26ms)
1
+ [0] Successfully compiled: 14 files with swc (321.25ms)
2
2
  [0] swc --strip-leading-paths src -d dist exited with code 0
3
3
  [1] tsc --project tsconfig.build.json exited with code 0
@@ -169,8 +169,8 @@ export declare function convertToLocaleCalendar(date: CalendarDate | CalendarDat
169
169
  */
170
170
  export declare function formatDuration(duration: Intl.DurationInput, options?: Intl.DurationFormatOptions): string;
171
171
  /**
172
- * Parses a date input into a dayjs object. String inputs are interpreted using
173
- * any-date-parser with corrections for its month/day representation differences
172
+ * Parses a date input into a dayjs object. ISO calendar dates are parsed independently of locale;
173
+ * other strings use any-date-parser with corrections for its month/day representation differences
174
174
  * with dayjs. Non-string inputs are passed directly to dayjs.
175
175
  *
176
176
  * @param dateInput The date to parse.
@@ -1 +1 @@
1
- {"version":3,"file":"date-util.d.ts","sourceRoot":"","sources":["../../src/dates/date-util.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"date-util.d.ts","sourceRoot":"","sources":["../../src/dates/date-util.ts"],"names":[],"mappings":"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"}
@@ -38,6 +38,36 @@ dayjs.extend(isToday);
38
38
  dayjs.extend(utc);
39
39
  dayjs.extend(objectSupport);
40
40
  const isoFormat = 'YYYY-MM-DDTHH:mm:ss.SSSZZ';
41
+ const isoCalendarDate = /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/;
42
+ /**
43
+ * Parses ISO calendar dates independently of the display locale, preserving missing fields.
44
+ * Invalid ISO dates must not fall back to a parser that may reinterpret or normalize them.
45
+ */ function parseDateString(dateString, locale) {
46
+ const match = isoCalendarDate.exec(dateString);
47
+ if (!match) {
48
+ return attempt(dateString, locale);
49
+ }
50
+ const year = Number(match[1]);
51
+ const month = match[2] === undefined ? undefined : Number(match[2]);
52
+ const day = match[3] === undefined ? undefined : Number(match[3]);
53
+ // Check for calendar overflow in UTC. setUTCFullYear also preserves years below 100.
54
+ const date = new Date(0);
55
+ date.setUTCFullYear(year, (month ?? 1) - 1, day ?? 1);
56
+ if (date.getUTCFullYear() !== year || date.getUTCMonth() !== (month ?? 1) - 1 || date.getUTCDate() !== (day ?? 1)) {
57
+ return {
58
+ invalid: `Invalid ISO calendar date '${dateString}'`
59
+ };
60
+ }
61
+ return {
62
+ year,
63
+ ...month !== undefined && {
64
+ month
65
+ },
66
+ ...day !== undefined && {
67
+ day
68
+ }
69
+ };
70
+ }
41
71
  /**
42
72
  * This function checks whether a date string is the OpenMRS ISO format.
43
73
  * The format should be YYYY-MM-DDTHH:mm:ss.SSSZZ
@@ -183,7 +213,7 @@ const defaultOptions = {
183
213
  */ // TODO: Shouldn't throw on null input
184
214
  export function formatPartialDate(dateString, options = {}) {
185
215
  const locale = getLocale();
186
- let parsed = attempt(dateString, locale);
216
+ let parsed = parseDateString(dateString, locale);
187
217
  if (parsed.invalid) {
188
218
  console.warn(`Could not parse invalid date '${dateString}'`);
189
219
  return null;
@@ -361,8 +391,8 @@ const formatParts = (separator)=>{
361
391
  return formatter.format(duration);
362
392
  }
363
393
  /**
364
- * Parses a date input into a dayjs object. String inputs are interpreted using
365
- * any-date-parser with corrections for its month/day representation differences
394
+ * Parses a date input into a dayjs object. ISO calendar dates are parsed independently of locale;
395
+ * other strings use any-date-parser with corrections for its month/day representation differences
366
396
  * with dayjs. Non-string inputs are passed directly to dayjs.
367
397
  *
368
398
  * @param dateInput The date to parse.
@@ -374,7 +404,7 @@ const formatParts = (separator)=>{
374
404
  }
375
405
  if (typeof dateInput === 'string') {
376
406
  const locale = getLocale();
377
- let parsedDate = attempt(dateInput, locale);
407
+ let parsedDate = parseDateString(dateInput, locale);
378
408
  if (parsedDate.invalid) {
379
409
  console.warn(`Could not interpret '${dateInput}' as a date`);
380
410
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmrs/esm-utils",
3
- "version": "11.0.1-pre.5906",
3
+ "version": "11.0.1-pre.5917",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Helper utilities for OpenMRS",
6
6
  "type": "module",
@@ -58,12 +58,12 @@
58
58
  "semver": "^7.7.3"
59
59
  },
60
60
  "peerDependencies": {
61
- "@openmrs/esm-globals": "^11.0.1-pre.5906",
61
+ "@openmrs/esm-globals": "^11.0.1-pre.5917",
62
62
  "dayjs": "1.x",
63
63
  "i18next": "25.x"
64
64
  },
65
65
  "devDependencies": {
66
- "@openmrs/esm-globals": "11.0.1-pre.5906",
66
+ "@openmrs/esm-globals": "11.0.1-pre.5917",
67
67
  "@swc/cli": "0.8.1",
68
68
  "@swc/core": "1.15.21",
69
69
  "@types/lodash-es": "^4.17.12",
@@ -1,4 +1,4 @@
1
- import { afterAll, describe, expect, it } from 'vitest';
1
+ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import dayjs from 'dayjs';
3
3
  import timezoneMock from 'timezone-mock';
4
4
  import type { i18n } from 'i18next';
@@ -14,10 +14,101 @@ import {
14
14
  formatDuration,
15
15
  duration,
16
16
  formatDurationBetween,
17
+ parseDateInput,
17
18
  } from './date-util';
18
19
 
19
20
  window.i18next = { language: 'en' } as i18n;
20
21
 
22
+ describe('ISO calendar dates', () => {
23
+ const referenceDate = dayjs('2024-03-31T12:00:00');
24
+ let originalLocale: string;
25
+
26
+ beforeEach(() => {
27
+ originalLocale = window.i18next.language;
28
+ window.i18next.language = 'en';
29
+ vi.useFakeTimers({ toFake: ['Date'] });
30
+ vi.setSystemTime(referenceDate.toDate());
31
+ });
32
+
33
+ afterEach(() => {
34
+ vi.useRealTimers();
35
+ vi.restoreAllMocks();
36
+ window.i18next.language = originalLocale;
37
+ });
38
+
39
+ it.each([
40
+ { input: '2024', expected: '2024', resolved: '2024-03-31' },
41
+ { input: '2024-03', expected: 'Mar 2024', resolved: '2024-03-31' },
42
+ { input: '2024-03-31', expected: '31-Mar-2024', resolved: '2024-03-31' },
43
+ { input: '2021-02', expected: 'Feb 2021', resolved: '2021-02-28' },
44
+ { input: '2020-02-29', expected: '29-Feb-2020', resolved: '2020-02-29' },
45
+ { input: '2000-02-29', expected: '29-Feb-2000', resolved: '2000-02-29' },
46
+ { input: '0096-02-29', expected: '29-Feb-96', resolved: '0096-02-29' },
47
+ ])('preserves precision and calendar fields for $input', ({ input, expected, resolved }) => {
48
+ expect(formatPartialDate(input, { time: false, noToday: true })).toEqual(expected);
49
+ expect(parseDateInput(input, referenceDate)?.format('YYYY-MM-DDTHH:mm:ss')).toEqual(`${resolved}T12:00:00`);
50
+ });
51
+
52
+ describe.each(['bn', 'ar_SY'])('with locale %s', (locale) => {
53
+ it.each([
54
+ { input: '2020', options: { year: 'numeric' } as const, resolved: '2020-03-31' },
55
+ { input: '2020-03', options: { year: 'numeric', month: 'short' } as const, resolved: '2020-03-31' },
56
+ {
57
+ input: '2020-03-22',
58
+ options: { year: 'numeric', month: 'short', day: '2-digit' } as const,
59
+ resolved: '2020-03-22',
60
+ },
61
+ ])('parses $input independently of the display locale', ({ input, options, resolved }) => {
62
+ window.i18next.language = locale;
63
+ const expected = new Intl.DateTimeFormat(locale.replace('_', '-'), options).format(
64
+ new Date('2020-03-22T12:00:00'),
65
+ );
66
+
67
+ expect(formatPartialDate(input, { time: false, noToday: true })).toEqual(expected);
68
+ expect(parseDateInput(input, referenceDate)?.format('YYYY-MM-DD')).toEqual(resolved);
69
+ });
70
+ });
71
+
72
+ it.each([
73
+ '2021-00',
74
+ '2021-13',
75
+ '2021-00-01',
76
+ '2021-13-01',
77
+ '2021-01-00',
78
+ '2021-01-32',
79
+ '2021-04-31',
80
+ '2021-02-29',
81
+ '2020-02-30',
82
+ '1900-02-29',
83
+ '0099-02-29',
84
+ ])('rejects the invalid calendar date %s', (input) => {
85
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
86
+
87
+ expect(formatPartialDate(input, { time: false, noToday: true })).toBeNull();
88
+ expect(parseDateInput(input, referenceDate)).toBeNull();
89
+ });
90
+
91
+ it('continues to parse non-ISO date strings', () => {
92
+ expect(formatPartialDate('9 April 2021', { time: false, noToday: true })).toEqual('09-Apr-2021');
93
+ expect(parseDateInput('9 April 2021', referenceDate)?.format('YYYY-MM-DD')).toEqual('2021-04-09');
94
+ expect(parseDateInput('2021-04-09T15:30:00Z', referenceDate)?.format('YYYY-MM-DDTHH:mm:ss')).toEqual(
95
+ '2021-04-09T15:30:00',
96
+ );
97
+ });
98
+
99
+ it('keeps date-only inputs on the same day behind UTC', () => {
100
+ vi.useRealTimers();
101
+ timezoneMock.register('US/Pacific');
102
+ try {
103
+ const localReferenceDate = dayjs('2024-03-31T12:00:00');
104
+ expect(formatPartialDate('2020-03-22', { time: false, noToday: true })).toEqual('22-Mar-2020');
105
+ expect(parseDateInput('2020-03-22', localReferenceDate)?.format('YYYY-MM-DD')).toEqual('2020-03-22');
106
+ } finally {
107
+ timezoneMock.unregister();
108
+ }
109
+ });
110
+ });
111
+
21
112
  describe('Openmrs Dates', () => {
22
113
  afterAll(() => timezoneMock.unregister());
23
114
 
@@ -26,6 +26,32 @@ export type DateInput = string | number | Date;
26
26
 
27
27
  const isoFormat = 'YYYY-MM-DDTHH:mm:ss.SSSZZ';
28
28
 
29
+ const isoCalendarDate = /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/;
30
+
31
+ /**
32
+ * Parses ISO calendar dates independently of the display locale, preserving missing fields.
33
+ * Invalid ISO dates must not fall back to a parser that may reinterpret or normalize them.
34
+ */
35
+ function parseDateString(dateString: string, locale: string): ReturnType<typeof attempt> {
36
+ const match = isoCalendarDate.exec(dateString);
37
+ if (!match) {
38
+ return attempt(dateString, locale);
39
+ }
40
+
41
+ const year = Number(match[1]);
42
+ const month = match[2] === undefined ? undefined : Number(match[2]);
43
+ const day = match[3] === undefined ? undefined : Number(match[3]);
44
+
45
+ // Check for calendar overflow in UTC. setUTCFullYear also preserves years below 100.
46
+ const date = new Date(0);
47
+ date.setUTCFullYear(year, (month ?? 1) - 1, day ?? 1);
48
+ if (date.getUTCFullYear() !== year || date.getUTCMonth() !== (month ?? 1) - 1 || date.getUTCDate() !== (day ?? 1)) {
49
+ return { invalid: `Invalid ISO calendar date '${dateString}'` };
50
+ }
51
+
52
+ return { year, ...(month !== undefined && { month }), ...(day !== undefined && { day }) };
53
+ }
54
+
29
55
  /**
30
56
  * This function checks whether a date string is the OpenMRS ISO format.
31
57
  * The format should be YYYY-MM-DDTHH:mm:ss.SSSZZ
@@ -240,7 +266,7 @@ const defaultOptions: FormatDateOptions = {
240
266
  // TODO: Shouldn't throw on null input
241
267
  export function formatPartialDate(dateString: string, options: Partial<FormatDateOptions> = {}) {
242
268
  const locale = getLocale();
243
- let parsed: ReturnType<typeof attempt> & { date?: number } = attempt(dateString, locale);
269
+ let parsed: ReturnType<typeof attempt> & { date?: number } = parseDateString(dateString, locale);
244
270
 
245
271
  if (parsed.invalid) {
246
272
  console.warn(`Could not parse invalid date '${dateString}'`);
@@ -444,8 +470,8 @@ export function formatDuration(duration: Intl.DurationInput, options?: Intl.Dura
444
470
  }
445
471
 
446
472
  /**
447
- * Parses a date input into a dayjs object. String inputs are interpreted using
448
- * any-date-parser with corrections for its month/day representation differences
473
+ * Parses a date input into a dayjs object. ISO calendar dates are parsed independently of locale;
474
+ * other strings use any-date-parser with corrections for its month/day representation differences
449
475
  * with dayjs. Non-string inputs are passed directly to dayjs.
450
476
  *
451
477
  * @param dateInput The date to parse.
@@ -459,7 +485,7 @@ export function parseDateInput(dateInput: dayjs.ConfigType, referenceDate: dayjs
459
485
 
460
486
  if (typeof dateInput === 'string') {
461
487
  const locale = getLocale();
462
- let parsedDate = attempt(dateInput, locale);
488
+ let parsedDate = parseDateString(dateInput, locale);
463
489
  if (parsedDate.invalid) {
464
490
  console.warn(`Could not interpret '${dateInput}' as a date`);
465
491
  return null;