@openmrs/esm-utils 9.0.3-pre.4257 → 9.0.3-pre.4264

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.
@@ -442,3 +442,283 @@ export function formatDuration(duration: Intl.DurationInput, options?: Intl.Dura
442
442
  const formatter = new Intl.DurationFormat(getLocale(), options);
443
443
  return formatter.format(duration);
444
444
  }
445
+
446
+ /**
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
449
+ * with dayjs. Non-string inputs are passed directly to dayjs.
450
+ *
451
+ * @param dateInput The date to parse.
452
+ * @param referenceDate Used as the base when resolving partial string dates (e.g., '2000' resolves missing fields from this date).
453
+ * @returns A dayjs object, or null if the string could not be parsed.
454
+ */
455
+ export function parseDateInput(dateInput: dayjs.ConfigType, referenceDate: dayjs.Dayjs): dayjs.Dayjs | null {
456
+ if (dateInput == null) {
457
+ return null;
458
+ }
459
+
460
+ if (typeof dateInput === 'string') {
461
+ const locale = getLocale();
462
+ let parsedDate = attempt(dateInput, locale);
463
+ if (parsedDate.invalid) {
464
+ console.warn(`Could not interpret '${dateInput}' as a date`);
465
+ return null;
466
+ }
467
+
468
+ // hack here but any date interprets 2000-01, etc. as yyyy-dd rather than yyyy-mm
469
+ if (parsedDate.day && !parsedDate.month) {
470
+ parsedDate = { ...omit(parsedDate, 'day'), ...{ month: parsedDate.day } };
471
+ }
472
+
473
+ // dayjs' object support uses 0-based months, whereas any-date-parser uses 1-based months
474
+ if (parsedDate.month) {
475
+ parsedDate.month -= 1;
476
+ }
477
+
478
+ // in dayjs day is day of week; in any-date-parser, its day of month, so we need to convert them
479
+ if (parsedDate.day) {
480
+ parsedDate = { ...omit(parsedDate, 'day'), ...{ date: parsedDate.day } };
481
+ }
482
+
483
+ return dayjs(referenceDate).set(parsedDate);
484
+ }
485
+
486
+ return dayjs(dateInput);
487
+ }
488
+
489
+ type DurationUnitPlural = 'seconds' | 'minutes' | 'hours' | 'days' | 'months' | 'years';
490
+ type DurationUnitSingular = 'second' | 'minute' | 'hour' | 'day' | 'month' | 'year';
491
+
492
+ /** Accepts both singular ('year') and plural ('years') forms, mirroring Temporal.Duration.round(). */
493
+ export type DurationUnit = DurationUnitPlural | DurationUnitSingular;
494
+
495
+ export interface DurationOptions {
496
+ /** Override auto-selection thresholds. Each value is in the unit's own terms (e.g., seconds: 30 means "use seconds if < 30 seconds"). */
497
+ thresholds?: Partial<Record<DurationUnit, number>>;
498
+ /**
499
+ * Coarsest unit to include. Accepts 'auto' (default when smallestUnit is set),
500
+ * which resolves to the largest non-zero unit or smallestUnit, whichever is greater.
501
+ * Mirrors Temporal.Duration.round() behavior.
502
+ */
503
+ largestUnit?: DurationUnit | 'auto';
504
+ /** Finest unit to include. Defaults to largestUnit when largestUnit is an explicit unit, giving a single-unit result. */
505
+ smallestUnit?: DurationUnit;
506
+ }
507
+
508
+ export interface DurationOptionsWithFormat extends DurationOptions {
509
+ /** Options passed to Intl.DurationFormat. Defaults to { style: 'short', localeMatcher: 'lookup' }. */
510
+ formatOptions?: Intl.DurationFormatOptions;
511
+ }
512
+
513
+ const UNIT_ORDER: DurationUnitPlural[] = ['years', 'months', 'days', 'hours', 'minutes', 'seconds'];
514
+
515
+ const SINGULAR_TO_PLURAL: Record<DurationUnitSingular, DurationUnitPlural> = {
516
+ second: 'seconds',
517
+ minute: 'minutes',
518
+ hour: 'hours',
519
+ day: 'days',
520
+ month: 'months',
521
+ year: 'years',
522
+ };
523
+
524
+ function normalizeUnit(unit: DurationUnit): DurationUnitPlural {
525
+ return SINGULAR_TO_PLURAL[unit as DurationUnitSingular] ?? (unit as DurationUnitPlural);
526
+ }
527
+
528
+ /**
529
+ * Normalizes threshold keys from singular/plural to plural, then merges with defaults.
530
+ */
531
+ function normalizeThresholds(thresholds?: Partial<Record<DurationUnit, number>>): Record<DurationUnitPlural, number> {
532
+ const result = { ...DEFAULT_THRESHOLDS };
533
+ if (thresholds) {
534
+ for (const [key, value] of Object.entries(thresholds)) {
535
+ if (value !== undefined) {
536
+ result[normalizeUnit(key as DurationUnit)] = value;
537
+ }
538
+ }
539
+ }
540
+ return result;
541
+ }
542
+
543
+ const DEFAULT_THRESHOLDS: Record<DurationUnitPlural, number> = {
544
+ seconds: 45,
545
+ minutes: 45,
546
+ hours: 22,
547
+ days: 26,
548
+ months: 11,
549
+ years: Infinity,
550
+ };
551
+
552
+ /**
553
+ * Auto-selects the appropriate unit based on the magnitude of the duration,
554
+ * using the provided thresholds (or defaults).
555
+ */
556
+ function autoSelectUnit(
557
+ from: dayjs.Dayjs,
558
+ to: dayjs.Dayjs,
559
+ thresholds?: Partial<Record<DurationUnit, number>>,
560
+ ): DurationUnitPlural {
561
+ const t = normalizeThresholds(thresholds);
562
+
563
+ if (to.diff(from, 'seconds') < t.seconds) return 'seconds';
564
+ if (to.diff(from, 'minutes') < t.minutes) return 'minutes';
565
+ if (to.diff(from, 'hours') < t.hours) return 'hours';
566
+ if (to.diff(from, 'days') < t.days) return 'days';
567
+ if (to.diff(from, 'months') < t.months) return 'months';
568
+ return 'years';
569
+ }
570
+
571
+ /**
572
+ * Finds the largest unit with a non-zero diff, or falls back to smallestUnit.
573
+ * Mirrors the Temporal.Duration.round() 'auto' behavior for largestUnit.
574
+ */
575
+ function autoLargestUnit(from: dayjs.Dayjs, to: dayjs.Dayjs, smallestUnit: DurationUnitPlural): DurationUnitPlural {
576
+ const smallestIdx = UNIT_ORDER.indexOf(smallestUnit);
577
+
578
+ for (let i = 0; i < UNIT_ORDER.length; i++) {
579
+ const unit = UNIT_ORDER[i];
580
+ if (to.diff(from, unit) > 0) {
581
+ // Return this unit or smallestUnit, whichever is coarser (lower index)
582
+ return i <= smallestIdx ? unit : smallestUnit;
583
+ }
584
+ }
585
+
586
+ return smallestUnit;
587
+ }
588
+
589
+ /**
590
+ * Decomposes the duration between two dates across a range of units, from
591
+ * largestUnit down to smallestUnit. Each unit's value is the remainder after
592
+ * subtracting all larger units.
593
+ */
594
+ function decompose(
595
+ from: dayjs.Dayjs,
596
+ to: dayjs.Dayjs,
597
+ largestUnit: DurationUnitPlural,
598
+ smallestUnit: DurationUnitPlural,
599
+ ): Intl.DurationInput {
600
+ const startIdx = UNIT_ORDER.indexOf(largestUnit);
601
+ const endIdx = UNIT_ORDER.indexOf(smallestUnit);
602
+ const units = UNIT_ORDER.slice(startIdx, endIdx + 1);
603
+
604
+ const result: Intl.DurationInput = {};
605
+ let current = from;
606
+
607
+ for (const unit of units) {
608
+ const diff = to.diff(current, unit);
609
+ result[unit] = diff;
610
+ current = current.add(diff, unit);
611
+ }
612
+
613
+ return result;
614
+ }
615
+
616
+ /**
617
+ * Calculates the duration between two dates as a structured duration object.
618
+ *
619
+ * When called with no options or a single unit string, the unit is auto-selected
620
+ * using dayjs relativeTime thresholds:
621
+ * - < 45 seconds → seconds
622
+ * - < 45 minutes → minutes
623
+ * - < 22 hours → hours
624
+ * - < 26 days → days
625
+ * - < 11 months → months
626
+ * - otherwise → years
627
+ *
628
+ * With a {@link DurationOptions} object, you can override thresholds and/or request
629
+ * a multi-unit decomposition via largestUnit/smallestUnit.
630
+ *
631
+ * @param startDate The start date. If null, returns null.
632
+ * @param endDate Optional. Defaults to now.
633
+ * @param options A unit string for single-unit output, or a DurationOptions object.
634
+ * @returns A DurationInput object, or null if either date is null or unparseable.
635
+ *
636
+ * @example
637
+ * // Auto-selects the appropriate unit
638
+ * duration('2022-01-01', '2024-07-30') // => { years: 2 }
639
+ *
640
+ * @example
641
+ * // Multi-unit decomposition
642
+ * duration('2022-01-01', '2024-07-30', { largestUnit: 'year', smallestUnit: 'day' })
643
+ * // => { years: 2, months: 6, days: 29 }
644
+ */
645
+ export function duration(
646
+ startDate: dayjs.ConfigType,
647
+ endDate: dayjs.ConfigType = dayjs(),
648
+ options?: DurationUnit | DurationOptions,
649
+ ): Intl.DurationInput | null {
650
+ const to = dayjs(endDate);
651
+ const from = parseDateInput(startDate, to);
652
+
653
+ if (from == null) {
654
+ return null;
655
+ }
656
+
657
+ if (typeof options === 'string') {
658
+ const normalized = normalizeUnit(options);
659
+ return { [normalized]: to.diff(from, normalized) };
660
+ }
661
+
662
+ const { thresholds, largestUnit: rawLargest, smallestUnit: rawSmallest } = options ?? {};
663
+
664
+ if (rawLargest !== undefined || rawSmallest !== undefined) {
665
+ const smallest = rawSmallest ? normalizeUnit(rawSmallest) : undefined;
666
+
667
+ let largest: DurationUnitPlural;
668
+ if (rawLargest === 'auto' || rawLargest === undefined) {
669
+ // 'auto' or omitted: resolve to the largest non-zero unit, or smallestUnit, whichever is coarser
670
+ const effectiveSmallest = smallest ?? 'seconds';
671
+ largest = autoLargestUnit(from, to, effectiveSmallest);
672
+ } else {
673
+ largest = normalizeUnit(rawLargest);
674
+ }
675
+
676
+ return decompose(from, to, largest, smallest ?? largest);
677
+ }
678
+
679
+ const selected = autoSelectUnit(from, to, thresholds);
680
+ return { [selected]: to.diff(from, selected) };
681
+ }
682
+
683
+ const DEFAULT_FORMAT_OPTIONS: Intl.DurationFormatOptions = { style: 'short', localeMatcher: 'lookup' };
684
+
685
+ /**
686
+ * Calculates the duration between two dates and formats it as a locale-aware string.
687
+ * Uses the same unit-selection logic as {@link duration} and delegates formatting
688
+ * to {@link formatDuration}.
689
+ *
690
+ * @param startDate The start date. If null, returns null.
691
+ * @param endDate Optional. Defaults to now.
692
+ * @param options A unit string for single-unit output, or a {@link DurationOptionsWithFormat} object.
693
+ * The `formatOptions` field is passed to Intl.DurationFormat (defaults to short style).
694
+ * @returns A formatted duration string, or null if either date is null or unparseable.
695
+ *
696
+ * @example
697
+ * formatDurationBetween('2022-01-01', '2024-07-30') // => '2 yrs'
698
+ *
699
+ * @example
700
+ * // Multi-unit with long-form formatting
701
+ * formatDurationBetween('2022-01-01', '2024-07-30', {
702
+ * largestUnit: 'year',
703
+ * smallestUnit: 'day',
704
+ * formatOptions: { style: 'long' },
705
+ * }) // => '2 years, 6 months, 29 days'
706
+ */
707
+ export function formatDurationBetween(
708
+ startDate: dayjs.ConfigType,
709
+ endDate: dayjs.ConfigType = dayjs(),
710
+ options?: DurationUnit | DurationOptionsWithFormat,
711
+ ): string | null {
712
+ const durationInput = duration(startDate, endDate, options);
713
+
714
+ if (durationInput == null) {
715
+ return null;
716
+ }
717
+
718
+ const formatOpts =
719
+ typeof options === 'object' && options.formatOptions
720
+ ? { ...DEFAULT_FORMAT_OPTIONS, ...options.formatOptions }
721
+ : DEFAULT_FORMAT_OPTIONS;
722
+
723
+ return formatDuration(durationInput, formatOpts);
724
+ }