diff --git a/package.json b/package.json index e3b25eab2..c2eb5df79 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ }, "packageManager": "yarn@4.2.2", "dependencies": { + "@formatjs/intl-durationformat": "^0.2.4", "@hookform/resolvers": "^3.6.0", "react-hook-form": "^7.52.0", "zod": "^3.23.8" diff --git a/packages/framework/esm-framework/docs/API.md b/packages/framework/esm-framework/docs/API.md index cf8830148..2da8bef5d 100644 --- a/packages/framework/esm-framework/docs/API.md +++ b/packages/framework/esm-framework/docs/API.md @@ -227,13 +227,11 @@ - [age](API.md#age) - [canAccessStorage](API.md#canaccessstorage) -- [daysIntoYear](API.md#daysintoyear) - [displayName](API.md#displayname) - [formatPatientName](API.md#formatpatientname) - [formattedName](API.md#formattedname) - [getDefaultsFromConfigSchema](API.md#getdefaultsfromconfigschema) - [getPatientName](API.md#getpatientname) -- [isSameDay](API.md#issameday) - [isVersionSatisfied](API.md#isversionsatisfied) - [retry](API.md#retry) - [selectPreferredName](API.md#selectpreferredname) @@ -6519,15 +6517,19 @@ ___ ### age -▸ **age**(`dateString`): `string` +▸ **age**(`birthDate`, `currentDate?`): `string` -Gets a human readable and locale supported age represention of the provided date string. +Gets a human readable and locale supported representation of a person's age, given their birthDate, +The representation logic follows the guideline here: +https://webarchive.nationalarchives.gov.uk/ukgwa/20160921162509mp_/http://systems.digital.nhs.uk/data/cui/uig/patben.pdf +(See Tables 7 and 8) #### Parameters | Name | Type | Description | | :------ | :------ | :------ | -| `dateString` | `string` | The stringified date. | +| `birthDate` | `undefined` \| ``null`` \| `string` \| `number` \| `Date` \| `Dayjs` | The birthDate. | +| `currentDate` | `undefined` \| ``null`` \| `string` \| `number` \| `Date` \| `Dayjs` | Optional. If provided, calculates the age of the person at the provided currentDate (instead of now). | #### Returns @@ -6537,7 +6539,7 @@ A human-readable string version of the age. #### Defined in -[packages/framework/esm-utils/src/age-helpers.ts:36](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-utils/src/age-helpers.ts#L36) +[packages/framework/esm-utils/src/age-helpers.ts:17](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-utils/src/age-helpers.ts#L17) ___ @@ -6567,30 +6569,6 @@ True if the WebStorage API object is able to be accessed, false otherwise ___ -### daysIntoYear - -▸ **daysIntoYear**(`date`): `number` - -Gets the number of days in the year of the given date. - -#### Parameters - -| Name | Type | Description | -| :------ | :------ | :------ | -| `date` | `Date` | The date to compute the days within the year. | - -#### Returns - -`number` - -The number of days. - -#### Defined in - -[packages/framework/esm-utils/src/age-helpers.ts:9](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-utils/src/age-helpers.ts#L9) - -___ - ### displayName ▸ **displayName**(`patient`): `string` @@ -6721,31 +6699,6 @@ The patient's display name or an empty string if name is not present. ___ -### isSameDay - -▸ **isSameDay**(`firstDate`, `secondDate`): `boolean` - -Checks if two dates are representing the same day. - -#### Parameters - -| Name | Type | Description | -| :------ | :------ | :------ | -| `firstDate` | `Date` | The first date. | -| `secondDate` | `Date` | The second date. | - -#### Returns - -`boolean` - -True if both are located on the same day. - -#### Defined in - -[packages/framework/esm-utils/src/age-helpers.ts:25](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-utils/src/age-helpers.ts#L25) - -___ - ### isVersionSatisfied ▸ **isVersionSatisfied**(`requiredVersion`, `installedVersion`): `boolean` diff --git a/packages/framework/esm-utils/src/age-helpers.test.ts b/packages/framework/esm-utils/src/age-helpers.test.ts new file mode 100644 index 000000000..3d1ad83f9 --- /dev/null +++ b/packages/framework/esm-utils/src/age-helpers.test.ts @@ -0,0 +1,37 @@ +import dayjs from 'dayjs'; +import type { i18n } from 'i18next'; +import { age } from '.'; + +window.i18next = { language: 'en' } as i18n; + +describe('Age Helper', () => { + // test cases mostly taken from + // https://webarchive.nationalarchives.gov.uk/ukgwa/20160921162509mp_/http://systems.digital.nhs.uk/data/cui/uig/patben.pdf + // (Table 8) + const now = dayjs('2024-07-30'); + const test1 = now.subtract(1, 'hour').subtract(30, 'minutes'); + const test2 = now.subtract(1, 'day').subtract(2, 'hours').subtract(5, 'minutes'); + const test3 = now.subtract(3, 'days').subtract(17, 'hours').subtract(30, 'minutes'); + const test4 = now.subtract(27, 'days').subtract(5, 'hours').subtract(2, 'minutes'); + const test5 = now.subtract(28, 'days').subtract(5, 'hours').subtract(2, 'minutes'); + const test6 = now.subtract(29, 'days').subtract(5, 'hours').subtract(2, 'minutes'); + const test7 = now.subtract(1, 'year').subtract(1, 'day').subtract(5, 'hours'); + const test8 = now.subtract(1, 'year').subtract(8, 'day').subtract(5, 'hours'); + const test9 = now.subtract(1, 'year').subtract(39, 'day').subtract(5, 'hours'); + const test10 = now.subtract(4, 'year').subtract(39, 'day'); + const test11 = now.subtract(18, 'year').subtract(39, 'day'); + + it('should render durations correctly', () => { + expect(age(test1, now)).toBe('90 min'); + expect(age(test2, now)).toBe('26 hr'); + expect(age(test3, now)).toBe('3 days'); + expect(age(test4, now)).toBe('27 days'); + expect(age(test5, now)).toBe('4 wks'); + expect(age(test6, now)).toBe('4 wks, 1 day'); + expect(age(test7, now)).toBe('12 mths, 1 day'); + expect(age(test8, now)).toBe('12 mths, 8 days'); + expect(age(test9, now)).toBe('13 mths, 9 days'); + expect(age(test10, now)).toBe('4 yrs, 1 mth'); + expect(age(test11, now)).toBe('18 yrs'); + }); +}); diff --git a/packages/framework/esm-utils/src/age-helpers.ts b/packages/framework/esm-utils/src/age-helpers.ts index ae858f258..12048a52d 100644 --- a/packages/framework/esm-utils/src/age-helpers.ts +++ b/packages/framework/esm-utils/src/age-helpers.ts @@ -1,101 +1,55 @@ /** @module @category Utility */ +import dayjs from 'dayjs'; import { getLocale } from './omrs-dates'; +import { DurationFormat } from '@formatjs/intl-durationformat'; +import { type DurationInput } from '@formatjs/intl-durationformat/src/types'; /** - * Gets the number of days in the year of the given date. - * @param date The date to compute the days within the year. - * @returns The number of days. - */ -export function daysIntoYear(date: Date) { - return ( - (Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) - Date.UTC(date.getUTCFullYear(), 0, 0)) / - 24 / - 60 / - 60 / - 1000 - ); -} - -/** - * Checks if two dates are representing the same day. - * @param firstDate The first date. - * @param secondDate The second date. - * @returns True if both are located on the same day. - */ -export function isSameDay(firstDate: Date, secondDate: Date) { - const firstISO = firstDate.toISOString(); - const secondISO = secondDate.toISOString(); - return firstISO.slice(0, firstISO.indexOf('T')) === secondISO.slice(0, secondISO.indexOf('T')); -} - -/** - * Gets a human readable and locale supported age represention of the provided date string. - * @param dateString The stringified date. + * Gets a human readable and locale supported representation of a person's age, given their birthDate, + * The representation logic follows the guideline here: + * https://webarchive.nationalarchives.gov.uk/ukgwa/20160921162509mp_/http://systems.digital.nhs.uk/data/cui/uig/patben.pdf + * (See Tables 7 and 8) + * + * @param birthDate The birthDate. + * @param currentDate Optional. If provided, calculates the age of the person at the provided currentDate (instead of now). * @returns A human-readable string version of the age. */ -export function age(dateString: string): string { - // Different from npm packages such as https://www.npmjs.com/package/timeago +export function age(birthDate: dayjs.ConfigType, currentDate: dayjs.ConfigType = dayjs()): string { + const to = dayjs(currentDate); + const from = dayjs(birthDate); - // First calculate the age in years - const today = new Date(); - const birthDate = new Date(dateString); - const monthDifference = today.getUTCMonth() - birthDate.getUTCMonth(); - const dateDifference = today.getUTCDate() - birthDate.getUTCDate(); - let age = today.getUTCFullYear() - birthDate.getUTCFullYear(); - if (monthDifference < 0 || (monthDifference === 0 && dateDifference < 0)) { - age--; - } - - // Now calculate the number of months in addition to the year's age - let monthsAgo = monthDifference >= 0 ? monthDifference : monthDifference + 12; - if (dateDifference < 0) { - monthsAgo--; - } + const hourDiff = to.diff(from, 'hours'); + const dayDiff = to.diff(from, 'days'); + const weekDiff = to.diff(from, 'weeks'); + const monthDiff = to.diff(from, 'months'); + const yearDiff = to.diff(from, 'years'); - // For patients less than a year old, we calculate the number of days/weeks they have been alive - let totalDaysAgo = daysIntoYear(today) - daysIntoYear(birthDate); - if (totalDaysAgo < 0) { - totalDaysAgo += 365; - } - const weeksAgo = Math.floor(totalDaysAgo / 7); + const duration: DurationInput = {}; const locale = getLocale(); - // Depending on their age, return a different representation of their age. - if (age === 0) { - if (isSameDay(today, birthDate)) { - const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); - return rtf.format(0, 'day'); - } else if (totalDaysAgo < 31) { - const totalDaysAgoStr = new Intl.NumberFormat(locale, { - style: 'unit', - unit: 'day', - unitDisplay: 'short', - }).format(totalDaysAgo); - - return totalDaysAgoStr; - } else { - const weeksAgoStr = new Intl.NumberFormat(locale, { - style: 'unit', - unit: 'week', - unitDisplay: 'short', - }).format(weeksAgo); - return weeksAgoStr; - } - } else if (age < 2) { - const monthsAgoStr = new Intl.NumberFormat(locale, { - style: 'unit', - unit: 'month', - unitDisplay: 'short', - }).format(monthsAgo + 12); - - return monthsAgoStr; + if (hourDiff < 2) { + const minuteDiff = to.diff(from, 'minutes'); + duration['minutes'] = minuteDiff; + } else if (dayDiff < 2) { + duration['hours'] = hourDiff; + } else if (weekDiff < 4) { + duration['days'] = dayDiff; + } else if (yearDiff < 1) { + const remainderDayDiff = to.subtract(weekDiff, 'weeks').diff(from, 'days'); + duration['weeks'] = weekDiff; + duration['days'] = remainderDayDiff; + } else if (yearDiff < 2) { + const remainderDayDiff = to.subtract(monthDiff, 'months').diff(from, 'days'); + duration['months'] = monthDiff; + duration['days'] = remainderDayDiff; + } else if (yearDiff < 18) { + const remainderMonthDiff = to.subtract(yearDiff, 'year').diff(from, 'months'); + duration['years'] = yearDiff; + duration['months'] = remainderMonthDiff; } else { - const yearsAgoStr = new Intl.NumberFormat(locale, { - style: 'unit', - unit: 'year', - unitDisplay: 'short', - }).format(age); - return yearsAgoStr; + duration['years'] = yearDiff; } + + return new DurationFormat(locale, { style: 'short' }).format(duration); } diff --git a/yarn.lock b/yarn.lock index 5800a5bca..866ffc356 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1855,6 +1855,16 @@ __metadata: languageName: node linkType: hard +"@formatjs/ecma402-abstract@npm:2.0.0": + version: 2.0.0 + resolution: "@formatjs/ecma402-abstract@npm:2.0.0" + dependencies: + "@formatjs/intl-localematcher": "npm:0.5.4" + tslib: "npm:^2.4.0" + checksum: 10/41543ba509ea3c7d6530d57b888115f7ca242f13462a951fae4d1d1f28bae10c999f4dea28a71d2f08366d4889a3f5276cae3a16c6f6417b841a84fd314c2234 + languageName: node + linkType: hard + "@formatjs/fast-memoize@npm:2.2.0": version: 2.2.0 resolution: "@formatjs/fast-memoize@npm:2.2.0" @@ -1885,6 +1895,17 @@ __metadata: languageName: node linkType: hard +"@formatjs/intl-durationformat@npm:^0.2.4": + version: 0.2.4 + resolution: "@formatjs/intl-durationformat@npm:0.2.4" + dependencies: + "@formatjs/ecma402-abstract": "npm:2.0.0" + "@formatjs/intl-localematcher": "npm:0.5.4" + tslib: "npm:^2.4.0" + checksum: 10/5f500409a20d18967e17ffbc222f9b4c4bf7ef08cce20023c33f06d1989c2bc4cf700d1dd1d048748d0a36c882109d5375896a4964d6700f73ec18914c6de4ba + languageName: node + linkType: hard + "@formatjs/intl-localematcher@npm:0.4.0": version: 0.4.0 resolution: "@formatjs/intl-localematcher@npm:0.4.0" @@ -1894,6 +1915,15 @@ __metadata: languageName: node linkType: hard +"@formatjs/intl-localematcher@npm:0.5.4": + version: 0.5.4 + resolution: "@formatjs/intl-localematcher@npm:0.5.4" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10/780cb29b42e1ea87f2eb5db268577fcdc53da52d9f096871f3a1bb78603b4ba81d208ea0b0b9bc21548797c941ce435321f62d2522795b83b740f90b0ceb5778 + languageName: node + linkType: hard + "@gar/promisify@npm:^1.0.1, @gar/promisify@npm:^1.1.3": version: 1.1.3 resolution: "@gar/promisify@npm:1.1.3" @@ -3012,6 +3042,7 @@ __metadata: version: 0.0.0-use.local resolution: "@openmrs/esm-core@workspace:." dependencies: + "@formatjs/intl-durationformat": "npm:^0.2.4" "@hookform/resolvers": "npm:^3.6.0" "@playwright/test": "npm:1.45.3" "@swc/core": "npm:^1.3.58"