From 9722a215a5b985d61a23b5b4b0c84ca2456f8bd7 Mon Sep 17 00:00:00 2001 From: Thomas Zemp Date: Mon, 14 Oct 2024 19:45:16 +0200 Subject: [PATCH] fix: handle ISO time stamps from backend --- i18n/en.pot | 8 +- src/bottom-bar/form-expiry-info.js | 10 +- src/bottom-bar/form-expiry-info.test.js | 7 +- .../period-selector-bar-item.js | 13 +- .../period-selector-bar-item/use-periods.js | 14 +- .../year-navigator.js | 1 - .../data-details-sidebar/audit-log.js | 25 +- .../data-details-sidebar/audit-log.test.js | 65 ++--- .../data-details-sidebar/basic-information.js | 18 +- .../basic-information.test.js | 4 +- .../history-line-chart.js | 24 +- src/shared/date/date-text.js | 65 +++++ src/shared/date/date-text.test.js | 150 +++++++++++ src/shared/date/date-utils.js | 164 +++++++----- src/shared/date/date-utils.test.js | 235 ++++++++++++------ src/shared/date/index.js | 3 + .../locked-status/use-check-lock-status.js | 86 ++++--- .../use-check-lock-status.test.js | 3 +- src/shared/metadata/selectors.js | 26 +- src/test-utils/render.js | 14 +- yarn.lock | 27 +- 21 files changed, 681 insertions(+), 281 deletions(-) create mode 100644 src/shared/date/date-text.js create mode 100644 src/shared/date/date-text.test.js diff --git a/i18n/en.pot b/i18n/en.pot index c26f4e5db..0a8628ab2 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-09-25T13:24:25.324Z\n" -"PO-Revision-Date: 2024-09-25T13:24:25.324Z\n" +"POT-Creation-Date: 2024-10-14T16:17:34.694Z\n" +"PO-Revision-Date: 2024-10-14T16:17:34.694Z\n" msgid "Not authorized" msgstr "Not authorized" @@ -351,8 +351,8 @@ msgstr "User" msgid "Change" msgstr "Change" -msgid "audit dates are given in {{- timezone}} time" -msgstr "audit dates are given in {{- timezone}} time" +msgid "audit dates are given in {{- timeZone}} time" +msgstr "audit dates are given in {{- timeZone}} time" msgid "Unmark for follow-up" msgstr "Unmark for follow-up" diff --git a/src/bottom-bar/form-expiry-info.js b/src/bottom-bar/form-expiry-info.js index 3cd636bfd..108865fc6 100644 --- a/src/bottom-bar/form-expiry-info.js +++ b/src/bottom-bar/form-expiry-info.js @@ -13,11 +13,11 @@ export default function FormExpiryInfo() { } = useLockedContext() const { systemInfo = {} } = useConfig() - const { calendar = 'gregory', serverTimeZoneId: timezone = 'Etc/UTC' } = - systemInfo + const { serverTimeZoneId: timezone = 'Etc/UTC' } = systemInfo + // the lock date is returned in ISO calendar const relativeTime = getRelativeTime({ startDate: lockDate, - calendar, + calendar: 'gregory', timezone, }) const dateTime = `${lockDate} (${timezone})` @@ -43,9 +43,7 @@ export default function FormExpiryInfo() { {i18n.t('Closes {{-relativeTime}}', { - relativeTime: relativeTime - ? relativeTime - : dateTime, + relativeTime, })} diff --git a/src/bottom-bar/form-expiry-info.test.js b/src/bottom-bar/form-expiry-info.test.js index 0b3062b89..11e047661 100644 --- a/src/bottom-bar/form-expiry-info.test.js +++ b/src/bottom-bar/form-expiry-info.test.js @@ -48,18 +48,15 @@ describe('FormExpiryInfo', () => { expect(getByText('Closes in 2 hours')).toBeInTheDocument() }) - it('shows absolute time for lockedDate, if not locked, there is a lockDate, and calendar not gregory', () => { + it('shows relative time for lockedDate, if not locked, there is a lockDate, and calendar not gregory', () => { useConfig.mockImplementation(() => ({ systemInfo: { - serverTimeZoneId: 'Africa/Addis_Ababa', calendar: 'ethiopian', }, })) const { getByText } = render() - expect( - getByText('Closes 2024-03-15T14:15:00 (Africa/Addis_Ababa)') - ).toBeInTheDocument() + expect(getByText('Closes in 2 hours')).toBeInTheDocument() }) it('corrects relative time for time zone differences', () => { diff --git a/src/context-selection/period-selector-bar-item/period-selector-bar-item.js b/src/context-selection/period-selector-bar-item/period-selector-bar-item.js index 4efbf12cf..5c2ccd567 100644 --- a/src/context-selection/period-selector-bar-item/period-selector-bar-item.js +++ b/src/context-selection/period-selector-bar-item/period-selector-bar-item.js @@ -122,12 +122,15 @@ export const PeriodSelectorBarItem = () => { const endDate = selectedPeriod?.endDate const displayName = selectedPeriod?.displayName - // date comparison + // date comparison (both in system calendar) if ( - isDateAGreaterThanDateB(endDate, dateLimit, { - inclusive: true, - calendar, - }) + isDateAGreaterThanDateB( + { date: endDate, calendar }, + { date: dateLimit, calendar }, + { + inclusive: true, + } + ) ) { resetPeriod(periodId, displayName) } diff --git a/src/context-selection/period-selector-bar-item/use-periods.js b/src/context-selection/period-selector-bar-item/use-periods.js index 9678cc018..de174b5c7 100644 --- a/src/context-selection/period-selector-bar-item/use-periods.js +++ b/src/context-selection/period-selector-bar-item/use-periods.js @@ -70,15 +70,14 @@ export default function usePeriods({ // we want to display the last period of the previous year if it // stretches into the current year - // date comparison + // date comparison (both are system calendar) if ( lastPeriodOfPrevYear && isDateALessThanDateB( - `${year}-01-01`, - lastPeriodOfPrevYear.endDate, + { date: `${year}-01-01`, calendar }, + { date: lastPeriodOfPrevYear.endDate, calendar }, { inclusive: true, - calendar, } ) ) { @@ -97,10 +96,11 @@ export default function usePeriods({ if ( firstPeriodNextYear && // `${year + 1}-01-01` > firstPeriodNextYear.startDate + // date comparison (both in system calendar) isDateAGreaterThanDateB( - `${year + 1}-01-01`, - firstPeriodNextYear.startDate, - { inclusive: false, calendar } + { date: `${year + 1}-01-01`, calendar }, + { date: firstPeriodNextYear.startDate, calendar }, + { inclusive: false } ) ) { periods.push(firstPeriodNextYear) diff --git a/src/context-selection/period-selector-bar-item/year-navigator.js b/src/context-selection/period-selector-bar-item/year-navigator.js index 8755206de..16e8f124d 100644 --- a/src/context-selection/period-selector-bar-item/year-navigator.js +++ b/src/context-selection/period-selector-bar-item/year-navigator.js @@ -10,7 +10,6 @@ export default function YearNavigator({ onYearChange, calendar, }) { - // console.log('maxYear', maxYear) const startYear = startingYears[calendar] ?? startingYears.default return (
diff --git a/src/data-workspace/data-details-sidebar/audit-log.js b/src/data-workspace/data-details-sidebar/audit-log.js index 63fddc183..a57e7668b 100644 --- a/src/data-workspace/data-details-sidebar/audit-log.js +++ b/src/data-workspace/data-details-sidebar/audit-log.js @@ -1,4 +1,3 @@ -import { useConfig } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' import { CircularLoader, @@ -14,7 +13,11 @@ import { } from '@dhis2/ui' import PropTypes from 'prop-types' import React from 'react' -import { ExpandableUnit, useConnectionStatus } from '../../shared/index.js' +import { + ExpandableUnit, + useConnectionStatus, + DateText, +} from '../../shared/index.js' import styles from './audit-log.module.css' import useDataValueContext from './use-data-value-context.js' import useOpenState from './use-open-state.js' @@ -25,8 +28,7 @@ export default function AuditLog({ item }) { const { offline } = useConnectionStatus() const { open, setOpen, openRef } = useOpenState(item) const dataValueContext = useDataValueContext(item, openRef.current) - const { systemInfo = {} } = useConfig() - const { serverTimeZoneId: timezone = 'Etc/UTC' } = systemInfo + const timeZone = Intl.DateTimeFormat()?.resolvedOptions()?.timeZone if (!offline && (!open || dataValueContext.isLoading)) { return ( @@ -110,11 +112,12 @@ export default function AuditLog({ item }) { return ( - {created - ? `${created - .substring(0, 16) - .replace('T', ' ')}` - : null} + {created ? ( + + ) : null} {user} @@ -142,8 +145,8 @@ export default function AuditLog({ item }) { {audits.length > 0 && (
{i18n.t( - 'audit dates are given in {{- timezone}} time', - { timezone } + 'audit dates are given in {{- timeZone}} time', + { timeZone } )}
)} diff --git a/src/data-workspace/data-details-sidebar/audit-log.test.js b/src/data-workspace/data-details-sidebar/audit-log.test.js index 4d4dea3f1..23579cce1 100644 --- a/src/data-workspace/data-details-sidebar/audit-log.test.js +++ b/src/data-workspace/data-details-sidebar/audit-log.test.js @@ -1,17 +1,10 @@ -import { waitFor } from '@testing-library/react' +import { waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import React from 'react' import { render } from '../../test-utils/index.js' import AuditLog from './audit-log.js' import useDataValueContext from './use-data-value-context.js' -jest.mock('@dhis2/app-runtime', () => ({ - ...jest.requireActual('@dhis2/app-runtime'), - useConfig: jest.fn(() => ({ - systemInfo: { serverTimeZoneId: 'Africa/Cairo' }, - })), -})) - jest.mock('./use-data-value-context.js', () => ({ __esModule: true, default: jest.fn(), @@ -74,26 +67,26 @@ describe('', () => { // @TODO: Enable and fix when working on: // https://dhis2.atlassian.net/browse/TECH-1281 - it.skip('renders the item audit log once loaded', async () => { + it('renders the item audit log once loaded', async () => { const audits = [ { - auditType: 'UPDATE', - created: new Date('2021-01-01').toISOString(), + auditType: 'DELETE', + created: new Date('2021-03-01').toISOString(), modifiedBy: 'Firstname Lastname', - prevValue: '19', value: '21', }, { auditType: 'UPDATE', created: new Date('2021-02-01').toISOString(), modifiedBy: 'Firstname2 Lastname2', + prevValue: '19', value: '21', }, { - auditType: 'DELETE', - created: new Date('2021-03-01').toISOString(), + auditType: 'UPDATE', + created: new Date('2021-01-01').toISOString(), modifiedBy: 'Firstname3 Lastname3', - value: '', + value: '19', }, ] @@ -113,26 +106,38 @@ describe('', () => { expect(queryByRole('progressbar')).not.toBeInTheDocument() }) - expect(getByRole('list')).toBeInTheDocument() - expect(getAllByRole('listitem')).toHaveLength(audits.length) + // the number of rows is: the length of audits + 1 (for header row) + const auditRows = getAllByRole('row') - const firstChangeEl = getByText('Firstname Lastname set to 19', { - selector: '.entry:nth-child(3):last-child .entryMessage', - }) - expect(firstChangeEl).toBeInTheDocument() + expect(auditRows).toHaveLength(audits.length + 1) - const secondChangeEl = getByText( - 'Firstname2 Lastname2 updated to 21 (was 19)', - { selector: '.entry:nth-child(2) .entryMessage' } + const firstChangeName = within(auditRows[1]).getByText( + 'Firstname Lastname', + {} ) - expect(secondChangeEl).toBeInTheDocument() + expect(firstChangeName).toBeInTheDocument() + const firstChangeValue = within(auditRows[1]).getByText('21', {}) + expect(firstChangeValue).toBeInTheDocument() - const thirdChangeEl = getByText( - 'Firstname3 Lastname3 deleted (was 21)', - { selector: '.entry:nth-child(1) .entryMessage' } + const secondChangeName = within(auditRows[2]).getByText( + 'Firstname2 Lastname2', + {} ) - expect(thirdChangeEl).toBeInTheDocument() + expect(secondChangeName).toBeInTheDocument() + const secondChangeValue = within(auditRows[2]).getByText('21', {}) + expect(secondChangeValue).toBeInTheDocument() + + const thirdChangeName = within(auditRows[3]).getByText( + 'Firstname3 Lastname3', + {} + ) + expect(thirdChangeName).toBeInTheDocument() + const thirdChangeValue = within(auditRows[3]).getByText('19', {}) + expect(thirdChangeValue).toBeInTheDocument() + // check that note about time zone appears - expect('audit dates are given in Africa/Cairo time').toBeInTheDocument() + expect( + getByText('audit dates are given in UTC time') + ).toBeInTheDocument() }) }) diff --git a/src/data-workspace/data-details-sidebar/basic-information.js b/src/data-workspace/data-details-sidebar/basic-information.js index f24e9b833..7201759ff 100644 --- a/src/data-workspace/data-details-sidebar/basic-information.js +++ b/src/data-workspace/data-details-sidebar/basic-information.js @@ -2,25 +2,20 @@ import { useConfig } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' import { Tooltip, IconFlag16, colors } from '@dhis2/ui' import React from 'react' -import { getRelativeTime } from '../../shared/index.js' +import { getRelativeTime, DateText } from '../../shared/index.js' import FollowUpButton from './basic-information-follow-up-button.js' import styles from './basic-information.module.css' import ItemPropType from './item-prop-type.js' const BasicInformation = ({ item }) => { const { systemInfo = {} } = useConfig() - const { calendar = 'gregory', serverTimeZoneId: timezone = 'Etc/UTC' } = - systemInfo - - const lastUpdatedString = item.lastUpdated - ? `${item.lastUpdated} (${timezone})` - : null + const { serverTimeZoneId: timezone = 'Etc/UTC' } = systemInfo // if item.lastUpdated is "undefined", getRelativeTime returns null // and this will not be displayed const timeAgo = getRelativeTime({ startDate: item.lastUpdated, - calendar, + calendar: 'gregory', timezone, }) @@ -63,14 +58,11 @@ const BasicInformation = ({ item }) => {
  • {item.lastUpdated && ( - - {/* timeAgo will be null if non-Gregory calendar. TO DO: update */} + }> {i18n.t( 'Last updated {{- timeAgo}} by {{- name}}', { - timeAgo: timeAgo - ? timeAgo - : lastUpdatedString, + timeAgo, name: item.storedBy, } )} diff --git a/src/data-workspace/data-details-sidebar/basic-information.test.js b/src/data-workspace/data-details-sidebar/basic-information.test.js index 916e2e24b..ecf911f32 100644 --- a/src/data-workspace/data-details-sidebar/basic-information.test.js +++ b/src/data-workspace/data-details-sidebar/basic-information.test.js @@ -97,7 +97,7 @@ describe('', () => { expect(getByText('a minute ago', { exact: false })).toBeInTheDocument() }) - it('renders when item was last updated (absolute time stamp with time zone) if non-gregory calendar', () => { + it('renders relative time with non-gregory calendar', () => { useConfig.mockImplementation(() => ({ systemInfo: { serverTimeZoneId: 'Africa/Abidjan', @@ -115,7 +115,7 @@ describe('', () => { expect(getByText(item.storedBy, { exact: false })).toBeInTheDocument() expect( - getByText('2022-06-28T14:51:14.435 (Africa/Abidjan)', { + getByText('a minute ago', { exact: false, }) ).toBeInTheDocument() diff --git a/src/data-workspace/data-details-sidebar/history-line-chart.js b/src/data-workspace/data-details-sidebar/history-line-chart.js index fcf7be3bf..a67b93fc3 100644 --- a/src/data-workspace/data-details-sidebar/history-line-chart.js +++ b/src/data-workspace/data-details-sidebar/history-line-chart.js @@ -49,20 +49,28 @@ function sortHistoryByStartDate(history, calendar = 'gregory') { }).startDate // date comparison + // date comparison (both in system calendar) if ( - isDateAGreaterThanDateB(leftStartDate, rightStartDate, { - calendar, - inclusive: false, - }) + isDateAGreaterThanDateB( + { date: leftStartDate, calendar }, + { date: rightStartDate, calendar }, + { + inclusive: false, + } + ) ) { return 1 } if ( - isDateALessThanDateB(leftStartDate, rightStartDate, { - calendar, - inclusive: false, - }) + // date comparison (both are system calendar) + isDateALessThanDateB( + { date: leftStartDate, calendar }, + { date: rightStartDate, calendar }, + { + inclusive: false, + } + ) ) { return -1 } diff --git a/src/shared/date/date-text.js b/src/shared/date/date-text.js new file mode 100644 index 000000000..46b9ffeba --- /dev/null +++ b/src/shared/date/date-text.js @@ -0,0 +1,65 @@ +import { useConfig, useTimeZoneConversion } from '@dhis2/app-runtime' +import PropTypes from 'prop-types' +import React from 'react' +import { convertFromIso8601ToString } from './date-utils.js' + +const formatDate = ({ + dateString, + dateFormat = 'yyyy-mm-dd', + includeTimeZone = false, +}) => { + if (!dateString) { + return '' + } + // the returned date includes seconds/ms and we want to simplify to just show date and HH:MM + const year = dateString.substring(0, 4) + const month = dateString.substring(5, 7) + const day = dateString.substring(8, 10) + const minutes = dateString.substring(11, 13) + const seconds = dateString.substring(14, 16) + + const timeZone = Intl.DateTimeFormat()?.resolvedOptions()?.timeZone + + if (dateFormat.toLowerCase() === 'dd-mm-yyyy') { + return `${day}-${month}-${year} ${minutes}:${seconds} ${ + includeTimeZone && timeZone ? '(' + timeZone + ')' : '' + }` + } + return `${year}-${month}-${day} ${minutes}:${seconds} ${ + includeTimeZone && timeZone ? '(' + timeZone + ')' : '' + }` +} + +export const DateText = ({ date, includeTimeZone }) => { + const { systemInfo = {} } = useConfig() + const { calendar = 'gregory', dateFormat } = systemInfo + const { fromServerDate } = useTimeZoneConversion() + + // NOTE: the passed date is assumed to be in ISO + + // we first correct for time zone + const dateClient = fromServerDate(date) + + // then we convert to the system calendar (we pass the client time zone equivalent of the date) + const inSystemCalendarDateString = convertFromIso8601ToString( + dateClient.getClientZonedISOString(), + calendar + ) + + // we put it in the system setting for the date display + + return ( +

    + {formatDate({ + dateString: inSystemCalendarDateString, + dateFormat, + includeTimeZone, + })} +

    + ) +} + +DateText.propTypes = { + date: PropTypes.string, + includeTimeZone: PropTypes.bool, +} diff --git a/src/shared/date/date-text.test.js b/src/shared/date/date-text.test.js new file mode 100644 index 000000000..176e53fea --- /dev/null +++ b/src/shared/date/date-text.test.js @@ -0,0 +1,150 @@ +import { useConfig } from '@dhis2/app-runtime' +import React from 'react' +import { render } from '../../test-utils/index.js' +import { DateText } from './date-text.js' + +jest.mock('@dhis2/app-runtime', () => ({ + ...jest.requireActual('@dhis2/app-runtime'), + useConfig: jest.fn(() => ({ + systemInfo: { + serverTimeZoneId: 'Etc/UTC', + calendar: 'gregory', + dateFormat: 'yyyy-mm-dd', + }, + })), +})) + +describe('DateText', () => { + afterEach(() => { + jest.clearAllMocks() + }) + it.each([ + { + inputDate: '2024-10-14T19:10:57.836', + dateFormat: 'yyyy-mm-dd', + calendar: 'gregory', + serverTimeZone: 'Etc/UTC', + includeTimeZone: false, + output: '2024-10-14 19:10', + }, + { + inputDate: '2024-10-14T19:10:57.836', + dateFormat: 'yyyy-mm-dd', + calendar: 'gregory', + serverTimeZone: 'Etc/UTC', + includeTimeZone: true, + output: '2024-10-14 19:10 (UTC)', + }, + { + inputDate: '2024-10-14T19:10:57.836', + dateFormat: 'dd-mm-yyyy', + calendar: 'gregory', + serverTimeZone: 'Etc/UTC', + includeTimeZone: false, + output: '14-10-2024 19:10', + }, + { + inputDate: '2024-10-14T19:10:57.836', + dateFormat: 'dd-mm-yyyy', + calendar: 'gregory', + serverTimeZone: 'Etc/UTC', + includeTimeZone: true, + output: '14-10-2024 19:10 (UTC)', + }, + { + inputDate: '2024-10-14T19:10:57.836', + dateFormat: 'yyyy-mm-dd', + calendar: 'gregory', + serverTimeZone: 'Asia/Vientiane', + includeTimeZone: true, + output: '2024-10-14 12:10 (UTC)', + }, + { + inputDate: '2024-10-14T19:10:57.836', + dateFormat: 'yyyy-mm-dd', + calendar: 'gregory', + serverTimeZone: 'Atlantic/Cape_Verde', + includeTimeZone: true, + output: '2024-10-14 20:10 (UTC)', + }, + { + inputDate: '2024-10-14T19:10:57.836', + dateFormat: 'yyyy-mm-dd', + calendar: 'gregory', + serverTimeZone: 'Etc/UTC', + includeTimeZone: null, + output: '2024-10-14 19:10', + }, + { + inputDate: '2024-10-14T19:10:57.836', + dateFormat: 'yyyy-mm-dd', + calendar: 'ethiopian', + serverTimeZone: 'Etc/UTC', + includeTimeZone: false, + output: '2017-02-04 19:10', + }, + { + inputDate: '2024-10-14T19:10:57.836', + dateFormat: 'yyyy-mm-dd', + calendar: 'ethiopian', + serverTimeZone: 'Africa/Addis_Ababa', + includeTimeZone: false, + output: '2017-02-04 16:10', + }, + { + inputDate: '2024-10-14T19:10:57.836', + dateFormat: 'yyyy-mm-dd', + calendar: 'ethiopian', + serverTimeZone: 'Africa/Addis_Ababa', + includeTimeZone: true, + output: '2017-02-04 16:10 (UTC)', + }, + { + inputDate: '2024-10-14T19:10:57.836', + dateFormat: 'yyyy-mm-dd', + calendar: 'nepali', + serverTimeZone: 'Etc/UTC', + includeTimeZone: false, + output: '2081-06-28 19:10', + }, + { + inputDate: '2024-10-14T19:10:57.836', + dateFormat: 'yyyy-mm-dd', + calendar: 'nepali', + serverTimeZone: 'Asia/Kathmandu', + includeTimeZone: false, + output: '2081-06-28 13:25', + }, + { + inputDate: '2024-10-14T19:10:57.836', + dateFormat: 'dd-mm-yyyy', + calendar: 'nepali', + serverTimeZone: 'Asia/Kathmandu', + includeTimeZone: false, + output: '28-06-2081 13:25', + }, + ])( + 'should display %s given: format is %s, calendar is %s, server time zone is %s, and includeTimeZone is %s', + ({ + inputDate, + dateFormat, + calendar, + serverTimeZone, + includeTimeZone, + output, + }) => { + useConfig.mockReturnValueOnce({ + systemInfo: { + serverTimeZoneId: serverTimeZone, + calendar, + dateFormat, + }, + }) + const { getByText } = render( + , + { timezone: serverTimeZone } + ) + expect(getByText(output)).toBeInTheDocument() + } + ) +}) diff --git a/src/shared/date/date-utils.js b/src/shared/date/date-utils.js index b3737f172..7eecc8ff1 100644 --- a/src/shared/date/date-utils.js +++ b/src/shared/date/date-utils.js @@ -1,7 +1,11 @@ +import { + convertFromIso8601, + convertToIso8601, +} from '@dhis2/multi-calendar-dates' import moment from 'moment' import { getNowInCalendarString } from './get-now-in-calendar.js' -const GREGORY_CALENDARS = ['gregory', 'gregorian', 'iso8601', 'julian'] // calendars that can be parsed by JS Date +const GREGORY_CALENDARS = ['gregory', 'gregorian', 'iso8601'] // calendars that can be parsed by JS Date const DAY_MS = 24 * 60 * 60 * 1000 const DATE_ONLY_REGEX = new RegExp(/^\d{4}-\d{2}-\d{2}$/) @@ -14,6 +18,7 @@ export const padWithZeros = (startValue, minLength) => { return startValue } } + const formatDate = (date, withoutTimeStamp) => { const yearString = padWithZeros(date.getFullYear(), 4) const monthString = padWithZeros(date.getMonth() + 1, 2) // Jan = 0 @@ -28,6 +33,48 @@ const formatDate = (date, withoutTimeStamp) => { return `${yearString}-${monthString}-${dateString}T${hoursString}:${minuteString}:${secondsString}` } +export const convertFromIso8601ToString = (date, calendar) => { + // return without conversion if already a gregory date + if (GREGORY_CALENDARS.includes(calendar)) { + return date + } + + // separate the YYYY-MM-DD and time portions of the string + const inCalendarDateString = date.substring(0, 10) + const timeString = date.substring(11) + + const { year, eraYear, month, day } = convertFromIso8601( + inCalendarDateString, + calendar + ) + const ISOyear = calendar === 'ethiopian' ? eraYear : year + return `${padWithZeros(ISOyear, 4)}-${padWithZeros( + month, + 2 + )}-${padWithZeros(day, 2)}${timeString ? 'T' + timeString : ''}` +} + +export const convertToIso8601ToString = (date, calendar) => { + // return without conversion if already a gregory date + if (GREGORY_CALENDARS.includes(calendar)) { + return date + } + + // separate the YYYY-MM-DD and time portions of the string + const inCalendarDateString = date.substring(0, 10) + const timeString = date.substring(11) + + const { year, month, day } = convertToIso8601( + inCalendarDateString, + calendar + ) + + return `${padWithZeros(year, 4)}-${padWithZeros(month, 2)}-${padWithZeros( + day, + 2 + )}${timeString ? 'T' + timeString : ''}` +} + // returns string in either 'YYYY-MM-DD' or 'YYYY-MM-DDTHH:MM.SSS' format (depending on input) // if non-gregory calendar, returns null // time zone will not be affected by browser conversion so long as initial date is not expressly UTC @@ -36,100 +83,99 @@ export const addDaysToDateString = ({ days, calendar = 'gregory', }) => { - if (!GREGORY_CALENDARS.includes(calendar)) { - // TO DO: add support for non-gregory calendar - return null - } + // convert date to gregory if necessary + const startDateStringISO = convertToIso8601ToString( + startDateString, + calendar + ) // if the startDate does not have time stamp, then add it // adding T00:00 will prevent the date from being parsed in UTC time zone // (parsing as UTC relative to browser time zone can alter the date) - const withoutTimeStamp = DATE_ONLY_REGEX.test(startDateString) + const withoutTimeStamp = DATE_ONLY_REGEX.test(startDateStringISO) const adjustedStartDateString = withoutTimeStamp - ? startDateString + 'T00:00' - : startDateString + ? startDateStringISO + 'T00:00' + : startDateStringISO const startDate = new Date(adjustedStartDateString) const endDate = new Date(startDate.getTime() + days * DAY_MS) - // we return the YYYY-MM-DD format if that was what was originally passed + // we remove the YYYY-MM-DD format if that was what was originally passed + const formattedDate = formatDate(endDate, withoutTimeStamp) - return formatDate(endDate, withoutTimeStamp) + // reconvert from ISO if necessary + return convertFromIso8601ToString(formattedDate, calendar) } // returns relative time between two dates // if endDate is not provided, assumes end is now -// if non-gregory calendar, returns null export const getRelativeTime = ({ startDate, endDate, calendar, timezone }) => { if (!startDate) { return null } - if (!GREGORY_CALENDARS.includes(calendar)) { - // TO DO: add support for non-gregory calendar - return null - } - - const end = - endDate ?? getNowInCalendarString({ calendar, timezone, long: true }) - return moment(startDate).from(end) + // convert dates to ISO if needed + const nowISO = getNowInCalendarString({ + calendar: 'gregory', + timezone, + long: true, + }) + const startISO = convertToIso8601ToString(startDate, calendar) + const endISO = endDate + ? convertToIso8601ToString(endDate, calendar) + : nowISO + + return moment(startISO).from(endISO) } export const isDateALessThanDateB = ( - dateA, - dateB, - { inclusive = false, calendar = 'gregory' } = {} + { date: dateA, calendar: calendarA = 'gregory' } = {}, + { date: dateB, calendar: calendarB = 'gregory' } = {}, + { inclusive = false } = {} ) => { if (!dateA || !dateB) { return false } - // if the calendar is gregory, we can use JavaScript Date for comparison - if (GREGORY_CALENDARS.includes(calendar)) { - // if date is in format 'YYYY-MM-DD', when passed to JavaScript Date() it will give us 00:00 in UTC time (not client time) - // dates with time information are interpreted in client time - // we need the dates to be parsed in consistent time zone (i.e. client), so we add T00:00 to YYYY-MM-DD dates - const dateAString = DATE_ONLY_REGEX.test(dateA) - ? dateA + 'T00:00' - : dateA - const dateBString = DATE_ONLY_REGEX.test(dateB) - ? dateB + 'T00:00' - : dateB - - const dateADate = new Date(dateAString) - const dateBDate = new Date(dateBString) - - // if dates are invalid, return null - if (isNaN(dateADate)) { - console.error(`Invalid date: ${dateA}`) - return null - } - - if (isNaN(dateBDate)) { - console.error(`Invalid date: ${dateB}`) - return null - } - - if (inclusive) { - return dateADate <= dateBDate - } else { - return dateADate < dateBDate - } + // we first convert dates to ISO strings + const dateAISO = convertToIso8601ToString(dateA, calendarA) + const dateBISO = convertToIso8601ToString(dateB, calendarB) + + // if date is in format 'YYYY-MM-DD', when passed to JavaScript Date() it will give us 00:00 in UTC time (not client time) + // dates with time information are interpreted in client time + // we need the dates to be parsed in consistent time zone (i.e. client), so we add T00:00 to YYYY-MM-DD dates + const dateAString = DATE_ONLY_REGEX.test(dateAISO) + ? dateAISO + 'T00:00' + : dateAISO + const dateBString = DATE_ONLY_REGEX.test(dateBISO) + ? dateBISO + 'T00:00' + : dateBISO + + const dateADate = new Date(dateAString) + const dateBDate = new Date(dateBString) + + // if dates are invalid, return null + if (isNaN(dateADate)) { + console.error(`Invalid date: ${dateA}`, dateAString, dateAISO) + return null } - // if calendar is not gregory, we try string comparison + if (isNaN(dateBDate)) { + console.error(`Invalid date: ${dateB}`, dateBString, dateBISO) + return null + } if (inclusive) { - return dateA <= dateB + return dateADate <= dateBDate + } else { + return dateADate < dateBDate } - - return dateA < dateB } // testing (a < b) is equivalent to testing (b > a), so we reuse the other function export const isDateAGreaterThanDateB = ( dateA, dateB, - { inclusive = false, calendar = 'gregory' } = {} + { inclusive = false } = {} ) => { - return isDateALessThanDateB(dateB, dateA, { inclusive, calendar }) + return isDateALessThanDateB(dateB, dateA, { inclusive }) } diff --git a/src/shared/date/date-utils.test.js b/src/shared/date/date-utils.test.js index d65945d44..7b37bd3a5 100644 --- a/src/shared/date/date-utils.test.js +++ b/src/shared/date/date-utils.test.js @@ -11,49 +11,49 @@ describe('isDateALessThanDateB (gregory)', () => { }) it('works for dates without time information', () => { - const dateA = '2022-01-01' - const dateB = '2022-07-01' - const options = { calendar: 'gregory', inclusive: false } + const dateA = { date: '2022-01-01', calendar: 'gregory' } + const dateB = { date: '2022-07-01', calendar: 'gregory' } + const options = { inclusive: false } expect(isDateALessThanDateB(dateA, dateB, options)).toBe(true) }) it('works for dates with time stamp', () => { - const dateA = '2022-01-01T12:00:00' - const dateB = '2023-07-01T12:00:00' - const options = { calendar: 'gregory', inclusive: false } + const dateA = { date: '2022-01-01T12:00:00', calendar: 'gregory' } + const dateB = { date: '2023-07-01T12:00:00', calendar: 'gregory' } + const options = { inclusive: false } expect(isDateALessThanDateB(dateA, dateB, options)).toBe(true) }) it('works for dates mixed with time stamp/without time stamp', () => { - const dateA = '2022-01-01' - const dateB = '2022-07-01T00:00:00' - const options = { calendar: 'gregory', inclusive: false } + const dateA = { date: '2022-01-01', calendar: 'gregory' } + const dateB = { date: '2022-07-01T00:00:00', calendar: 'gregory' } + const options = { inclusive: false } expect(isDateALessThanDateB(dateA, dateB, options)).toBe(true) }) it('returns null for invalid dates', () => { - const dateA = '2022-01-01' - const dateB = '2022-01-01T00:00.00000' - const options = { calendar: 'gregory', inclusive: false } + const dateA = { date: '2022-01-01', calendar: 'gregory' } + const dateB = { date: '2022-01-01T00:00.00000', calendar: 'gregory' } + const options = { inclusive: false } expect(isDateALessThanDateB(dateA, dateB, options)).toBe(null) }) it('defaults to assume gregory calendar, and returns null for invalid dates', () => { - const dateA = '2022-01-01' - const dateB = '2023-13-03' + const dateA = { date: '2022-01-01' } + const dateB = { date: '2023-13-03' } expect(isDateALessThanDateB(dateA, dateB)).toBe(null) }) it('defaults to inclusive: false by default', () => { - const dateA = '2022-01-01' - const dateB = '2022-01-01T00:00:00' + const dateA = { date: '2022-01-01', calendar: 'gregory' } + const dateB = { date: '2022-01-01T00:00:00', calendar: 'gregory' } expect(isDateALessThanDateB(dateA, dateB)).toBe(false) }) it('uses inclusive comparison if specified', () => { - const dateA = '2022-01-01' - const dateB = '2022-01-01T00:00:00' - const options = { calendar: 'gregory', inclusive: true } + const dateA = { date: '2022-01-01', calendar: 'gregory' } + const dateB = { date: '2022-01-01T00:00:00', calendar: 'gregory' } + const options = { inclusive: true } expect(isDateALessThanDateB(dateA, dateB, options)).toBe(true) }) }) @@ -64,37 +64,37 @@ describe('isDateALessThanDateB (nepali)', () => { }) it('works for dates without time information', () => { - const dateA = '2078-04-31' - const dateB = '2078-05-31' - const options = { calendar: 'nepali', inclusive: false } + const dateA = { date: '2078-04-31', calendar: 'nepali' } + const dateB = { date: '2078-05-31', calendar: 'nepali' } + const options = { inclusive: false } expect(isDateALessThanDateB(dateA, dateB, options)).toBe(true) }) it('works for dates with time stamp', () => { - const dateA = '2078-04-31T00:00:00' - const dateB = '2078-05-31T:00:00:00' - const options = { calendar: 'nepali', inclusive: false } + const dateA = { date: '2078-04-31T00:00:00', calendar: 'nepali' } + const dateB = { date: '2078-05-31T00:00:00', calendar: 'nepali' } + const options = { inclusive: false } expect(isDateALessThanDateB(dateA, dateB, options)).toBe(true) }) it('works for dates mixed with time stamp/without time stamp', () => { - const dateA = '2078-04-31' - const dateB = '2078-05-31T:00:00:00' - const options = { calendar: 'nepali', inclusive: false } + const dateA = { date: '2078-04-31', calendar: 'nepali' } + const dateB = { date: '2078-05-31T00:00:00', calendar: 'nepali' } + const options = { inclusive: false } expect(isDateALessThanDateB(dateA, dateB, options)).toBe(true) }) // this test will fail while using string comparison it.skip('returns null for invalid dates', () => { - const dateA = '2078-04-40' - const dateB = '2078-05-31' - const options = { calendar: 'nepali', inclusive: false } + const dateA = { date: '2078-04-40', calendar: 'nepali' } + const dateB = { date: '2078-05-31', calendar: 'nepali' } + const options = { inclusive: false } expect(isDateALessThanDateB(dateA, dateB, options)).toBe(null) }) it('uses inclusive comparison if specified', () => { - const dateA = '2022-01-01' - const dateB = '2022-01-01T00:00:00' + const dateA = { date: '2022-01-01', calendar: 'nepali' } + const dateB = { date: '2022-01-01T00:00:00', calendar: 'nepali' } const options = { calendar: 'nepali', inclusive: true } expect(isDateALessThanDateB(dateA, dateB, options)).toBe(true) }) @@ -106,106 +106,148 @@ describe('isDateALessThanDateB (ethiopian)', () => { }) it('works for dates without time information', () => { - const dateA = '2016-02-30' - const dateB = '2016-04-30' - const options = { calendar: 'ethiopian', inclusive: false } + const dateA = { date: '2016-02-30', calendar: 'ethiopian' } + const dateB = { date: '2016-04-30', calendar: 'ethiopian' } + const options = { inclusive: false } expect(isDateALessThanDateB(dateA, dateB, options)).toBe(true) }) it('works for dates with time stamp', () => { - const dateA = '2016-02-30T00:00:00' - const dateB = '2016-04-30T00:00:00' - const options = { calendar: 'ethiopian', inclusive: false } + const dateA = { date: '2016-02-30T00:00:00', calendar: 'ethiopian' } + const dateB = { date: '2016-04-30T00:00:00', calendar: 'ethiopian' } + const options = { inclusive: false } expect(isDateALessThanDateB(dateA, dateB, options)).toBe(true) }) it('works for dates mixed with time stamp/without time stamp', () => { - const dateA = '2016-02-30' - const dateB = '2016-04-30T00:00:00' - const options = { calendar: 'ethiopian', inclusive: false } + const dateA = { date: '2016-02-30', calendar: 'ethiopian' } + const dateB = { date: '2016-04-30T00:00:00', calendar: 'ethiopian' } + const options = { inclusive: false } expect(isDateALessThanDateB(dateA, dateB, options)).toBe(true) }) // this test will fail while using string comparison it.skip('returns null for invalid dates', () => { - const dateA = '2016-02-31' - const dateB = '2016-04-30' - const options = { calendar: 'ethiopian', inclusive: false } + const dateA = { date: '2016-02-31', calendar: 'ethiopian' } + const dateB = { date: '2016-04-30', calendar: 'ethiopian' } + const options = { inclusive: false } expect(isDateALessThanDateB(dateA, dateB, options)).toBe(null) }) it('uses inclusive comparison if specified', () => { - const dateA = '2016-02-30' - const dateB = '2016-02-30T00:00:00' - const options = { calendar: 'ethiopian', inclusive: true } + const dateA = { date: '2016-02-30', calendar: 'ethiopian' } + const dateB = { date: '2016-02-30T00:00:00', calendar: 'ethiopian' } + const options = { inclusive: true } expect(isDateALessThanDateB(dateA, dateB, options)).toBe(true) }) }) -describe('isDateAGreaterThanDateB (gregory)', () => { +describe('isDateALessThanDateB (mixed calendars)', () => { beforeEach(() => { jest.spyOn(console, 'error').mockImplementation(jest.fn()) }) + // 2023-09-10 ISO + // 2015-13-05 Ethiopian + // 2080-05-24 Nepali + it('works for dates without time information', () => { - const dateA = '2022-07-01' - const dateB = '2022-01-01' - const options = { calendar: 'gregory', inclusive: false } + const dateA = { date: '2023-09-10', calendar: 'gregory' } + const dateB = { date: '2015-13-05', calendar: 'ethiopian' } + const options = { inclusive: false } + expect(isDateALessThanDateB(dateA, dateB, options)).toBe(false) + }) + + it('works for dates without time information (inclusive)', () => { + const dateA = { date: '2023-09-10', calendar: 'gregory' } + const dateB = { date: '2015-13-05', calendar: 'ethiopian' } + const options = { inclusive: true } + expect(isDateALessThanDateB(dateA, dateB, options)).toBe(true) + }) + + it('defaults to gregorian calendar if not passed', () => { + const dateA = { date: '2016-02-30', calendar: 'ethiopian' } + const dateB = { date: '2016-02-30' } + const options = { inclusive: false } + expect(isDateALessThanDateB(dateA, dateB, options)).toBe(false) + }) + + it('works with mix of time/timeless strings', () => { + const dateA = { date: '2015-13-05', calendar: 'ethiopian' } + const dateB = { date: '2023-09-10T00:00', calendar: 'gregory' } + const options = { inclusive: true } + expect(isDateALessThanDateB(dateA, dateB, options)).toBe(true) + }) + + it('works with mix of calendars (dateA is less)', () => { + const dateA = { date: '2015-13-05T00:00', calendar: 'ethiopian' } + const dateB = { date: '2080-05-25T00:00', calendar: 'nepali' } + const options = { inclusive: false } + expect(isDateALessThanDateB(dateA, dateB, options)).toBe(true) + }) + + it('works with mix of calendars (dateA is greater)', () => { + const dateA = { date: '2015-13-05T00:00', calendar: 'ethiopian' } + const dateB = { date: '2080-05-23T00:00', calendar: 'nepali' } + const options = { inclusive: false } + expect(isDateALessThanDateB(dateA, dateB, options)).toBe(false) + }) +}) + +describe('isDateAGreaterThanDateB', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(jest.fn()) + }) + + it('works for dates without time information', () => { + const dateA = { date: '2022-07-01', calendar: 'gregory' } + const dateB = { date: '2022-01-01', calendar: 'gregory' } + const options = { inclusive: false } expect(isDateAGreaterThanDateB(dateA, dateB, options)).toBe(true) }) it('works for dates with time stamp', () => { - const dateA = '2023-07-01T12:00:00' - const dateB = '2022-01-01T12:00:00' - const options = { calendar: 'gregory', inclusive: false } + const dateA = { date: '2023-07-01T12:00:00', calendar: 'gregory' } + const dateB = { date: '2022-01-01T12:00:00', calendar: 'gregory' } + const options = { inclusive: false } expect(isDateAGreaterThanDateB(dateA, dateB, options)).toBe(true) }) it('works for dates mixed with time stamp/without time stamp', () => { - const dateA = '2022-07-01T00:00:00' - const dateB = '2022-01-01' - const options = { calendar: 'gregory', inclusive: false } + const dateA = { date: '2022-07-01T00:00:00', calendar: 'gregory' } + const dateB = { date: '2022-01-01', calendar: 'gregory' } + const options = { inclusive: false } expect(isDateAGreaterThanDateB(dateA, dateB, options)).toBe(true) }) it('returns null for invalid dates', () => { - const dateA = '2022-01-01T00:00.00000' - const dateB = '2022-01-01' - const options = { calendar: 'gregory', inclusive: false } + const dateA = { date: '2022-01-01T00:00.00000', calendar: 'gregory' } + const dateB = { date: '2022-01-01', calendar: 'gregory' } + const options = { inclusive: false } expect(isDateAGreaterThanDateB(dateA, dateB, options)).toBe(null) }) it('defaults to assume gregory calendar, and returns null for invalid dates', () => { - const dateA = '2023-13-03' - const dateB = '2022-01-01' + const dateA = { date: '2023-13-03' } + const dateB = { date: '2022-01-01' } expect(isDateAGreaterThanDateB(dateA, dateB)).toBe(null) }) it('defaults to inclusive: false by default', () => { - const dateA = '2022-01-01T00:00:00' - const dateB = '2022-01-01' + const dateA = { date: '2022-01-01T00:00:00', calendar: 'gregory' } + const dateB = { date: '2022-01-01', calendar: 'gregory' } expect(isDateAGreaterThanDateB(dateA, dateB)).toBe(false) }) it('uses inclusive comparison if specified', () => { - const dateA = '2022-01-01T00:00:00' - const dateB = '2022-01-01' - const options = { calendar: 'gregory', inclusive: true } + const dateA = { date: '2022-01-01T00:00:00', calendar: 'gregory' } + const dateB = { date: '2022-01-01', calendar: 'gregory' } + const options = { inclusive: true } expect(isDateAGreaterThanDateB(dateA, dateB, options)).toBe(true) }) }) describe('addDaysToDateString', () => { - it('returns null if calendar is not Gregory', () => { - expect( - addDaysToDateString({ - startDateString: '2016-02-30', - days: 5, - calendar: 'ethiopian', - }) - ).toBeNull() - }) - it('adds appropriate number of days', () => { const startDateString = '2023-03-15' const days = 5 @@ -237,6 +279,30 @@ describe('addDaysToDateString', () => { const result = addDaysToDateString({ startDateString, days, calendar }) expect(result).toBe('2023-03-20T12:00:00') }) + + it('works with ethiopian calendar', () => { + const startDateString = '2016-02-30' + const days = 5 + const calendar = 'ethiopian' + const result = addDaysToDateString({ + startDateString, + days, + calendar, + }) + expect(result).toBe('2016-03-05') + }) + + it('works with nepali calendar', () => { + const startDateString = '2080-02-30' + const days = 5 + const calendar = 'nepali' + const result = addDaysToDateString({ + startDateString, + days, + calendar, + }) + expect(result).toBe('2080-03-03') + }) }) describe('getRelativeTime', () => { @@ -249,11 +315,20 @@ describe('getRelativeTime', () => { jest.useRealTimers() }) - it('returns null if calendar is not gregory type', () => { + it('works with ethiopian calendar', () => { + // 2024-06-15 Ethiopian = 2032-02-23 (i.e. in 8 years) const startDate = '2024-06-15T13:00:00' const calendar = 'ethiopian' const result = getRelativeTime({ startDate, calendar }) - expect(result).toBe(null) + expect(result).toBe('in 8 years') + }) + + it('works with nepali calendar', () => { + // 2024-06-15 Nepali = 1967-10-01 (i.e. 57 years ago) + const startDate = '2024-06-15T13:00:00' + const calendar = 'nepali' + const result = getRelativeTime({ startDate, calendar }) + expect(result).toBe('57 years ago') }) it('returns relative time (from now) with gregory dates if no end date specified', () => { diff --git a/src/shared/date/index.js b/src/shared/date/index.js index 6f2246b48..ae33c594d 100644 --- a/src/shared/date/index.js +++ b/src/shared/date/index.js @@ -2,7 +2,10 @@ export { getNowInCalendarString } from './get-now-in-calendar.js' export { startingYears } from './starting-years.js' export { addDaysToDateString, + convertToIso8601ToString, + convertFromIso8601ToString, getRelativeTime, isDateAGreaterThanDateB, isDateALessThanDateB, } from './date-utils.js' +export { DateText } from './date-text.js' diff --git a/src/shared/locked-status/use-check-lock-status.js b/src/shared/locked-status/use-check-lock-status.js index 035f23c83..d9ca9be06 100644 --- a/src/shared/locked-status/use-check-lock-status.js +++ b/src/shared/locked-status/use-check-lock-status.js @@ -5,6 +5,7 @@ import { addDaysToDateString, isDateAGreaterThanDateB, isDateALessThanDateB, + convertToIso8601ToString, } from '../date/index.js' import { useMetadata, selectors } from '../metadata/index.js' import { usePeriod } from '../period/index.js' @@ -42,7 +43,7 @@ const getFrontendLockStatus = ({ return } - let serverLockDateString = null + let serverLockDateStringISO = null // this will be a date string corrected for client/server time zone differences const currentDateString = getNowInCalendarString({ @@ -73,18 +74,24 @@ const getFrontendLockStatus = ({ // They are ISO dates without a timezone, so should be parsed // as "server dates" - // date comparison + // date comparison (openingDate/closingDate dates are iso, currentDateString is system calendar) if ( (openingDate && - isDateALessThanDateB(currentDateString, openingDate, { - calendar, - inclusive: false, - })) || + isDateALessThanDateB( + { date: currentDateString, calendar }, + { date: openingDate, calendar: 'gregory' }, + { + inclusive: false, + } + )) || (closingDate && - isDateAGreaterThanDateB(currentDateString, closingDate, { - calendar, - inclusive: false, - })) + isDateAGreaterThanDateB( + { date: currentDateString, calendar }, + { date: closingDate, calendar: 'gregory' }, + { + inclusive: false, + } + )) ) { return { state: LockedStates.LOCKED_DATA_INPUT_PERIOD, @@ -93,7 +100,8 @@ const getFrontendLockStatus = ({ } // If we're here, the form isn't (yet) locked by the data input period. - serverLockDateString = closingDate + // this date is ISO + serverLockDateStringISO = closingDate } if (expiryDays > 0 && !userCanEditExpired) { @@ -103,30 +111,34 @@ const getFrontendLockStatus = ({ // Add one day more because selectedPeriod.endDate is the START // of the period's last day (00:00), and we want the end of that day // (confirmed with backend behavior). - // this will currently be null if calendar is not gregory const expiryDateString = addDaysToDateString({ startDateString: selectedPeriod.endDate, days: expiryDays + 1, calendar, }) - // date comparison + // date comparison (both in system calendar) if ( currentDateString && expiryDateString && - isDateALessThanDateB(currentDateString, expiryDateString, { - calendar, - inclusive: false, - }) + isDateALessThanDateB( + { date: currentDateString, calendar }, + { date: expiryDateString, calendar }, + { + inclusive: false, + } + ) ) { // Take the sooner of the two possible lock dates - serverLockDateString = isDateALessThanDateB( - serverLockDateString, - expiryDateString, - { calendar, inclusive: false } + // date comparison (serverLockDateStringISO: ISO, expiryDateString: system calendar) + // if serverLockDateStringISO is null, the logic returns null and hence uses expiryDays + serverLockDateStringISO = isDateALessThanDateB( + { date: serverLockDateStringISO, calendar: 'gregory' }, + { date: expiryDateString, calendar }, + { inclusive: false } ) - ? serverLockDateString - : expiryDateString + ? serverLockDateStringISO + : convertToIso8601ToString(expiryDateString, calendar) // expiryDateString is in system calendar an needs to be converted to ISO // ! NB: // Until lock exception checks are done, this value is still shown, // even if the form won't actually lock due to a lock exception. @@ -138,7 +150,7 @@ const getFrontendLockStatus = ({ // TODO: implement this full check on the front-end (TECH-1428) } - return { state: LockedStates.OPEN, lockDate: serverLockDateString } + return { state: LockedStates.OPEN, lockDate: serverLockDateStringISO } } const isOrgUnitLocked = ({ @@ -162,14 +174,18 @@ const isOrgUnitLocked = ({ const periodStartDate = selectedPeriod.startDate const periodEndDate = selectedPeriod.endDate - // date comparison + // date comparison (orgUnitDates: ISO, periodDates: system calendar) // if orgUnitOpeningDate exists, it must be earlier than the periodStartDate if (orgUnitOpeningDateString) { if ( - !isDateALessThanDateB(orgUnitOpeningDateString, periodStartDate, { - calendar, - inclusive: true, - }) + !isDateALessThanDateB( + { date: orgUnitOpeningDateString, calendar: 'gregory' }, + { date: periodStartDate, calendar }, + { + calendar, + inclusive: true, + } + ) ) { return true } @@ -178,10 +194,14 @@ const isOrgUnitLocked = ({ // if orgUnitClosedDate exists, it must be after the periodEndDate if (orgUnitClosedDateString) { if ( - !isDateAGreaterThanDateB(orgUnitClosedDateString, periodEndDate, { - calendar, - inclusive: true, - }) + !isDateAGreaterThanDateB( + { date: orgUnitClosedDateString, calendar: 'gregory' }, + { date: periodEndDate, calendar }, + { + calendar, + inclusive: true, + } + ) ) { return true } diff --git a/src/shared/locked-status/use-check-lock-status.test.js b/src/shared/locked-status/use-check-lock-status.test.js index 74067733c..c35df6431 100644 --- a/src/shared/locked-status/use-check-lock-status.test.js +++ b/src/shared/locked-status/use-check-lock-status.test.js @@ -143,9 +143,10 @@ describe('useCheckLockStatus', () => { useConfig.mockImplementationOnce(() => ({ systemInfo: { calendar: 'ethiopian', serverTimeZoneId: 'Etc/UTC' }, })) + // org unit closed date from back end is ISO (2024-09-10 ISO = 2016-13-05 Ethiopian) useOrgUnit.mockImplementationOnce(() => ({ data: { - closedDate: '2016-13-03', + closedDate: '2024-09-10', }, })) usePeriod.mockImplementationOnce(() => ({ diff --git a/src/shared/metadata/selectors.js b/src/shared/metadata/selectors.js index c1b65b9f2..bd56421e0 100644 --- a/src/shared/metadata/selectors.js +++ b/src/shared/metadata/selectors.js @@ -451,10 +451,15 @@ const isOptionWithinPeriod = ({ if (categoryOption.startDate) { const categoryOptionStartDate = categoryOption.startDate if ( - isDateALessThanDateB(periodStartDate, categoryOptionStartDate, { - calendar, - inclusive: false, - }) + // date comparison (periodStartDate: system calendar, categoryOptionStartDate: ISO) + isDateALessThanDateB( + { date: periodStartDate, calendar }, + { date: categoryOptionStartDate, calendar: 'gregory' }, + { + calendar, + inclusive: false, + } + ) ) { // option start date is after period start date return false @@ -463,11 +468,16 @@ const isOptionWithinPeriod = ({ if (categoryOption.endDate) { const categoryOptionEndDate = categoryOption.endDate + // date comparison (periodEndDate: system calendar, categoryOptionEndDate: ISO) if ( - isDateAGreaterThanDateB(periodEndDate, categoryOptionEndDate, { - calendar, - inclusive: false, - }) + isDateAGreaterThanDateB( + { date: periodEndDate, calendar }, + { date: categoryOptionEndDate, calendar: 'gregory' }, + { + calendar, + inclusive: false, + } + ) ) { // option end date is before period end date return false diff --git a/src/test-utils/render.js b/src/test-utils/render.js index 907070b67..ab809904e 100644 --- a/src/test-utils/render.js +++ b/src/test-utils/render.js @@ -34,11 +34,16 @@ export function TestWrapper({ export function Wrapper({ dataForCustomProvider, queryClientOptions, + timezone, children, ...restOptions }) { return ( - + ( {children} diff --git a/yarn.lock b/yarn.lock index fe61768e3..425976b7a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2619,9 +2619,9 @@ moment "^2.24.0" "@dhis2/multi-calendar-dates@1.0.2", "@dhis2/multi-calendar-dates@^1.3.1": - version "1.3.1" - resolved "https://registry.yarnpkg.com/@dhis2/multi-calendar-dates/-/multi-calendar-dates-1.3.1.tgz#3bfa14fecbb4a77cbbebc759ddcab012301c1105" - integrity sha512-xAP8vzZF0eC7TwgRoa5qLmBLevAh+7+tqzlOQ6D9Iss3oMtWpFwrhlHrl20k3oXe/WYoTCx2D62PGKdon+U7Ew== + version "1.3.2" + resolved "https://registry.yarnpkg.com/@dhis2/multi-calendar-dates/-/multi-calendar-dates-1.3.2.tgz#34e5896f7fdfb761a2ee6035d848cba00446cde5" + integrity sha512-H37EptumkqZeHUpbR4wl3y2NZjeipxUNUI2VaHX28z2fbVD7O9H+k1InSskeP5nNWzTLMdPAM4lt/zQP8oRbrg== dependencies: "@dhis2/d2-i18n" "^1.1.3" "@js-temporal/polyfill" "0.4.3" @@ -5639,11 +5639,16 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -classnames@2.3.2, classnames@^2.2.6, classnames@^2.3.1, classnames@^2.3.2: +classnames@2.3.2, classnames@^2.2.6, classnames@^2.3.1: version "2.3.2" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924" integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw== +classnames@^2.3.2: + version "2.5.1" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b" + integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow== + clean-css@^5.2.2: version "5.3.2" resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-5.3.2.tgz#70ecc7d4d4114921f5d298349ff86a31a9975224" @@ -11285,7 +11290,12 @@ module-alias@^2.2.2: resolved "https://registry.yarnpkg.com/module-alias/-/module-alias-2.2.2.tgz#151cdcecc24e25739ff0aa6e51e1c5716974c0e0" integrity sha512-A/78XjoX2EmNvppVWEhM2oGk3x4lLxnkEA4jTbaK97QKSDjkIoOsKQlfylt/d3kKKi596Qy3NP5XrXJ6fZIC9Q== -moment@^2.24.0, moment@^2.29.1: +moment@^2.24.0: + version "2.30.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" + integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== + +moment@^2.29.1: version "2.29.4" resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== @@ -14990,11 +15000,16 @@ tslib@^1.8.1, tslib@^1.9.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.1: +tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0: version "2.5.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf" integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg== +tslib@^2.3.1: + version "2.7.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01" + integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"