diff --git a/cypress/support/generateTestMatrix.js b/cypress/support/generateTestMatrix.js index 6f5049f3e..dd25570eb 100644 --- a/cypress/support/generateTestMatrix.js +++ b/cypress/support/generateTestMatrix.js @@ -30,6 +30,4 @@ const createGroups = (files, numberOfGroups = 6) => { const cypressSpecsPath = './cypress/e2e' const specs = getAllFiles(cypressSpecsPath) -const groupedSpecs = createGroups(specs) - -console.log(JSON.stringify(groupedSpecs)) +createGroups(specs) diff --git a/i18n/en.pot b/i18n/en.pot index ec19704ca..3c65b3573 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-01-15T14:35:08.966Z\n" -"PO-Revision-Date: 2024-01-15T14:35:08.966Z\n" +"POT-Creation-Date: 2024-10-16T08:10:10.883Z\n" +"PO-Revision-Date: 2024-10-16T08:10:10.883Z\n" msgid "Not authorized" msgstr "Not authorized" @@ -54,9 +54,6 @@ msgstr "1 value failed to save" msgid "{{numberOfErrors}} values failed to save" msgstr "{{numberOfErrors}} values failed to save" -msgid "This form closes and will be locked at {{-dateTime}}" -msgstr "This form closes and will be locked at {{-dateTime}}" - msgid "Closes {{-relativeTime}}" msgstr "Closes {{-relativeTime}}" @@ -342,6 +339,18 @@ msgstr "Not available offline" msgid "No audit log for this data item." msgstr "No audit log for this data item." +msgid "Date" +msgstr "Date" + +msgid "User" +msgstr "User" + +msgid "Change" +msgstr "Change" + +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" @@ -586,6 +595,9 @@ msgstr "" "Something went wrong while setting the form's completion to " "\"{{completed}}\": {{errorMessage}}" +msgid "Invalid date ({{date}})" +msgstr "Invalid date ({{date}})" + msgid "{{title}} (disabled)" msgstr "{{title}} (disabled)" diff --git a/package.json b/package.json index e56c3c3d3..de29044a4 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@dhis2/cli-style": "10.5.1", "@dhis2/cypress-commands": "^10.0.1", "@dhis2/cypress-plugins": "^10.0.1", + "@dhis2/d2-i18n": "1.1.3", "@testing-library/jest-dom": "5.16.5", "@testing-library/react": "12.1.5", "@testing-library/react-hooks": "7.0.2", @@ -48,7 +49,7 @@ }, "dependencies": { "@dhis2/app-runtime": "^3.10.2", - "@dhis2/multi-calendar-dates": "^1.1.0", + "@dhis2/multi-calendar-dates": "^1.3.1", "@dhis2/ui": "^9.2.0", "@dhis2/ui-forms": "7.16.3", "@tanstack/react-query": "4.24.10", @@ -79,8 +80,9 @@ "node": ">=14.0.0" }, "resolutions": { - "@dhis2/multi-calendar-dates": "^1.1.0", + "@dhis2/multi-calendar-dates": "^1.3.1", "@dhis2/ui": "^9.2.0", - "@dhis2/app-runtime": "^3.10.2" + "@dhis2/app-runtime": "^3.10.2", + "@dhis2/d2-i18n": "1.1.3" } } diff --git a/src/bottom-bar/form-expiry-info.js b/src/bottom-bar/form-expiry-info.js index 572a3ff8c..cdacd55c9 100644 --- a/src/bottom-bar/form-expiry-info.js +++ b/src/bottom-bar/form-expiry-info.js @@ -1,9 +1,9 @@ +import { useConfig } from '@dhis2/app-runtime' import { IconInfo16, colors, Tooltip } from '@dhis2/ui' import cx from 'classnames' -import moment from 'moment' import React from 'react' import i18n from '../locales/index.js' -import { useLockedContext } from '../shared/index.js' +import { useLockedContext, getRelativeTime, DateText } from '../shared/index.js' import styles from './main-tool-bar.module.css' export default function FormExpiryInfo() { @@ -12,14 +12,22 @@ export default function FormExpiryInfo() { lockStatus: { lockDate }, } = useLockedContext() + const { systemInfo = {} } = useConfig() + const { serverTimeZoneId: timezone = 'Etc/UTC' } = systemInfo + // the lock date is returned in ISO calendar + const relativeTime = getRelativeTime({ + startDate: lockDate, + calendar: 'gregory', + timezone, + }) + return ( <> {!locked && lockDate && ( + } > {i18n.t('Closes {{-relativeTime}}', { - relativeTime: moment(lockDate).fromNow(), + relativeTime, })} diff --git a/src/bottom-bar/form-expiry-info.test.js b/src/bottom-bar/form-expiry-info.test.js new file mode 100644 index 000000000..11e047661 --- /dev/null +++ b/src/bottom-bar/form-expiry-info.test.js @@ -0,0 +1,75 @@ +import { useConfig } from '@dhis2/app-runtime' +import { render as testingLibraryRender } from '@testing-library/react' +import React from 'react' +import { useLockedContext } from '../shared/locked-status/use-locked-context.js' +import { render } from '../test-utils/render.js' +import FormExpiryInfo from './form-expiry-info.js' + +jest.mock('@dhis2/app-runtime', () => ({ + ...jest.requireActual('@dhis2/app-runtime'), + useConfig: jest.fn(() => ({ + systemInfo: { serverTimeZoneId: 'Etc/UTC', calendar: 'gregory' }, + })), +})) + +jest.mock('../shared/locked-status/use-locked-context.js', () => ({ + ...jest.requireActual('../shared/locked-status/use-locked-context.js'), + useLockedContext: jest.fn(() => ({ + locked: false, + lockStatus: { lockDate: '2024-03-15T14:15:00' }, + })), +})) + +describe('FormExpiryInfo', () => { + beforeEach(() => { + jest.useFakeTimers('modern') + jest.setSystemTime(new Date('2024-03-15T12:15:00')) + }) + + afterEach(() => { + jest.useRealTimers() + jest.clearAllMocks() + }) + + it('shows nothing if locked', () => { + useLockedContext.mockImplementationOnce(() => ({ + locked: true, + lockStatus: { lockDate: '2024-03-15T14:15:00' }, + })) + // render without the wrapper as we just want to test this element is empty and wrapper introduces some extra divs + const { container } = testingLibraryRender() + + expect(container).toBeEmptyDOMElement() + }) + + it('shows relative time for lockedDate, if not locked, there is a lockDate, and calendar:gregory', () => { + const { getByText } = render() + + expect(getByText('Closes in 2 hours')).toBeInTheDocument() + }) + + it('shows relative time for lockedDate, if not locked, there is a lockDate, and calendar not gregory', () => { + useConfig.mockImplementation(() => ({ + systemInfo: { + calendar: 'ethiopian', + }, + })) + const { getByText } = render() + + expect(getByText('Closes in 2 hours')).toBeInTheDocument() + }) + + it('corrects relative time for time zone differences', () => { + useConfig.mockImplementation(() => ({ + systemInfo: { + serverTimeZoneId: 'America/Port-au-Prince', + calendar: 'gregory', + }, + })) + const { getByText } = render() + + // current browser time: 2024-03-15T12:15:00 UTC + // which is 2024-03-15T08:15:00 Port-au-Prince (GMT-4 due to DST) + expect(getByText('Closes in 6 hours')).toBeInTheDocument() + }) +}) diff --git a/src/context-selection/attribute-option-combo-selector-bar-item/attribute-option-combo-selector-bar-item.js b/src/context-selection/attribute-option-combo-selector-bar-item/attribute-option-combo-selector-bar-item.js index beda48833..0dcc05742 100644 --- a/src/context-selection/attribute-option-combo-selector-bar-item/attribute-option-combo-selector-bar-item.js +++ b/src/context-selection/attribute-option-combo-selector-bar-item/attribute-option-combo-selector-bar-item.js @@ -1,11 +1,10 @@ -import { useAlert } from '@dhis2/app-runtime' +import { useAlert, useConfig } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' import { SelectorBarItem } from '@dhis2/ui' import PropTypes from 'prop-types' import React, { useEffect, useState } from 'react' import { selectors, - useClientServerDateUtils, useDataSetId, useMetadata, useOrgUnitId, @@ -57,14 +56,16 @@ export default function AttributeOptionComboSelectorBarItem({ ) const [attributeOptionComboSelection, setAttributeOptionComboSelection] = useAttributeOptionComboSelection() - const { fromClientDate } = useClientServerDateUtils() + const { systemInfo = {} } = useConfig() + const { calendar = 'gregory' } = systemInfo + const relevantCategoriesWithOptions = selectors.getCategoriesWithOptionsWithinPeriodWithOrgUnit( metadata, dataSetId, periodId, orgUnitId, - fromClientDate + calendar ) const [open, setOpen] = useState(false) 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 3d1f9063f..644f1e1a7 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 @@ -1,5 +1,6 @@ -import { useAlert } from '@dhis2/app-runtime' +import { useAlert, useConfig } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' +import { getNowInCalendar } from '@dhis2/multi-calendar-dates' import { SelectorBarItem } from '@dhis2/ui' import React, { useEffect, useState } from 'react' import { @@ -8,10 +9,9 @@ import { usePeriod, useDataSetId, usePeriodId, - formatJsDateToDateString, periodTypesMapping, - useClientServerDate, yearlyFixedPeriodTypes, + isDateAGreaterThanDateB, } from '../../shared/index.js' import DisabledTooltip from './disabled-tooltip.js' import PeriodMenu from './period-menu.js' @@ -22,15 +22,41 @@ import YearNavigator from './year-navigator.js' export const PERIOD = 'PERIOD' +const getYear = (date) => { + // return null if date is undefined (for example) + if (typeof date !== 'string') { + return null + } + const [year] = date.split('-') + const yearNumber = Number(year) + return isNaN(yearNumber) ? null : yearNumber +} + const getMaxYear = (dateLimit) => { - // periods run up to, but not including dateLimit, so decrement by 1 ms in case limit is 1 January - return new Date(dateLimit - 1).getUTCFullYear() + // periods run up to, but not including dateLimit, so if limit is 1 January, max year is previous year + // otherwise, max year is the year from the date limit + const dateLimitYear = getYear(dateLimit) + + try { + const [year, month, day] = dateLimit.split('-') + if (Number(month) === 1 && Number(day) === 1) { + return Number(year) - 1 + } + return dateLimitYear + } catch (e) { + console.error(e) + return dateLimitYear + } } export const PeriodSelectorBarItem = () => { - const currentDate = useClientServerDate() - const currentDay = formatJsDateToDateString(currentDate.serverDate) - const currentFullYear = parseInt(currentDay.split('-')[0]) + const { systemInfo = {} } = useConfig() + const { calendar = 'gregory' } = systemInfo + const { eraYear: nowEraYear, year: nowYear } = getNowInCalendar(calendar) + const currentFullYear = ['ethiopian', 'ethiopic'].includes(calendar) + ? nowEraYear + : nowYear + const [periodOpen, setPeriodOpen] = useState(false) const [periodId, setPeriodId] = usePeriodId() const selectedPeriod = usePeriod(periodId) @@ -43,52 +69,76 @@ export const PeriodSelectorBarItem = () => { warning: true, }) - const [year, setYear] = useState(selectedPeriod?.year || currentFullYear) + const [year, setYear] = useState( + getYear(selectedPeriod?.startDate) || currentFullYear + ) const dateLimit = useDateLimit() const [maxYear, setMaxYear] = useState(() => getMaxYear(dateLimit)) + const periods = usePeriods({ periodType: dataSetPeriodType, openFuturePeriods, dateLimit, - year, + year: yearlyFixedPeriodTypes.includes(dataSetPeriodType) + ? currentFullYear + : year, }) useEffect(() => { - if (selectedPeriod?.year) { - setYear(selectedPeriod.year) + const selectedPeriodYear = getYear(selectedPeriod?.startDate) + if (selectedPeriodYear) { + setYear(selectedPeriodYear) } - }, [selectedPeriod?.year]) + }, [selectedPeriod?.startDate]) useEffect(() => { if (dataSetPeriodType) { const newMaxYear = getMaxYear(dateLimit) setMaxYear(newMaxYear) - if (!selectedPeriod?.year) { + const selectedPeriodYear = getYear(selectedPeriod?.startDate) + if (!selectedPeriodYear) { setYear(currentFullYear) } } - }, [dataSetPeriodType, selectedPeriod?.year, dateLimit, currentFullYear]) + }, [ + dataSetPeriodType, + selectedPeriod?.startDate, + dateLimit, + currentFullYear, + ]) useEffect(() => { - const resetPeriod = (id) => { - showWarningAlert(`The Period (${id}) is not open or is invalid.`) - i18n.t('The Period ({{id}}) is not open or is invalid.', { - id, - }) + const resetPeriod = (id, displayName) => { + showWarningAlert( + i18n.t('The Period ({{id}}) is not open or is invalid.', { + id: displayName ? displayName : id, + }) + ) setPeriodId(undefined) } if (selectedPeriod) { - const endDate = new Date(selectedPeriod?.endDate) - if (endDate >= dateLimit) { - resetPeriod(periodId) + const endDate = selectedPeriod?.endDate + const displayName = selectedPeriod?.displayName + + // date comparison (both in system calendar) + if ( + isDateAGreaterThanDateB( + { date: endDate, calendar }, + { date: dateLimit, calendar }, + { + inclusive: true, + } + ) + ) { + resetPeriod(periodId, displayName) } if (selectedPeriod?.periodType !== dataSetPeriodType) { - resetPeriod(periodId) + resetPeriod(periodId, selectedPeriod?.displayName) } } else if (periodId) { setPeriodId(undefined) @@ -101,6 +151,7 @@ export const PeriodSelectorBarItem = () => { setPeriodId, showWarningAlert, dataSetPeriodType, + calendar, ]) const selectorBarItemValue = useSelectorBarItemValue() @@ -125,6 +176,7 @@ export const PeriodSelectorBarItem = () => { maxYear={maxYear} year={year} onYearChange={(year) => setYear(year)} + calendar={calendar} /> )} diff --git a/src/context-selection/period-selector-bar-item/use-date-limit.js b/src/context-selection/period-selector-bar-item/use-date-limit.js index 91ab5c1d7..42a1bbe29 100644 --- a/src/context-selection/period-selector-bar-item/use-date-limit.js +++ b/src/context-selection/period-selector-bar-item/use-date-limit.js @@ -1,3 +1,4 @@ +import { useConfig } from '@dhis2/app-runtime' import { getAdjacentFixedPeriods, getFixedPeriodByDate, @@ -7,29 +8,17 @@ import { selectors, useDataSetId, useMetadata, - formatJsDateToDateString, - getCurrentDate, periodTypesMapping, - useClientServerDateUtils, - useClientServerDate, + getNowInCalendarString, } from '../../shared/index.js' -export const getDateInTimeZone = (dateString) => { - const [yyyy, mm, dd] = dateString.split('-') - if (isNaN(Number(yyyy)) || isNaN(Number(dd)) || isNaN(Number(mm))) { - return new Date(dateString) - } - return new Date(yyyy, Number(mm) - 1, dd) -} - export const computePeriodDateLimit = ({ periodType, - serverDate, + dateServerInCalendarString, openFuturePeriods = 0, + calendar = 'gregory', }) => { - const calendar = 'gregory' - // serverDate is converted to YYYY-MM-DD string named date before being passed - const date = serverDate.toLocaleDateString('sv') + const date = dateServerInCalendarString const currentPeriod = getFixedPeriodByDate({ periodType, date, @@ -37,7 +26,7 @@ export const computePeriodDateLimit = ({ }) if (openFuturePeriods <= 0) { - return getDateInTimeZone(currentPeriod.startDate) + return currentPeriod.startDate } const followingPeriods = getAdjacentFixedPeriods({ @@ -48,7 +37,7 @@ export const computePeriodDateLimit = ({ const [lastFollowingPeriod] = followingPeriods.slice(-1) - return getDateInTimeZone(lastFollowingPeriod.startDate) + return lastFollowingPeriod.startDate } /** @@ -58,19 +47,23 @@ export const computePeriodDateLimit = ({ * period is a considered afuture period) */ export const useDateLimit = () => { + const { systemInfo = {} } = useConfig() + const { calendar = 'gregory', serverTimeZoneId: timezone = 'Etc/UTC' } = + systemInfo const [dataSetId] = useDataSetId() const { data: metadata } = useMetadata() - const { fromClientDate } = useClientServerDateUtils() - const currentDate = useClientServerDate() - const currentDay = formatJsDateToDateString(currentDate.serverDate) + + const dateServerInCalendarString = getNowInCalendarString({ + calendar, + timezone, + }) return useMemo( () => { - const currentDate = fromClientDate(getCurrentDate()) const dataSet = selectors.getDataSetById(metadata, dataSetId) if (!dataSet) { - return currentDate.serverDate + return dateServerInCalendarString } const periodType = periodTypesMapping[dataSet.periodType] @@ -79,13 +72,13 @@ export const useDateLimit = () => { return computePeriodDateLimit({ periodType, openFuturePeriods, - serverDate: currentDate.serverDate, + dateServerInCalendarString, + calendar, }) }, // Adding `dateWithoutTime` to the dependency array so this hook will // recompute the date limit when the actual date changes - // eslint-disable-next-line react-hooks/exhaustive-deps - [dataSetId, metadata, currentDay, fromClientDate] + [dataSetId, metadata, dateServerInCalendarString, calendar] ) } diff --git a/src/context-selection/period-selector-bar-item/use-date-limit.test.js b/src/context-selection/period-selector-bar-item/use-date-limit.test.js index 09463612f..c6b51546f 100644 --- a/src/context-selection/period-selector-bar-item/use-date-limit.test.js +++ b/src/context-selection/period-selector-bar-item/use-date-limit.test.js @@ -1,20 +1,24 @@ +import { useConfig } from '@dhis2/app-runtime' import { renderHook } from '@testing-library/react-hooks' +import * as getNowInCalendarFunctions from '../../shared/date/get-now-in-calendar.js' import { - getCurrentDate, periodTypes, useMetadata, periodTypesMapping, } from '../../shared/index.js' -import { - useDateLimit, - computePeriodDateLimit, - getDateInTimeZone, -} from './use-date-limit.js' +import { useDateLimit, computePeriodDateLimit } from './use-date-limit.js' export const reversedPeriodTypesMapping = Object.fromEntries( Object.entries(periodTypesMapping).map(([key, value]) => [value, key]) ) +jest.mock('@dhis2/app-runtime', () => ({ + ...jest.requireActual('@dhis2/app-runtime'), + useConfig: jest.fn(() => ({ + systemInfo: { serverTimeZoneId: 'Etc/UTC', calendar: 'gregory' }, + })), +})) + jest.mock( '../../shared/use-context-selection/use-context-selection.js', () => ({ @@ -36,65 +40,29 @@ jest.mock('../../shared/metadata/use-metadata.js', () => ({ })), })) -jest.mock('../../shared/fixed-periods/get-current-date.js', () => ({ - __esModule: true, - default: jest.fn(() => new Date()), -})) - -jest.mock('../../shared/date/use-server-time-offset.js', () => ({ - __esModule: true, - default: jest.fn(() => 0), -})) - -describe('getDateInTimeZone', () => { - it('should return a date corrected that corresponds to the browser time zone', () => { - // note that we are setting the time zone for tests to UTC, so new Date() and getDateInTimeZone() are equivalent - const dateString = '2024-01-01' - const actual = getDateInTimeZone(dateString) - const expected = new Date(dateString) - expect(actual).toEqual(expected) - }) - - it('should use Date constructor if date is not in format yyyy-mm-dd', () => { - const dateString = '2024-01-01T12:00:00' - const actual = getDateInTimeZone(dateString) - const expected = new Date(dateString) - expect(actual).toEqual(expected) - }) -}) - describe('computePeriodDateLimit', () => { - const actualSystemTime = new Date() - jest.useFakeTimers() - - afterEach(() => { - jest.setSystemTime(actualSystemTime) - }) - it('it should return the "2022-04-01" date', () => { - const currentDate = getDateInTimeZone('2023-03-01') - jest.setSystemTime(currentDate) + const currentDate = '2023-03-01' const actual = computePeriodDateLimit({ periodType: periodTypes.FYAPR, - serverDate: currentDate, + dateServerInCalendarString: currentDate, openFuturePeriods: 0, }) - const expected = getDateInTimeZone('2022-04-01') + const expected = '2022-04-01' expect(actual).toEqual(expected) }) it('it should return the "2023-04-01" period', () => { - const currentDate = getDateInTimeZone('2023-05-01') - jest.setSystemTime(currentDate) + const currentDate = '2023-05-01' const actual = computePeriodDateLimit({ periodType: periodTypes.FYAPR, - serverDate: currentDate, + dateServerInCalendarString: currentDate, openFuturePeriods: 0, }) - const expected = getDateInTimeZone('2023-04-01') + const expected = '2023-04-01' expect(actual).toEqual(expected) }) @@ -331,8 +299,175 @@ describe.each([ 'useDateLimit', // eslint-disable-next-line max-params (currentDate, periodType, openFuturePeriods, expectedDate) => { + afterEach(() => { + jest.useRealTimers() + }) test(`should be ${expectedDate} if current date: ${currentDate}, periodType: ${periodType}, openFuturePeriods: ${openFuturePeriods}`, () => { - getCurrentDate.mockImplementation(() => new Date(currentDate)) + jest.useFakeTimers('modern') + // we can set the system time this way because our "browser" time zone is set to UTC + jest.setSystemTime(new Date(currentDate)) + useMetadata.mockImplementationOnce(() => ({ + data: { + dataSets: { + dataSetId: { + id: 'dataSetId', + periodType, + openFuturePeriods, + }, + }, + }, + })) + + const { result } = renderHook(() => useDateLimit()) + expect(result.current).toEqual(expectedDate) + }) + } +) + +describe('useDateLimit (time zones)', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + afterEach(() => { + jest.useRealTimers() + jest.clearAllMocks() + }) + + it('corrects for timezone discrepancy (earlier time zone)', () => { + jest.useFakeTimers('modern') + jest.setSystemTime(new Date('2024-06-01')) + useConfig.mockImplementation(() => ({ + systemInfo: { + serverTimeZoneId: 'America/Santiago', + calendar: 'gregory', + }, + })) + // browser time is UTC, but time zone is earlier, so we should get 1 day earlier as the boundary + useMetadata.mockImplementationOnce(() => ({ + data: { + dataSets: { + dataSetId: { + id: 'dataSetId', + periodType: reversedPeriodTypesMapping.DAILY, + openFuturePeriods: 0, + }, + }, + }, + })) + + const { result } = renderHook(() => useDateLimit()) + expect(result.current).toEqual('2024-05-31') + }) + + it('corrects for timezone discrepancy (later time zone)', () => { + jest.useFakeTimers('modern') + jest.setSystemTime(new Date('2024-06-01T22:00:00')) + useConfig.mockImplementation(() => ({ + systemInfo: { + serverTimeZoneId: 'Asia/Vientiane', + calendar: 'gregory', + }, + })) + // browser time is UTC, but time zone is later, so we get 1 day later as the boundary (since we are at 23:00 UTC) + useMetadata.mockImplementationOnce(() => ({ + data: { + dataSets: { + dataSetId: { + id: 'dataSetId', + periodType: reversedPeriodTypesMapping.DAILY, + openFuturePeriods: 0, + }, + }, + }, + })) + + const { result } = renderHook(() => useDateLimit()) + expect(result.current).toEqual('2024-06-02') + }) +}) + +describe.each([ + ['2017-13-03', reversedPeriodTypesMapping.DAILY, 0, '2017-13-03'], + ['2017-02-30', reversedPeriodTypesMapping.DAILY, 0, '2017-02-30'], + ['2017-02-30', reversedPeriodTypesMapping.WEEKLY, 0, '2017-02-26'], + ['2017-13-02', reversedPeriodTypesMapping.WEEKLY, 0, '2017-12-27'], + ['2017-01-01', reversedPeriodTypesMapping.MONTHLY, 0, '2017-01-01'], + ['2017-02-30', reversedPeriodTypesMapping.MONTHLY, 0, '2017-02-01'], + ['2017-13-03', reversedPeriodTypesMapping.DAILY, 4, '2018-01-02'], + ['2017-02-30', reversedPeriodTypesMapping.DAILY, 10, '2017-03-10'], + ['2017-02-30', reversedPeriodTypesMapping.WEEKLY, 3, '2017-03-17'], + ['2017-13-02', reversedPeriodTypesMapping.WEEKLY, 13, '2018-03-23'], + ['2017-01-01', reversedPeriodTypesMapping.MONTHLY, 5, '2017-06-01'], + ['2017-02-30', reversedPeriodTypesMapping.MONTHLY, 15, '2018-05-01'], +])( + 'useDateLimit (ethiopian calendar)', + // eslint-disable-next-line max-params + (currentDate, periodType, openFuturePeriods, expectedDate) => { + beforeEach(() => { + useConfig.mockImplementation(() => ({ + systemInfo: { calendar: 'ethiopian', timeZone: 'Etc/UTC' }, + })) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it(`should be ${expectedDate} if current date: ${currentDate}, periodType: ${periodType}, openFuturePeriods: ${openFuturePeriods}`, () => { + jest.spyOn( + getNowInCalendarFunctions, + 'getNowInCalendarString' + ).mockImplementation(() => currentDate) + useMetadata.mockImplementationOnce(() => ({ + data: { + dataSets: { + dataSetId: { + id: 'dataSetId', + periodType, + openFuturePeriods, + }, + }, + }, + })) + + const { result } = renderHook(() => useDateLimit()) + expect(result.current).toEqual(expectedDate) + }) + } +) + +describe.each([ + ['2076-04-32', reversedPeriodTypesMapping.DAILY, 0, '2076-04-32'], + ['2076-02-30', reversedPeriodTypesMapping.DAILY, 0, '2076-02-30'], + ['2076-02-30', reversedPeriodTypesMapping.WEEKLY, 0, '2076-02-27'], + ['2076-12-02', reversedPeriodTypesMapping.WEEKLY, 0, '2076-11-26'], + ['2076-01-01', reversedPeriodTypesMapping.MONTHLY, 0, '2076-01-01'], + ['2076-02-30', reversedPeriodTypesMapping.MONTHLY, 0, '2076-02-01'], + ['2076-04-32', reversedPeriodTypesMapping.DAILY, 4, '2076-05-04'], + ['2076-02-30', reversedPeriodTypesMapping.DAILY, 10, '2076-03-08'], + ['2076-02-30', reversedPeriodTypesMapping.WEEKLY, 3, '2076-03-16'], + ['2076-12-02', reversedPeriodTypesMapping.WEEKLY, 13, '2077-02-26'], + ['2076-01-01', reversedPeriodTypesMapping.MONTHLY, 5, '2076-06-01'], + ['2076-02-30', reversedPeriodTypesMapping.MONTHLY, 15, '2077-05-01'], +])( + 'useDateLimit (nepali calendar)', + // eslint-disable-next-line max-params + (currentDate, periodType, openFuturePeriods, expectedDate) => { + beforeEach(() => { + useConfig.mockImplementation(() => ({ + systemInfo: { calendar: 'nepali', timeZone: 'Etc/UTC' }, + })) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it(`should be ${expectedDate} if current date: ${currentDate}, periodType: ${periodType}, openFuturePeriods: ${openFuturePeriods}`, () => { + jest.spyOn( + getNowInCalendarFunctions, + 'getNowInCalendarString' + ).mockImplementation(() => currentDate) useMetadata.mockImplementationOnce(() => ({ data: { dataSets: { @@ -346,7 +481,7 @@ describe.each([ })) const { result } = renderHook(() => useDateLimit()) - expect(result.current).toEqual(new Date(expectedDate)) + expect(result.current).toEqual(expectedDate) }) } ) diff --git a/src/context-selection/period-selector-bar-item/use-deselect-on-period-type-change.js b/src/context-selection/period-selector-bar-item/use-deselect-on-period-type-change.js deleted file mode 100644 index 691413eec..000000000 --- a/src/context-selection/period-selector-bar-item/use-deselect-on-period-type-change.js +++ /dev/null @@ -1,51 +0,0 @@ -import { createFixedPeriodFromPeriodId } from '@dhis2/multi-calendar-dates' -import { useEffect, useState } from 'react' -import { useDataSetId, usePeriodId } from '../../shared/index.js' - -const convertPeriodIdToPeriodType = (periodId) => { - if (!periodId) { - return '' - } - - // @TODO(calendar) - const calendar = 'gregory' - try { - return ( - createFixedPeriodFromPeriodId({ periodId: periodId, calendar }) - ?.periodType || '' - ) - } catch (e) { - console.error(e) - // In case period id is invalid - return '' - } -} - -/** - * If the period type changes, we need to deselect the current selection. As - * the different selectors should be self-contained / isolated, I put this - * logic into the category option combo module - */ -export default function useDeselectOnPeriodTypeChange(dataSetPeriodType) { - const [periodId, setPeriodId] = usePeriodId() - const [previousPeriodType, setPreviousPeriodType] = useState( - convertPeriodIdToPeriodType(periodId) - ) - const [dataSetId] = useDataSetId() - - useEffect(() => { - if (previousPeriodType !== dataSetPeriodType) { - if (periodId) { - setPeriodId(undefined) - } - - setPreviousPeriodType(dataSetPeriodType) - } - }, [ - setPeriodId, - dataSetPeriodType, - dataSetId, - periodId, - previousPeriodType, - ]) -} 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 a3ecbe6ab..de174b5c7 100644 --- a/src/context-selection/period-selector-bar-item/use-periods.js +++ b/src/context-selection/period-selector-bar-item/use-periods.js @@ -1,10 +1,13 @@ +import { useConfig } from '@dhis2/app-runtime' import { generateFixedPeriods } from '@dhis2/multi-calendar-dates' import { useMemo } from 'react' +import { getNowInCalendarString } from '../../shared/date/get-now-in-calendar.js' import { - formatJsDateToDateString, - useClientServerDate, useUserInfo, yearlyFixedPeriodTypes, + startingYears, + isDateALessThanDateB, + isDateAGreaterThanDateB, } from '../../shared/index.js' export default function usePeriods({ @@ -14,19 +17,19 @@ export default function usePeriods({ // only required when periodType is a yearly period type openFuturePeriods, }) { - // @TODO(calendar) - const calendar = 'gregory' + const { systemInfo = {} } = useConfig() + const { calendar = 'gregory', serverTimeZoneId: timezone = 'Etc/UTC' } = + systemInfo + const { data: userInfo } = useUserInfo() const { keyUiLocale: locale } = userInfo.settings - const currentDate = useClientServerDate() - const currentDay = formatJsDateToDateString(currentDate.serverDate) + const currentDayString = getNowInCalendarString({ calendar, timezone }) return useMemo(() => { - // Adding `currentDay` to the dependency array so this hook will + // Adding `currentDayString` to the dependency array so this hook will // recompute the date limit when the actual date changes - currentDay - if (!periodType) { + if (!periodType || !calendar) { return [] } @@ -34,20 +37,21 @@ export default function usePeriods({ const yearForGenerating = isYearlyPeriodType ? year + openFuturePeriods : year - // dateLimit is converted to YYYY-MM-DD string named endsBefore before being passed - const endsBefore = dateLimit.toLocaleDateString('sv') const generateFixedPeriodsPayload = { calendar, periodType, year: yearForGenerating, - endsBefore, + endsBefore: dateLimit, locale, // only used when generating yearly periods, so save to use // here, regardless of the period type. - // + 1 so we include 1970 as well - yearsCount: yearForGenerating - 1970 + 1, + // + 1 so we include the starting year as well + yearsCount: + yearForGenerating - + (startingYears[calendar] ?? startingYears.default) + + 1, } const periods = generateFixedPeriods(generateFixedPeriodsPayload) @@ -66,9 +70,16 @@ export default function usePeriods({ // we want to display the last period of the previous year if it // stretches into the current year + // date comparison (both are system calendar) if ( lastPeriodOfPrevYear && - `${year}-01-01` <= lastPeriodOfPrevYear.endDate + isDateALessThanDateB( + { date: `${year}-01-01`, calendar }, + { date: lastPeriodOfPrevYear.endDate, calendar }, + { + inclusive: true, + } + ) ) { const [lastPeriodOfPrevYear] = generateFixedPeriods({ ...generateFixedPeriodsPayload, @@ -81,13 +92,28 @@ export default function usePeriods({ // if we're allowed to display the first period of the next year, we // want to display the first period of the next year if it starts in // the current year + // date comparison if ( firstPeriodNextYear && - `${year + 1}-01-01` > firstPeriodNextYear.startDate + // `${year + 1}-01-01` > firstPeriodNextYear.startDate + // date comparison (both in system calendar) + isDateAGreaterThanDateB( + { date: `${year + 1}-01-01`, calendar }, + { date: firstPeriodNextYear.startDate, calendar }, + { inclusive: false } + ) ) { periods.push(firstPeriodNextYear) } return periods.reverse() - }, [periodType, currentDay, year, dateLimit, locale, openFuturePeriods]) + }, [ + periodType, + currentDayString, + year, + dateLimit, + locale, + openFuturePeriods, + calendar, + ]) } diff --git a/src/context-selection/period-selector-bar-item/use-periods.test.js b/src/context-selection/period-selector-bar-item/use-periods.test.js index 6a511a769..e61e3ff27 100644 --- a/src/context-selection/period-selector-bar-item/use-periods.test.js +++ b/src/context-selection/period-selector-bar-item/use-periods.test.js @@ -1,6 +1,14 @@ +import { useConfig } from '@dhis2/app-runtime' import { renderHook } from '@testing-library/react-hooks' import usePeriods from './use-periods.js' +jest.mock('@dhis2/app-runtime', () => ({ + ...jest.requireActual('@dhis2/app-runtime'), + useConfig: jest.fn(() => ({ + systemInfo: { serverTimeZoneId: 'Etc/UTC', calendar: 'gregory' }, + })), +})) + jest.mock('../../shared/use-user-info/use-user-info.js', () => ({ useUserInfo: jest.fn(() => ({ data: { @@ -9,11 +17,6 @@ jest.mock('../../shared/use-user-info/use-user-info.js', () => ({ })), })) -jest.mock('../../shared/date/use-server-time-offset.js', () => ({ - __esModule: true, - default: jest.fn(() => 0), -})) - describe('usePeriods', () => { const actualSystemTime = new Date() jest.useFakeTimers() @@ -26,7 +29,7 @@ describe('usePeriods', () => { const periodType = 'DAILY' const openFuturePeriods = 0 const year = 2023 - const dateLimit = new Date('2023-03-01') + const dateLimit = '2023-03-01' const { result } = renderHook(() => usePeriods({ @@ -58,7 +61,7 @@ describe('usePeriods', () => { const periodType = 'WEEKLY' const openFuturePeriods = 0 const year = 2023 - const dateLimit = new Date('2023-03-01') + const dateLimit = '2023-03-01' const { result } = renderHook(() => usePeriods({ @@ -90,7 +93,7 @@ describe('usePeriods', () => { const periodType = 'WEEKLY' const openFuturePeriods = 0 const year = 2019 - const dateLimit = new Date('2023-03-01') + const dateLimit = '2023-03-01' const { result } = renderHook(() => usePeriods({ @@ -122,7 +125,7 @@ describe('usePeriods', () => { const periodType = 'MONTHLY' const openFuturePeriods = 0 const year = 2023 - const dateLimit = new Date('2023-07-16') + const dateLimit = '2023-07-16' const { result } = renderHook(() => usePeriods({ @@ -154,7 +157,7 @@ describe('usePeriods', () => { const periodType = 'QUARTERLY' const openFuturePeriods = 0 const year = 2023 - const dateLimit = new Date('2023-08-16') + const dateLimit = '2023-08-16' const { result } = renderHook(() => usePeriods({ @@ -184,7 +187,7 @@ describe('usePeriods', () => { const periodType = 'SIXMONTHLYNOV' const openFuturePeriods = 0 const year = 2023 - const dateLimit = new Date('2023-08-16') + const dateLimit = '2023-08-16' const { result } = renderHook(() => usePeriods({ @@ -209,7 +212,7 @@ describe('usePeriods', () => { const periodType = 'YEARLY' const openFuturePeriods = 0 const year = 2023 - const dateLimit = new Date('2023-08-16') + const dateLimit = '2023-08-16' const { result } = renderHook(() => usePeriods({ @@ -241,7 +244,7 @@ describe('usePeriods', () => { const periodType = 'FYNOV' const openFuturePeriods = 0 const year = 2023 - const dateLimit = new Date('2023-08-16') + const dateLimit = '2023-08-16' const { result } = renderHook(() => usePeriods({ @@ -277,10 +280,300 @@ describe('usePeriods', () => { periodType: 'FYAPR', openFuturePeriods: 2, year: 2023, - dateLimit: new Date('2024-04-01'), + dateLimit: '2024-04-01', }) ) expect(result.current).toHaveLength(54) }) }) + +describe('usePeriods (ethiopian)', () => { + beforeEach(() => { + jest.useFakeTimers('modern') + jest.setSystemTime(new Date('2024-07-15T12:00:00').getTime()) + useConfig.mockImplementation(() => ({ + systemInfo: { calendar: 'ethiopian', timeZone: 'Etc/UTC' }, + })) + }) + + afterEach(() => { + jest.clearAllMocks() + jest.useRealTimers() + }) + + it('should return a list of daily periods', () => { + const periodType = 'DAILY' + const openFuturePeriods = 0 + const year = 2015 + const dateLimit = '2015-02-30' + + const { result } = renderHook(() => + usePeriods({ + periodType, + openFuturePeriods, + year, + dateLimit, + }) + ) + + expect(result.current).toHaveLength(59) + expect(result.current[0]).toEqual( + expect.objectContaining({ + endDate: '2015-02-29', + startDate: '2015-02-29', + id: '20150229', + }) + ) + expect(result.current[58]).toEqual( + expect.objectContaining({ + endDate: '2015-01-01', + startDate: '2015-01-01', + id: '20150101', + }) + ) + }) + + it('should return a list of weekly periods', () => { + const periodType = 'WEEKLY' + const openFuturePeriods = 0 + const year = 2015 + const dateLimit = '2015-02-30' + + const { result } = renderHook(() => + usePeriods({ + periodType, + openFuturePeriods, + year, + dateLimit, + }) + ) + + expect(result.current).toHaveLength(9) + expect(result.current[0]).toEqual( + expect.objectContaining({ + startDate: '2015-02-22', + id: '2015W8', + endDate: '2015-02-28', + }) + ) + expect(result.current[8]).toEqual( + expect.objectContaining({ + startDate: '2014-13-01', + id: '2014W52', + endDate: '2015-01-02', + }) + ) + }) + + it('should return a list of monthly periods', () => { + const periodType = 'MONTHLY' + const openFuturePeriods = 0 + const year = 2015 + const dateLimit = '2015-07-16' + + const { result } = renderHook(() => + usePeriods({ + periodType, + openFuturePeriods, + year, + dateLimit, + }) + ) + + expect(result.current).toHaveLength(6) + expect(result.current[0]).toEqual( + expect.objectContaining({ + endDate: '2015-06-30', + startDate: '2015-06-01', + id: '201506', + name: 'Yekatit 2015', + }) + ) + expect(result.current[5]).toEqual( + expect.objectContaining({ + endDate: '2015-01-30', + startDate: '2015-01-01', + id: '201501', + name: 'Meskerem 2015', + }) + ) + }) + + it('should return a list of yearly periods', () => { + const periodType = 'YEARLY' + const openFuturePeriods = 0 + const year = 2016 + const dateLimit = '2016-08-16' + + const { result } = renderHook(() => + usePeriods({ + periodType, + openFuturePeriods, + year, + dateLimit, + }) + ) + + expect(result.current).toHaveLength(53) + expect(result.current[0]).toEqual( + expect.objectContaining({ + endDate: '2015-13-06', + startDate: '2015-01-01', + id: '2015', + }) + ) + expect(result.current[52]).toEqual( + expect.objectContaining({ + endDate: '1963-13-06', + startDate: '1963-01-01', + id: '1963', + }) + ) + }) +}) + +describe('usePeriods (nepali)', () => { + beforeEach(() => { + jest.useFakeTimers('modern') + jest.setSystemTime(new Date('2024-07-15T12:00:00').getTime()) + useConfig.mockImplementation(() => ({ + systemInfo: { calendar: 'nepali', timeZone: 'Etc/UTC' }, + })) + }) + + afterEach(() => { + jest.clearAllMocks() + jest.useRealTimers() + }) + + it('should return a list of daily periods', () => { + const periodType = 'DAILY' + const openFuturePeriods = 0 + const year = 2084 + const dateLimit = '2084-02-31' + + const { result } = renderHook(() => + usePeriods({ + periodType, + openFuturePeriods, + year, + dateLimit, + }) + ) + + expect(result.current).toHaveLength(61) + expect(result.current[0]).toEqual( + expect.objectContaining({ + endDate: '2084-02-30', + startDate: '2084-02-30', + id: '20840230', + }) + ) + expect(result.current[60]).toEqual( + expect.objectContaining({ + endDate: '2084-01-01', + startDate: '2084-01-01', + id: '20840101', + }) + ) + }) + + it('should return a list of weekly periods', () => { + const periodType = 'WEEKLY' + const openFuturePeriods = 0 + const year = 2084 + const dateLimit = '2084-02-30' + + const { result } = renderHook(() => + usePeriods({ + periodType, + openFuturePeriods, + year, + dateLimit, + }) + ) + + expect(result.current).toHaveLength(8) + expect(result.current[0]).toEqual( + expect.objectContaining({ + startDate: '2084-02-17', + id: '2084W8', + endDate: '2084-02-23', + }) + ) + expect(result.current[7]).toEqual( + expect.objectContaining({ + startDate: '2083-12-29', + id: '2084W1', + endDate: '2084-01-05', + }) + ) + }) + + it('should return a list of monthly periods', () => { + const periodType = 'MONTHLY' + const openFuturePeriods = 0 + const year = 2084 + const dateLimit = '2084-07-16' + + const { result } = renderHook(() => + usePeriods({ + periodType, + openFuturePeriods, + year, + dateLimit, + }) + ) + + expect(result.current).toHaveLength(6) + expect(result.current[0]).toEqual( + expect.objectContaining({ + endDate: '2084-06-30', + startDate: '2084-06-01', + id: '208406', + name: 'Ashwin 2084', + }) + ) + expect(result.current[5]).toEqual( + expect.objectContaining({ + endDate: '2084-01-31', + startDate: '2084-01-01', + id: '208401', + name: 'Baisakh 2084', + }) + ) + }) + + it.skip('should return a list of yearly periods', () => { + const periodType = 'YEARLY' + const openFuturePeriods = 0 + const year = 2084 + const dateLimit = '2084-08-16' + + const { result } = renderHook(() => + usePeriods({ + periodType, + openFuturePeriods, + year, + dateLimit, + }) + ) + + expect(result.current).toHaveLength(53) + expect(result.current[0]).toEqual( + expect.objectContaining({ + endDate: '2083-12-30', + startDate: '2083-01-01', + id: '2083', + }) + ) + expect(result.current[52]).toEqual( + expect.objectContaining({ + endDate: '1963-13-06', + startDate: '1963-01-01', + id: '1963', + }) + ) + }) +}) 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 02fe2138c..16e8f124d 100644 --- a/src/context-selection/period-selector-bar-item/year-navigator.js +++ b/src/context-selection/period-selector-bar-item/year-navigator.js @@ -1,16 +1,21 @@ import { Button, IconArrowRight24, IconArrowLeft24 } from '@dhis2/ui' import PropTypes from 'prop-types' import React from 'react' +import { startingYears } from '../../shared/index.js' import classes from './year-navigator.module.css' -const startYear = 1970 - -export default function YearNavigator({ maxYear, year, onYearChange }) { +export default function YearNavigator({ + maxYear, + year, + onYearChange, + calendar, +}) { + const startYear = startingYears[calendar] ?? startingYears.default return (
) diff --git a/src/data-workspace/data-details-sidebar/audit-log.module.css b/src/data-workspace/data-details-sidebar/audit-log.module.css index 17751dacc..ad3d386b4 100644 --- a/src/data-workspace/data-details-sidebar/audit-log.module.css +++ b/src/data-workspace/data-details-sidebar/audit-log.module.css @@ -47,4 +47,11 @@ .alignToEnd { text-align: right; +} + +.timeZoneNote { + margin-block-start: var(--spacers-dp4); + font-size: 12px; + line-height: 19px; + color: var(--colors-grey600); } \ No newline at end of file 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 ffe4cd0d9..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,14 +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('../../shared/date/use-client-server-date-utils.js', () => () => ({ - fromServerDate: jest.fn(), -})) - jest.mock('./use-data-value-context.js', () => ({ __esModule: true, default: jest.fn(), @@ -71,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', }, ] @@ -110,24 +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( + 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 094fb11bf..7201759ff 100644 --- a/src/data-workspace/data-details-sidebar/basic-information.js +++ b/src/data-workspace/data-details-sidebar/basic-information.js @@ -1,22 +1,23 @@ +import { useConfig } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' import { Tooltip, IconFlag16, colors } from '@dhis2/ui' -import moment from 'moment' import React from 'react' -import { useClientServerDate } 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 }) => { - // This might pass "undefined" to moment and subsequently a wrong - // "timeAgo", but in that case we won't render anything anyway, so there's - // nothing to worry about in case there is no "item" - const lastUpdated = useClientServerDate({ - serverDate: new Date(item.lastUpdated), + const { systemInfo = {} } = useConfig() + 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: 'gregory', + timezone, }) - // @TODO: This is not being translated! - // https://dhis2.atlassian.net/browse/TECH-1461 - const timeAgo = moment(lastUpdated.clientDate).fromNow() return (
@@ -56,20 +57,17 @@ const BasicInformation = ({ item }) => { })}
  • - { - // Safeguard! Using item because the `lastUpdated` - // variable will always have a value - item.lastUpdated && ( - - {i18n.t( - 'Last updated {{- timeAgo}} by {{- name}}', - { timeAgo, name: item.storedBy } - )} - - ) - } + {item.lastUpdated && ( + }> + {i18n.t( + 'Last updated {{- timeAgo}} by {{- name}}', + { + timeAgo, + name: item.storedBy, + } + )} + + )}
  • {item.followUp ? ( 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 6d64b1610..ecf911f32 100644 --- a/src/data-workspace/data-details-sidebar/basic-information.test.js +++ b/src/data-workspace/data-details-sidebar/basic-information.test.js @@ -1,18 +1,18 @@ +import { useConfig } from '@dhis2/app-runtime' import React from 'react' import useHighlightedField from '../../shared/highlighted-field/use-highlighted-field.js' import { render } from '../../test-utils/index.js' import BasicInformation from './basic-information.js' -jest.mock('../../shared/highlighted-field/use-highlighted-field.js') - -jest.mock('../../shared/date/use-client-server-date.js', () => ({ - __esModule: true, - default: jest.fn(({ serverDate }) => ({ - serverDate, - clientDate: serverDate, +jest.mock('@dhis2/app-runtime', () => ({ + ...jest.requireActual('@dhis2/app-runtime'), + useConfig: jest.fn(() => ({ + systemInfo: { serverTimeZoneId: 'Etc/UTC', calendar: 'gregory' }, })), })) +jest.mock('../../shared/highlighted-field/use-highlighted-field.js') + const noop = () => {} const item = { @@ -41,6 +41,7 @@ describe('', () => { afterEach(() => { jest.useRealTimers() + jest.clearAllMocks() }) it('renders the item name in a heading', () => { @@ -96,6 +97,30 @@ describe('', () => { expect(getByText('a minute ago', { exact: false })).toBeInTheDocument() }) + it('renders relative time with non-gregory calendar', () => { + useConfig.mockImplementation(() => ({ + systemInfo: { + serverTimeZoneId: 'Africa/Abidjan', + calendar: 'nepali', + }, + })) + + const { getByText } = render( + + ) + + expect(getByText(item.storedBy, { exact: false })).toBeInTheDocument() + expect( + getByText('a minute ago', { + exact: false, + }) + ).toBeInTheDocument() + }) + it('renders the item description if one is provided', () => { const description = 'this is the very helpful description' const itemWithDescription = { diff --git a/src/data-workspace/data-details-sidebar/data-details-sidebar.test.js b/src/data-workspace/data-details-sidebar/data-details-sidebar.test.js index ee62a20ad..149554039 100644 --- a/src/data-workspace/data-details-sidebar/data-details-sidebar.test.js +++ b/src/data-workspace/data-details-sidebar/data-details-sidebar.test.js @@ -4,9 +4,11 @@ import { useUserInfo } from '../../shared/use-user-info/use-user-info.js' import { render } from '../../test-utils/render.js' import DataDetailsSidebar from './data-details-sidebar.js' -jest.mock('../../shared/date/use-server-time-offset.js', () => ({ - __esModule: true, - default: () => 0, +jest.mock('@dhis2/app-runtime', () => ({ + ...jest.requireActual('@dhis2/app-runtime'), + useConfig: jest.fn(() => ({ + systemInfo: { serverTimeZoneId: 'Etc/UTC', calendar: 'gregory' }, + })), })) jest.mock('../../shared/highlighted-field/use-highlighted-field.js') 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 2c4c274a4..a67b93fc3 100644 --- a/src/data-workspace/data-details-sidebar/history-line-chart.js +++ b/src/data-workspace/data-details-sidebar/history-line-chart.js @@ -1,3 +1,4 @@ +import { useConfig } from '@dhis2/app-runtime' import { createFixedPeriodFromPeriodId } from '@dhis2/multi-calendar-dates' import { Chart as ChartJS, @@ -12,6 +13,10 @@ import { import PropTypes from 'prop-types' import React from 'react' import { Line } from 'react-chartjs-2' +import { + isDateAGreaterThanDateB, + isDateALessThanDateB, +} from '../../shared/index.js' ChartJS.register( CategoryScale, @@ -30,29 +35,43 @@ const options = { }, } -function sortHistoryByStartDate(history) { +function sortHistoryByStartDate(history, calendar = 'gregory') { // [...history] -> prevent mutating the original array return [...history].sort((left, right) => { - // @TODO(calendar) - const calendar = 'gregory' - const leftStartDate = new Date( - createFixedPeriodFromPeriodId({ - periodId: left.period, - calendar, - }).startDate - ) - const rightStartDate = new Date( - createFixedPeriodFromPeriodId({ - periodId: right.period, - calendar, - }).startDate - ) + const leftStartDate = createFixedPeriodFromPeriodId({ + periodId: left.period, + calendar, + }).startDate + + const rightStartDate = createFixedPeriodFromPeriodId({ + periodId: right.period, + calendar, + }).startDate - if (leftStartDate > rightStartDate) { + // date comparison + // date comparison (both in system calendar) + if ( + isDateAGreaterThanDateB( + { date: leftStartDate, calendar }, + { date: rightStartDate, calendar }, + { + inclusive: false, + } + ) + ) { return 1 } - if (leftStartDate < rightStartDate) { + if ( + // date comparison (both are system calendar) + isDateALessThanDateB( + { date: leftStartDate, calendar }, + { date: rightStartDate, calendar }, + { + inclusive: false, + } + ) + ) { return -1 } @@ -60,9 +79,7 @@ function sortHistoryByStartDate(history) { }) } -function createLabelsFromHistory(history) { - // @TODO(calendar) - const calendar = 'gregory' +function createLabelsFromHistory(history, calendar) { return history.map(({ period }) => { try { return createFixedPeriodFromPeriodId({ periodId: period, calendar }) @@ -76,8 +93,10 @@ function createLabelsFromHistory(history) { } export default function HistoryLineChart({ history }) { - const oldToNewHistory = sortHistoryByStartDate(history) - const labels = createLabelsFromHistory(oldToNewHistory) + const { systemInfo = {} } = useConfig() + const { calendar = 'gregory' } = systemInfo + const oldToNewHistory = sortHistoryByStartDate(history, calendar) + const labels = createLabelsFromHistory(oldToNewHistory, calendar) const data = { labels, datasets: [ diff --git a/src/shared/date/date-text.js b/src/shared/date/date-text.js new file mode 100644 index 000000000..10a26be2d --- /dev/null +++ b/src/shared/date/date-text.js @@ -0,0 +1,72 @@ +import { useConfig, useTimeZoneConversion } from '@dhis2/app-runtime' +import i18n from '@dhis2/d2-i18n' +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 + + // check that date is parsable as ISO + const parsedDate = new Date(date) + if (isNaN(parsedDate)) { + return {i18n.t('Invalid date ({{date}})', { date })} + } + + // 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..dd610dcb3 --- /dev/null +++ b/src/shared/date/date-text.test.js @@ -0,0 +1,159 @@ +/* eslint-disable max-params */ +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([ + [ + '2024-10-14T19:10:57.836', + 'yyyy-mm-dd', + 'gregory', + 'Etc/UTC', + false, + '2024-10-14 19:10', + ], + [ + '2024-10-14T19:10:57.836', + 'yyyy-mm-dd', + 'gregory', + 'Etc/UTC', + true, + '2024-10-14 19:10 (UTC)', + ], + [ + '2024-10-14T19:10:57.836', + 'dd-mm-yyyy', + 'gregory', + 'Etc/UTC', + false, + '14-10-2024 19:10', + ], + [ + '2024-10-14T19:10:57.836', + 'dd-mm-yyyy', + 'gregory', + 'Etc/UTC', + true, + '14-10-2024 19:10 (UTC)', + ], + [ + '2024-10-14T19:10:57.836', + 'yyyy-mm-dd', + 'gregory', + 'Asia/Vientiane', + true, + '2024-10-14 12:10 (UTC)', + ], + [ + '2024-10-14T19:10:57.836', + 'yyyy-mm-dd', + 'gregory', + 'Atlantic/Cape_Verde', + true, + '2024-10-14 20:10 (UTC)', + ], + [ + '2024-10-14T19:10:57.836', + 'yyyy-mm-dd', + 'gregory', + 'Etc/UTC', + null, + '2024-10-14 19:10', + ], + [ + '2024-10-14T19:10:57.836', + 'yyyy-mm-dd', + 'ethiopian', + 'Etc/UTC', + false, + '2017-02-04 19:10', + ], + [ + '2024-10-14T19:10:57.836', + 'yyyy-mm-dd', + 'ethiopian', + 'Africa/Addis_Ababa', + false, + '2017-02-04 16:10', + ], + [ + '2024-10-14T19:10:57.836', + 'yyyy-mm-dd', + 'ethiopian', + 'Africa/Addis_Ababa', + true, + '2017-02-04 16:10 (UTC)', + ], + [ + '2024-10-14T19:10:57.836', + 'yyyy-mm-dd', + 'nepali', + 'Etc/UTC', + false, + '2081-06-28 19:10', + ], + [ + '2024-10-14T19:10:57.836', + 'yyyy-mm-dd', + 'nepali', + 'Asia/Kathmandu', + false, + '2081-06-28 13:25', + ], + [ + '2024-10-14T19:10:57.836', + 'dd-mm-yyyy', + 'nepali', + 'Asia/Kathmandu', + false, + '28-06-2081 13:25', + ], + [ + '2017-13-05T19:10:57.836', + 'yyyy-mm-dd', + 'gregory', + 'Etc/UTC', + false, + 'Invalid date (2017-13-05T19:10:57.836)', + ], + ])( + 'with input of %s format is %s, calendar is %s, server time zone is %s, and includeTimeZone is %s. Should display %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 new file mode 100644 index 000000000..7eecc8ff1 --- /dev/null +++ b/src/shared/date/date-utils.js @@ -0,0 +1,181 @@ +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'] // 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}$/) + +export const padWithZeros = (startValue, minLength) => { + try { + const startString = String(startValue) + return startString.padStart(minLength, '0') + } catch (e) { + console.error(e) + return startValue + } +} + +const formatDate = (date, withoutTimeStamp) => { + const yearString = padWithZeros(date.getFullYear(), 4) + const monthString = padWithZeros(date.getMonth() + 1, 2) // Jan = 0 + const dateString = padWithZeros(date.getDate(), 2) + const hoursString = padWithZeros(date.getHours(), 2) + const minuteString = padWithZeros(date.getMinutes(), 2) + const secondsString = padWithZeros(date.getSeconds(), 2) + + if (withoutTimeStamp) { + return `${yearString}-${monthString}-${dateString}` + } + 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 +export const addDaysToDateString = ({ + startDateString, + days, + calendar = 'gregory', +}) => { + // 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(startDateStringISO) + const adjustedStartDateString = withoutTimeStamp + ? startDateStringISO + 'T00:00' + : startDateStringISO + + const startDate = new Date(adjustedStartDateString) + const endDate = new Date(startDate.getTime() + days * DAY_MS) + + // we remove the YYYY-MM-DD format if that was what was originally passed + const formattedDate = 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 +export const getRelativeTime = ({ startDate, endDate, calendar, timezone }) => { + if (!startDate) { + return null + } + + // 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 = ( + { date: dateA, calendar: calendarA = 'gregory' } = {}, + { date: dateB, calendar: calendarB = 'gregory' } = {}, + { inclusive = false } = {} +) => { + if (!dateA || !dateB) { + return false + } + // 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 (isNaN(dateBDate)) { + console.error(`Invalid date: ${dateB}`, dateBString, dateBISO) + return null + } + + if (inclusive) { + return dateADate <= dateBDate + } else { + return dateADate < dateBDate + } +} + +// testing (a < b) is equivalent to testing (b > a), so we reuse the other function +export const isDateAGreaterThanDateB = ( + dateA, + dateB, + { inclusive = false } = {} +) => { + return isDateALessThanDateB(dateB, dateA, { inclusive }) +} diff --git a/src/shared/date/date-utils.test.js b/src/shared/date/date-utils.test.js new file mode 100644 index 000000000..7b37bd3a5 --- /dev/null +++ b/src/shared/date/date-utils.test.js @@ -0,0 +1,357 @@ +import { + isDateALessThanDateB, + isDateAGreaterThanDateB, + addDaysToDateString, + getRelativeTime, +} from './date-utils.js' + +describe('isDateALessThanDateB (gregory)', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(jest.fn()) + }) + + it('works for dates without time information', () => { + 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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) + }) +}) + +describe('isDateALessThanDateB (nepali)', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(jest.fn()) + }) + + it('works for dates without time information', () => { + 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 = { 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 = { 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 = { 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 = { 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) + }) +}) + +describe('isDateALessThanDateB (ethiopian)', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(jest.fn()) + }) + + it('works for dates without time information', () => { + 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 = { 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 = { 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 = { 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 = { 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('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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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('adds appropriate number of days', () => { + const startDateString = '2023-03-15' + const days = 5 + const calendar = 'gregorian' + const result = addDaysToDateString({ startDateString, days, calendar }) + expect(result).toBe('2023-03-20') + }) + + it('adds appropriate number of days across months', () => { + const startDateString = '2023-03-15' + const days = 35 + const calendar = 'gregorian' + const result = addDaysToDateString({ startDateString, days, calendar }) + expect(result).toBe('2023-04-19') + }) + + it('handles negative days', () => { + const startDateString = '2023-03-15' + const days = -5 + const calendar = 'gregorian' + const result = addDaysToDateString({ startDateString, days, calendar }) + expect(result).toBe('2023-03-10') + }) + + it('returns date with timestamp if originally included', () => { + const startDateString = '2023-03-15T12:00:00' + const days = 5 + const calendar = 'gregorian' + 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', () => { + beforeEach(() => { + jest.useFakeTimers('modern') + jest.setSystemTime(new Date('2024-06-15T12:00:00').getTime()) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + 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('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', () => { + const startDate = '2024-06-15T11:00:00' + const calendar = 'gregory' + const result = getRelativeTime({ startDate, calendar }) + expect(result).toBe('an hour ago') + }) + + it('corrects for timezone differences when no end date is specified and time zone is passed', () => { + const startDate = '2024-06-15T11:00:00' + const calendar = 'gregory' + const timezone = 'Europe/Oslo' + const result = getRelativeTime({ startDate, calendar, timezone }) + // now (client): is 12:00 UTC; last updated (server): is 11:00 (Europe/Oslo), which is 9:00 UTC + expect(result).toBe('3 hours ago') + }) + + it('returns relative time with gregory dates based on end date if provided', () => { + const startDate = '2024-06-15T11:00:00' + const endDate = '2024-06-15T17:00:00' + const calendar = 'gregory' + const result = getRelativeTime({ startDate, endDate, calendar }) + expect(result).toBe('6 hours ago') + }) +}) diff --git a/src/shared/date/format-js-date-to-date-string.js b/src/shared/date/format-js-date-to-date-string.js deleted file mode 100644 index b89987684..000000000 --- a/src/shared/date/format-js-date-to-date-string.js +++ /dev/null @@ -1,7 +0,0 @@ -export default function formatJsDateToDateString(date) { - const yyyy = date.getFullYear() - const mm = String(date.getMonth() + 1).padStart(2, '0') - const dd = String(date.getDate()).padStart(2, '0') - - return `${yyyy}-${mm}-${dd}` -} diff --git a/src/shared/date/get-now-in-calendar.js b/src/shared/date/get-now-in-calendar.js new file mode 100644 index 000000000..587b7e2f0 --- /dev/null +++ b/src/shared/date/get-now-in-calendar.js @@ -0,0 +1,28 @@ +import { getNowInCalendar } from '@dhis2/multi-calendar-dates' +import { padWithZeros } from './date-utils.js' + +const stringifyDate = (temporalDate, long, calendar) => { + const year = ['ethiopian', 'ethiopic'].includes(calendar) + ? temporalDate.eraYear + : temporalDate.year + const shortDate = `${padWithZeros(year, 4)}-${padWithZeros( + temporalDate.month, + 2 + )}-${padWithZeros(temporalDate.day, 2)}` + if (!long) { + return shortDate + } + return `${shortDate}T${padWithZeros(temporalDate.hour, 2)}:${padWithZeros( + temporalDate.minute, + 2 + )}:${padWithZeros(temporalDate.second, 2)}` +} + +export const getNowInCalendarString = ({ + calendar = 'gregory', + timezone = 'Etc/UTC', + long = false, +} = {}) => { + const nowTemporal = getNowInCalendar(calendar, timezone) + return stringifyDate(nowTemporal, long, calendar) +} diff --git a/src/shared/date/get-now-in-calendar.test.js b/src/shared/date/get-now-in-calendar.test.js new file mode 100644 index 000000000..a005a9405 --- /dev/null +++ b/src/shared/date/get-now-in-calendar.test.js @@ -0,0 +1,52 @@ +import { getNowInCalendarString } from './get-now-in-calendar.js' + +describe('getNowInCalendarString', () => { + beforeEach(() => { + jest.useFakeTimers('modern') + jest.setSystemTime(new Date('2024-06-15T12:00:00').getTime()) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('returns YYYY-MM-DD and assumes gregory calendar by default', () => { + const result = getNowInCalendarString() + expect(result).toBe('2024-06-15') + }) + + it('returns date in long format if specified', () => { + const result = getNowInCalendarString({ long: true }) + expect(result).toBe('2024-06-15T12:00:00') + }) + + it('corrects for time zone difference', () => { + const long = true + const timezone = 'Africa/Kigali' + const result = getNowInCalendarString({ long, timezone }) + // Kigali is UTC+2 + expect(result).toBe('2024-06-15T14:00:00') + }) + + it('corrects for time zone difference', () => { + const long = true + const timezone = 'Africa/Kigali' + const result = getNowInCalendarString({ long, timezone }) + // Kigali is UTC+2 + expect(result).toBe('2024-06-15T14:00:00') + }) + + it('handles nepali calendar', () => { + const long = true + const calendar = 'nepali' + const result = getNowInCalendarString({ long, calendar }) + expect(result).toBe('2081-03-01T12:00:00') + }) + + it('handles ethiopian calendar', () => { + const long = true + const calendar = 'ethiopian' + const result = getNowInCalendarString({ long, calendar }) + expect(result).toBe('2016-10-08T12:00:00') + }) +}) diff --git a/src/shared/date/index.js b/src/shared/date/index.js index 9b099e8d7..ae33c594d 100644 --- a/src/shared/date/index.js +++ b/src/shared/date/index.js @@ -1,4 +1,11 @@ -export { default as formatJsDateToDateString } from './format-js-date-to-date-string.js' -export { default as useClientServerDate } from './use-client-server-date.js' -export { default as useClientServerDateUtils } from './use-client-server-date-utils.js' -export { default as useServerTimeOffset } from './use-server-time-offset.js' +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/date/starting-years.js b/src/shared/date/starting-years.js new file mode 100644 index 000000000..218a324f3 --- /dev/null +++ b/src/shared/date/starting-years.js @@ -0,0 +1,13 @@ +// equivalents of 1970, except for Nepali which is 3 years later +// we don't have calendar details for nepali calendar from 1970 and back + +export const startingYears = { + nepali: 2030, + coptic: 1686, + ethiopian: 1963, + ethiopic: 1963, + persian: 1349, + thai: 2513, + islamic: 1392, + default: 1970, +} diff --git a/src/shared/date/use-client-server-date-utils.js b/src/shared/date/use-client-server-date-utils.js deleted file mode 100644 index 09c3e92ec..000000000 --- a/src/shared/date/use-client-server-date-utils.js +++ /dev/null @@ -1,27 +0,0 @@ -import { useCallback, useMemo } from 'react' -import useServerTimeOffset from './use-server-time-offset.js' - -export default function useClientServerDateUtils() { - const serverTimeOffset = useServerTimeOffset() - - const fromServerDate = useCallback( - (serverDate) => { - const clientDate = new Date(serverDate.getTime() + serverTimeOffset) - return { serverDate, clientDate } - }, - [serverTimeOffset] - ) - - const fromClientDate = useCallback( - (clientDate) => { - const serverDate = new Date(clientDate.getTime() - serverTimeOffset) - return { clientDate, serverDate } - }, - [serverTimeOffset] - ) - - return useMemo( - () => ({ fromServerDate, fromClientDate }), - [fromServerDate, fromClientDate] - ) -} diff --git a/src/shared/date/use-client-server-date-utils.test.js b/src/shared/date/use-client-server-date-utils.test.js deleted file mode 100644 index 4da90e88f..000000000 --- a/src/shared/date/use-client-server-date-utils.test.js +++ /dev/null @@ -1,45 +0,0 @@ -import { renderHook } from '@testing-library/react-hooks' -import useClientServerDateUtils from './use-client-server-date-utils.js' -import useServerTimeOffset from './use-server-time-offset.js' - -jest.mock('./use-server-time-offset.js', () => ({ - __esModule: true, - default: jest.fn(() => 0), -})) - -/** - * Client timezone is set to Etc/UTC when starting the test runner - * See `package.json`: - * "test": "TZ=Etc/UTC d2-app-scripts test", - */ -describe('useClientServerDateUtils', () => { - it('provides a function to start with a client date', () => { - useServerTimeOffset.mockImplementation(() => 7200000) - - const { result } = renderHook(() => useClientServerDateUtils()) - const { fromClientDate } = result.current - const clientDate = new Date('2022-10-13 10:00:00') - const actual = fromClientDate(clientDate) - const expected = { - clientDate: new Date('2022-10-13 10:00:00'), - serverDate: new Date('2022-10-13 08:00:00'), - } - - expect(actual).toEqual(expected) - }) - - it('provides a function to start with a server date', () => { - useServerTimeOffset.mockImplementation(() => 7200000) - - const { result } = renderHook(() => useClientServerDateUtils()) - const { fromServerDate } = result.current - const serverDate = new Date('2022-10-13 10:00:00') - const actual = fromServerDate(serverDate) - const expected = { - clientDate: new Date('2022-10-13 12:00:00'), - serverDate: new Date('2022-10-13 10:00:00'), - } - - expect(actual).toEqual(expected) - }) -}) diff --git a/src/shared/date/use-client-server-date.js b/src/shared/date/use-client-server-date.js deleted file mode 100644 index 8a20135db..000000000 --- a/src/shared/date/use-client-server-date.js +++ /dev/null @@ -1,27 +0,0 @@ -import { useMemo, useState } from 'react' -import { getCurrentDate } from '../fixed-periods/index.js' -import useClientServerDateUtils from './use-client-server-date-utils.js' - -export default function useClientServerDate({ - clientDate: clientDateInput, - serverDate: serverDateInput, -} = {}) { - if (clientDateInput && serverDateInput) { - throw new Error( - '`useClientServerDate` does not accept both a client and a server date' - ) - } - - const { fromClientDate, fromServerDate } = useClientServerDateUtils() - const [{ clientDate, serverDate }] = useState(() => { - if (serverDateInput) { - return fromServerDate(serverDateInput) - } - - return fromClientDate( - clientDateInput ? clientDateInput : getCurrentDate() - ) - }) - - return useMemo(() => ({ clientDate, serverDate }), [clientDate, serverDate]) -} diff --git a/src/shared/date/use-client-server-date.test.js b/src/shared/date/use-client-server-date.test.js deleted file mode 100644 index 381388e21..000000000 --- a/src/shared/date/use-client-server-date.test.js +++ /dev/null @@ -1,39 +0,0 @@ -import { renderHook } from '@testing-library/react-hooks' -import useClientServerDate from './use-client-server-date.js' -import useServerTimeOffset from './use-server-time-offset.js' - -jest.mock('./use-server-time-offset.js', () => ({ - __esModule: true, - default: jest.fn(() => 0), -})) - -/** - * Client timezone is set to Etc/UTC when starting the test runner - * See `package.json`: - * "test": "TZ=Etc/UTC d2-app-scripts test", - */ -describe('useClientServerDate', () => { - it('throws an error when passing both a client- and a serverDate', () => { - const clientDate = new Date('2022-10-13 10:00:00') - const serverDate = new Date('2022-10-13 08:00:00') - const { result } = renderHook(() => - useClientServerDate({ clientDate, serverDate }) - ) - expect(result.error).toEqual( - new Error( - '`useClientServerDate` does not accept both a client and a server date' - ) - ) - }) - - it('should return the client- & serverDate', () => { - useServerTimeOffset.mockImplementation(() => 7200000) - const clientDate = new Date('2022-10-13 10:00:00') - const { result } = renderHook(() => useClientServerDate({ clientDate })) - - expect(result.current).toEqual({ - clientDate: new Date('2022-10-13 10:00:00'), - serverDate: new Date('2022-10-13 08:00:00'), - }) - }) -}) diff --git a/src/shared/date/use-server-time-offset.js b/src/shared/date/use-server-time-offset.js deleted file mode 100644 index e5d2e2d3c..000000000 --- a/src/shared/date/use-server-time-offset.js +++ /dev/null @@ -1,27 +0,0 @@ -import { useConfig } from '@dhis2/app-runtime' -import { useMemo } from 'react' -import { getCurrentDate } from '../fixed-periods/index.js' - -export default function useServerTimeOffset() { - const { systemInfo } = useConfig() - const { serverTimeZoneId: timeZone } = systemInfo - - return useMemo(() => { - const currentDate = getCurrentDate() - let serverLocaleString - try { - serverLocaleString = currentDate.toLocaleString('sv', { - timeZone, - }) - } catch (e) { - console.error(e) - console.info('Assuming no server/client time zone difference') - serverLocaleString = currentDate.toLocaleString('sv') - } - - const nowAtServerTimeZone = new Date(serverLocaleString) - const currentDateTime = currentDate.getTime() - const nowAtServerTimeZoneTime = nowAtServerTimeZone.getTime() - return currentDateTime - nowAtServerTimeZoneTime - }, [timeZone]) -} diff --git a/src/shared/date/use-server-time-offset.test.js b/src/shared/date/use-server-time-offset.test.js deleted file mode 100644 index 799a4b23c..000000000 --- a/src/shared/date/use-server-time-offset.test.js +++ /dev/null @@ -1,64 +0,0 @@ -import { useConfig } from '@dhis2/app-runtime' -import { renderHook } from '@testing-library/react-hooks' -import useServerTimeOffset from './use-server-time-offset.js' - -jest.mock('@dhis2/app-runtime', () => ({ - useConfig: jest.fn(() => ({ - systemInfo: { serverTimeZoneId: 'Etc/UTC' }, - })), -})) - -/** - * Client timezone is set to Etc/UTC when starting the test runner - * See `package.json`: - * "test": "TZ=Etc/UTC d2-app-scripts test", - */ -describe('useServerTimeOffset', () => { - it('return an offset of 0 when in the same timezone as the server', () => { - const timeZone = 'Etc/UTC' - const systemInfo = { serverTimeZoneId: timeZone } - useConfig.mockReturnValue({ systemInfo }) - - const expected = 0 - const { result } = renderHook(() => useServerTimeOffset()) - const actual = result.current - - expect(actual).toBe(expected) - }) - - it('returns a negative offset when ahead of server time', () => { - const timeZone = 'Etc/GMT-2' - const systemInfo = { serverTimeZoneId: timeZone } - useConfig.mockReturnValue({ systemInfo }) - - const expected = -7200000 - const { result } = renderHook(() => useServerTimeOffset()) - const actual = result.current - - expect(actual).toBe(expected) - }) - - it('returns a positive offset when behind of server time', () => { - const timeZone = 'Etc/GMT+2' - const systemInfo = { serverTimeZoneId: timeZone } - useConfig.mockReturnValue({ systemInfo }) - - const expected = 7200000 - const { result } = renderHook(() => useServerTimeOffset()) - const actual = result.current - - expect(actual).toBe(expected) - }) - - it('returns no offset when server time zone is invalid', () => { - const timeZone = 'Invalid time zone' - const systemInfo = { serverTimeZoneId: timeZone } - useConfig.mockReturnValue({ systemInfo }) - - const expected = 0 - const { result } = renderHook(() => useServerTimeOffset()) - const actual = result.current - - expect(actual).toBe(expected) - }) -}) diff --git a/src/shared/fixed-periods/get-current-date.js b/src/shared/fixed-periods/get-current-date.js deleted file mode 100644 index 241b6bf0d..000000000 --- a/src/shared/fixed-periods/get-current-date.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Initialise a Date instance with Date.now() for Jest mocking. - */ -export default function getCurrentDate() { - const currentDate = new Date(Date.now()) - - // This will ensure that there's no rounding issue when calculating the - // offset to the server time - currentDate.setMilliseconds(0) - - return currentDate -} diff --git a/src/shared/fixed-periods/index.js b/src/shared/fixed-periods/index.js deleted file mode 100644 index 130e672a5..000000000 --- a/src/shared/fixed-periods/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default as getCurrentDate } from './get-current-date.js' diff --git a/src/shared/index.js b/src/shared/index.js index f55855db1..97bae33ab 100644 --- a/src/shared/index.js +++ b/src/shared/index.js @@ -1,9 +1,8 @@ export * from './completion/index.js' export * from './constants.js' export * from './data-value-mutations/index.js' -export * from './date/index.js' export * from './error-message/index.js' -export * from './fixed-periods/index.js' +export * from './date/index.js' export * from './highlighted-field/index.js' export * from './locked-status/index.js' export * from './metadata/index.js' diff --git a/src/shared/locked-status/use-check-lock-status.js b/src/shared/locked-status/use-check-lock-status.js index f5e67b457..d9ca9be06 100644 --- a/src/shared/locked-status/use-check-lock-status.js +++ b/src/shared/locked-status/use-check-lock-status.js @@ -1,6 +1,12 @@ +import { useConfig } from '@dhis2/app-runtime' import { useEffect } from 'react' -import { useClientServerDateUtils } from '../date/index.js' -import { getCurrentDate } from '../fixed-periods/index.js' +import { + getNowInCalendarString, + addDaysToDateString, + isDateAGreaterThanDateB, + isDateALessThanDateB, + convertToIso8601ToString, +} from '../date/index.js' import { useMetadata, selectors } from '../metadata/index.js' import { usePeriod } from '../period/index.js' import { @@ -13,15 +19,14 @@ import { useUserInfo, userInfoSelectors } from '../use-user-info/index.js' import { LockedStates, BackendLockStatusMap } from './locked-states.js' import { useLockedContext } from './use-locked-context.js' -const DAY_MS = 24 * 60 * 60 * 1000 - /** Check for status relative to dataInputPeriods and expiryDays */ const getFrontendLockStatus = ({ dataSetId, metadata, selectedPeriod, - clientServerDateUtils: { fromServerDate, fromClientDate }, userCanEditExpired, + calendar, + timezone, }) => { if (!selectedPeriod) { return @@ -38,8 +43,14 @@ const getFrontendLockStatus = ({ return } - let clientLockDate - const currentDate = fromClientDate(getCurrentDate()) + let serverLockDateStringISO = null + + // this will be a date string corrected for client/server time zone differences + const currentDateString = getNowInCalendarString({ + calendar, + timezone, + long: true, + }) if (dataInputPeriods.length > 0) { const applicableDataInputPeriod = @@ -62,16 +73,25 @@ const getFrontendLockStatus = ({ // openingDate and closingDate can be undefined. // They are ISO dates without a timezone, so should be parsed // as "server dates" - const parsedOpeningDate = - openingDate && fromServerDate(new Date(openingDate)) - const parsedClosingDate = - closingDate && fromServerDate(new Date(closingDate)) + // date comparison (openingDate/closingDate dates are iso, currentDateString is system calendar) if ( (openingDate && - currentDate.serverDate < parsedOpeningDate.serverDate) || + isDateALessThanDateB( + { date: currentDateString, calendar }, + { date: openingDate, calendar: 'gregory' }, + { + inclusive: false, + } + )) || (closingDate && - currentDate.serverDate > parsedClosingDate.serverDate) + isDateAGreaterThanDateB( + { date: currentDateString, calendar }, + { date: closingDate, calendar: 'gregory' }, + { + inclusive: false, + } + )) ) { return { state: LockedStates.LOCKED_DATA_INPUT_PERIOD, @@ -80,33 +100,45 @@ const getFrontendLockStatus = ({ } // If we're here, the form isn't (yet) locked by the data input period. - // Set the clientLockDate to the input period's closing date (if this - // input period has one) before also checking expiry days. - // This might still be undefined, but that's okay - clientLockDate = parsedClosingDate?.clientDate + // this date is ISO + serverLockDateStringISO = closingDate } if (expiryDays > 0 && !userCanEditExpired) { // selectedPeriod.endDate is a string like '2023-02-20' -- this gets // converted to a UTC time by default, but adding 'T00:00' - // to make an ISO string makes Date() parse it in the local zone. - // I.e., the UTC time is adjusted to give us the right server clock - // time but in the LOCAL time zone. This is the "server date" we need - // for the `clientServerDateUtils`. - const serverEndDate = new Date(selectedPeriod.endDate + 'T00:00') - // Convert that to ms to add expiry days. - // Also add one day more because selectedPeriod.endDate is the START + + // 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). - const expiryDate = fromServerDate( - new Date(serverEndDate.getTime() + (expiryDays + 1) * DAY_MS) - ) + const expiryDateString = addDaysToDateString({ + startDateString: selectedPeriod.endDate, + days: expiryDays + 1, + calendar, + }) - if (currentDate.serverDate < expiryDate.serverDate) { + // date comparison (both in system calendar) + if ( + currentDateString && + expiryDateString && + isDateALessThanDateB( + { date: currentDateString, calendar }, + { date: expiryDateString, calendar }, + { + inclusive: false, + } + ) + ) { // Take the sooner of the two possible lock dates - clientLockDate = clientLockDate - ? new Date(Math.min(clientLockDate, expiryDate.clientDate)) - : expiryDate.clientDate + // 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 } + ) + ? 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. @@ -118,13 +150,14 @@ const getFrontendLockStatus = ({ // TODO: implement this full check on the front-end (TECH-1428) } - return { state: LockedStates.OPEN, lockDate: clientLockDate } + return { state: LockedStates.OPEN, lockDate: serverLockDateStringISO } } const isOrgUnitLocked = ({ orgUnitOpeningDateString, orgUnitClosedDateString, selectedPeriod, + calendar = 'gregory', }) => { // if period start or end is undefined or if both opening and closed date are undefined for org unit, skip check if ( @@ -136,23 +169,40 @@ const isOrgUnitLocked = ({ } // since all the dates are the same (server) time zone, we do not need to do server/client time zone adjustments - // for the purpose of these calculations, dates are effecitvely treated as days without hours - // for example, if org unit closing date is 2020-12-31, the period December 2020 should still be open for the org unit - const periodStartDate = new Date(selectedPeriod.startDate + 'T00:00') - const periodEndDate = new Date(selectedPeriod.endDate + 'T00:00') + // note: period dates are without times (assumed to be T00:00), but org units can have time information + const periodStartDate = selectedPeriod.startDate + const periodEndDate = selectedPeriod.endDate + + // date comparison (orgUnitDates: ISO, periodDates: system calendar) // if orgUnitOpeningDate exists, it must be earlier than the periodStartDate if (orgUnitOpeningDateString) { - const orgUnitOpeningDate = new Date(orgUnitOpeningDateString) - if (!(orgUnitOpeningDate <= periodStartDate)) { + if ( + !isDateALessThanDateB( + { date: orgUnitOpeningDateString, calendar: 'gregory' }, + { date: periodStartDate, calendar }, + { + calendar, + inclusive: true, + } + ) + ) { return true } } // if orgUnitClosedDate exists, it must be after the periodEndDate if (orgUnitClosedDateString) { - const orgUnitClosedDate = new Date(orgUnitClosedDateString) - if (!(orgUnitClosedDate >= periodEndDate)) { + if ( + !isDateAGreaterThanDateB( + { date: orgUnitClosedDateString, calendar: 'gregory' }, + { date: periodEndDate, calendar }, + { + calendar, + inclusive: true, + } + ) + ) { return true } } @@ -173,7 +223,6 @@ export const useCheckLockStatus = () => { const [periodId] = usePeriodId() const selectedPeriod = usePeriod(periodId) - const clientServerDateUtils = useClientServerDateUtils() const { data: metadata } = useMetadata() const dataValueSet = useDataValueSet() @@ -182,6 +231,10 @@ export const useCheckLockStatus = () => { const { data: userInfo } = useUserInfo() const userCanEditExpired = userInfoSelectors.getCanEditExpired(userInfo) + const { systemInfo = {} } = useConfig() + const { calendar = 'gregory', serverTimeZoneId: timezone = 'Etc/UTC' } = + systemInfo + useEffect(() => { // prefer lock status from backend, found in dataValueSet const backendLockStatus = @@ -202,6 +255,7 @@ export const useCheckLockStatus = () => { orgUnitOpeningDateString, orgUnitClosedDateString, selectedPeriod, + calendar, }) ) { setLockStatus({ state: LockedStates.LOCKED_ORGANISATION_UNIT }) @@ -214,8 +268,9 @@ export const useCheckLockStatus = () => { dataSetId, selectedPeriod, metadata, - clientServerDateUtils, userCanEditExpired, + calendar, + timezone, }) if (frontendLockStatus) { setLockStatus(frontendLockStatus) @@ -229,10 +284,11 @@ export const useCheckLockStatus = () => { dataSetId, orgUnitOpeningDateString, orgUnitClosedDateString, - clientServerDateUtils, userCanEditExpired, dataValueSet.data?.lockStatus, setLockStatus, selectedPeriod, + calendar, + timezone, ]) } diff --git a/src/shared/locked-status/use-check-lock-status.test.js b/src/shared/locked-status/use-check-lock-status.test.js new file mode 100644 index 000000000..c35df6431 --- /dev/null +++ b/src/shared/locked-status/use-check-lock-status.test.js @@ -0,0 +1,518 @@ +import { useConfig } from '@dhis2/app-runtime' +import { renderHook } from '@testing-library/react-hooks' +import { useMetadata } from '../metadata/use-metadata.js' +import { usePeriod } from '../period/index.js' +import { useDataValueSet } from '../use-data-value-set/use-data-value-set.js' +import { useOrgUnit } from '../use-org-unit/use-organisation-unit.js' +import { useUserInfo } from '../use-user-info/use-user-info.js' +import { useCheckLockStatus } from './use-check-lock-status.js' +import * as useLockedContextModule from './use-locked-context.js' + +jest.mock('@dhis2/app-runtime', () => ({ + ...jest.requireActual('@dhis2/app-runtime'), + useConfig: jest.fn(() => ({ + systemInfo: { serverTimeZoneId: 'Etc/UTC', calendar: 'gregory' }, + })), +})) + +jest.mock('../use-context-selection/use-context-selection.js', () => ({ + ...jest.requireActual('../use-context-selection/use-context-selection.js'), + useDataSetId: jest.fn(() => ['data-set-id']), + usePeriodId: jest.fn(() => ['period-id']), +})) + +jest.mock('../use-org-unit/use-organisation-unit.js', () => ({ + ...jest.requireActual('../use-org-unit/use-organisation-unit.js'), + useOrgUnit: jest.fn(() => ({ + data: { + openingDate: '1970-01-01', + closedDate: '2070-01-01', + }, + })), +})) + +jest.mock('../period/index.js', () => ({ + ...jest.requireActual('../period/index.js'), + usePeriod: jest.fn(() => ({ + id: 'period-id', + startDate: '2023-04-01', + endDate: '2023-04-30', + })), +})) + +jest.mock('../metadata/use-metadata.js', () => ({ + ...jest.requireActual('../metadata/use-metadata.js'), + useMetadata: jest.fn(() => ({ + data: { + dataSets: { + 'data-set-id': { + id: 'data-set-id', + expiryDays: 10, + dataInputPeriods: [], + }, + }, + }, + })), +})) + +jest.mock('../use-data-value-set/use-data-value-set.js', () => ({ + ...jest.requireActual('../use-data-value-set/use-data-value-set.js'), + useDataValueSet: jest.fn(() => ({ data: { lockStatus: null } })), +})) + +jest.mock('../use-user-info/use-user-info.js', () => ({ + ...jest.requireActual('../use-user-info/use-user-info.js'), + useUserInfo: jest.fn(() => ({ data: { authorities: ['ALL'] } })), +})) + +describe('useCheckLockStatus', () => { + afterEach(() => { + jest.clearAllMocks() + jest.useRealTimers() + }) + + it('uses backend status if available', () => { + const setLockedStatusMocked = jest.fn() + jest.spyOn( + useLockedContextModule, + 'useLockedContext' + ).mockImplementation(() => ({ setLockStatus: setLockedStatusMocked })) + useDataValueSet.mockImplementationOnce(() => ({ + data: { + lockStatus: 'LOCKED', + }, + })) + renderHook(() => useCheckLockStatus()) + expect(setLockedStatusMocked).toHaveBeenCalledWith({ + state: 'Locked_expiry_days', + }) + }) + + it('locks if org unit opens after period starts', () => { + const setLockedStatusMocked = jest.fn() + jest.spyOn( + useLockedContextModule, + 'useLockedContext' + ).mockImplementation(() => ({ setLockStatus: setLockedStatusMocked })) + useOrgUnit.mockImplementationOnce(() => ({ + data: { + openingDate: '2024-01-03', + }, + })) + usePeriod.mockImplementationOnce(() => ({ + id: 'period-id', + startDate: '2024-01-01', + endDate: '2024-01-31', + })) + + renderHook(() => useCheckLockStatus()) + expect(setLockedStatusMocked).toHaveBeenCalledWith({ + state: 'Locked_organisation_unit', + }) + }) + + it('locks if org unit closes before period ends', () => { + const setLockedStatusMocked = jest.fn() + jest.spyOn( + useLockedContextModule, + 'useLockedContext' + ).mockImplementation(() => ({ setLockStatus: setLockedStatusMocked })) + useOrgUnit.mockImplementationOnce(() => ({ + data: { + closedDate: '2024-01-28', + }, + })) + usePeriod.mockImplementationOnce(() => ({ + id: 'period-id', + startDate: '2024-01-01', + endDate: '2024-01-31', + })) + + renderHook(() => useCheckLockStatus()) + expect(setLockedStatusMocked).toHaveBeenCalledWith({ + state: 'Locked_organisation_unit', + }) + }) + + it('locks if org unit closes before period ends (ethiopian calendar)', () => { + const setLockedStatusMocked = jest.fn() + jest.spyOn( + useLockedContextModule, + 'useLockedContext' + ).mockImplementation(() => ({ setLockStatus: setLockedStatusMocked })) + 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: '2024-09-10', + }, + })) + usePeriod.mockImplementationOnce(() => ({ + id: 'period-id', + startDate: '2016-13-01', + endDate: '2017-01-02', + })) + + renderHook(() => useCheckLockStatus()) + expect(setLockedStatusMocked).toHaveBeenCalledWith({ + state: 'Locked_organisation_unit', + }) + }) + + it('locks for data input period if there are data input periods, but none for the selected period', () => { + const setLockedStatusMocked = jest.fn() + jest.spyOn( + useLockedContextModule, + 'useLockedContext' + ).mockImplementation(() => ({ setLockStatus: setLockedStatusMocked })) + usePeriod.mockImplementationOnce(() => ({ + id: 'period-id', + startDate: '2024-01-01', + endDate: '2024-01-31', + })) + useMetadata.mockImplementationOnce(() => ({ + data: { + dataSets: { + 'data-set-id': { + id: 'data-set-id', + expiryDays: 10, + dataInputPeriods: [ + { + period: { + id: 'another-period-id', + }, + openingDate: '2025-01-01T00:00:00', + closingDate: '2025-01-31T00:00:00', + }, + ], + }, + }, + }, + })) + + renderHook(() => useCheckLockStatus()) + expect(setLockedStatusMocked).toHaveBeenCalledWith({ + lockDate: null, + state: 'Locked_data_input_period', + }) + }) + + it('locks for data input period if browser date/time is not within data input period', () => { + jest.useFakeTimers('modern') + jest.setSystemTime(new Date('2026-10-15')) + const setLockedStatusMocked = jest.fn() + jest.spyOn( + useLockedContextModule, + 'useLockedContext' + ).mockImplementation(() => ({ setLockStatus: setLockedStatusMocked })) + usePeriod.mockImplementationOnce(() => ({ + id: 'period-id', + startDate: '2024-01-01', + endDate: '2024-01-31', + })) + useMetadata.mockImplementationOnce(() => ({ + data: { + dataSets: { + 'data-set-id': { + id: 'data-set-id', + expiryDays: 10, + dataInputPeriods: [ + { + period: { + id: 'period-id', + }, + openingDate: '2025-01-01T00:00:00', + closingDate: '2025-01-31T00:00:00', + }, + ], + }, + }, + }, + })) + + renderHook(() => useCheckLockStatus()) + expect(setLockedStatusMocked).toHaveBeenCalledWith({ + lockDate: null, + state: 'Locked_data_input_period', + }) + }) + + it('locks for data input period if browser date/time is not within data input period (time zone adjustment)', () => { + // system time is 30 Jan 23:00 UTC, server is in Kampala (UTC+3), so 31 Jan 02:00 + jest.useFakeTimers('modern') + jest.setSystemTime(new Date('2025-01-30T23:00:00')) + useConfig.mockImplementationOnce(() => ({ + systemInfo: { + calendar: 'gregory', + serverTimeZoneId: 'Africa/Kampala', + }, + })) + const setLockedStatusMocked = jest.fn() + jest.spyOn( + useLockedContextModule, + 'useLockedContext' + ).mockImplementation(() => ({ setLockStatus: setLockedStatusMocked })) + usePeriod.mockImplementationOnce(() => ({ + id: 'period-id', + startDate: '2024-01-01', + endDate: '2024-01-31', + })) + useMetadata.mockImplementationOnce(() => ({ + data: { + dataSets: { + 'data-set-id': { + id: 'data-set-id', + expiryDays: 10, + dataInputPeriods: [ + { + period: { + id: 'period-id', + }, + openingDate: '2025-01-01T00:00:00', + closingDate: '2025-01-31T00:00:00', + }, + ], + }, + }, + }, + })) + + renderHook(() => useCheckLockStatus()) + expect(setLockedStatusMocked).toHaveBeenCalledWith({ + lockDate: null, + state: 'Locked_data_input_period', + }) + }) + + it('does not lock for data input period if browser date/time is within data input period', () => { + jest.useFakeTimers('modern') + jest.setSystemTime(new Date('2025-01-15')) + const setLockedStatusMocked = jest.fn() + jest.spyOn( + useLockedContextModule, + 'useLockedContext' + ).mockImplementation(() => ({ setLockStatus: setLockedStatusMocked })) + usePeriod.mockImplementationOnce(() => ({ + id: 'period-id', + startDate: '2024-01-01', + endDate: '2024-01-31', + })) + useMetadata.mockImplementationOnce(() => ({ + data: { + dataSets: { + 'data-set-id': { + id: 'data-set-id', + expiryDays: 10, + dataInputPeriods: [ + { + period: { + id: 'period-id', + }, + openingDate: '2025-01-01T00:00:00', + closingDate: '2025-01-31T00:00:00', + }, + ], + }, + }, + }, + })) + + renderHook(() => useCheckLockStatus()) + expect(setLockedStatusMocked).toHaveBeenCalledWith({ + lockDate: '2025-01-31T00:00:00', + state: 'Open', + }) + }) + + it('does not lock for data input period if browser date/time is within data input period', () => { + jest.useFakeTimers('modern') + jest.setSystemTime(new Date('2025-01-15')) + const setLockedStatusMocked = jest.fn() + jest.spyOn( + useLockedContextModule, + 'useLockedContext' + ).mockImplementation(() => ({ setLockStatus: setLockedStatusMocked })) + usePeriod.mockImplementationOnce(() => ({ + id: 'period-id', + startDate: '2024-01-01', + endDate: '2024-01-31', + })) + useMetadata.mockImplementationOnce(() => ({ + data: { + dataSets: { + 'data-set-id': { + id: 'data-set-id', + expiryDays: 10, + dataInputPeriods: [ + { + period: { + id: 'period-id', + }, + openingDate: '2025-01-01T00:00:00', + closingDate: '2025-01-31T00:00:00', + }, + ], + }, + }, + }, + })) + + renderHook(() => useCheckLockStatus()) + expect(setLockedStatusMocked).toHaveBeenCalledWith({ + lockDate: '2025-01-31T00:00:00', + state: 'Open', + }) + }) + + it('sets a lock date, accounting for expiry days (if releveant), but leaves form open', () => { + jest.useFakeTimers('modern') + jest.setSystemTime(new Date('2024-02-04')) + useUserInfo.mockImplementationOnce(() => ({ + data: { authorities: [] }, + })) + const setLockedStatusMocked = jest.fn() + jest.spyOn( + useLockedContextModule, + 'useLockedContext' + ).mockImplementation(() => ({ setLockStatus: setLockedStatusMocked })) + usePeriod.mockImplementationOnce(() => ({ + id: 'period-id', + startDate: '2024-01-01', + endDate: '2024-01-31', + })) + useMetadata.mockImplementationOnce(() => ({ + data: { + dataSets: { + 'data-set-id': { + id: 'data-set-id', + expiryDays: 10, + dataInputPeriods: [], + }, + }, + }, + })) + + renderHook(() => useCheckLockStatus()) + expect(setLockedStatusMocked).toHaveBeenCalledWith({ + lockDate: '2024-02-11', + state: 'Open', + }) + }) + + it('sets end of data input period as lock date if less than period end + expiry days', () => { + jest.useFakeTimers('modern') + jest.setSystemTime(new Date('2024-02-04')) + useUserInfo.mockImplementationOnce(() => ({ + data: { authorities: [] }, + })) + const setLockedStatusMocked = jest.fn() + jest.spyOn( + useLockedContextModule, + 'useLockedContext' + ).mockImplementation(() => ({ setLockStatus: setLockedStatusMocked })) + usePeriod.mockImplementationOnce(() => ({ + id: 'period-id', + startDate: '2024-01-01', + endDate: '2024-01-31', + })) + useMetadata.mockImplementationOnce(() => ({ + data: { + dataSets: { + 'data-set-id': { + id: 'data-set-id', + expiryDays: 90, + dataInputPeriods: [ + { + period: { + id: 'period-id', + }, + openingDate: '2024-02-01T00:00:00', + closingDate: '2024-02-29T00:00:00', + }, + ], + }, + }, + }, + })) + + renderHook(() => useCheckLockStatus()) + expect(setLockedStatusMocked).toHaveBeenCalledWith({ + lockDate: '2024-02-29T00:00:00', + state: 'Open', + }) + }) + + it('does not warn about expiry days if user has authority to edit expired periods', () => { + jest.useFakeTimers('modern') + jest.setSystemTime(new Date('2024-02-04')) + useUserInfo.mockImplementationOnce(() => ({ + data: { authorities: ['F_EDIT_EXPIRED'] }, + })) + const setLockedStatusMocked = jest.fn() + jest.spyOn( + useLockedContextModule, + 'useLockedContext' + ).mockImplementation(() => ({ setLockStatus: setLockedStatusMocked })) + usePeriod.mockImplementationOnce(() => ({ + id: 'period-id', + startDate: '2024-01-01', + endDate: '2024-01-31', + })) + useMetadata.mockImplementationOnce(() => ({ + data: { + dataSets: { + 'data-set-id': { + id: 'data-set-id', + expiryDays: 90, + dataInputPeriods: [], + }, + }, + }, + })) + + renderHook(() => useCheckLockStatus()) + expect(setLockedStatusMocked).toHaveBeenCalledWith({ + lockDate: null, + state: 'Open', + }) + }) + + // this test confirms that we do not have functionality to add days to non-gregory days + // i.e., we'd like this test to fail eventually when we add ability to add days to non-gregory dates + it('does not set lockDate based on expiry days if calendar is non-gregory ', () => { + jest.useFakeTimers('modern') + jest.setSystemTime(new Date('2024-02-04')) + useConfig.mockImplementationOnce(() => ({ + systemInfo: { calendar: 'ethiopian', serverTimeZoneId: 'Etc/UTC' }, + })) + const setLockedStatusMocked = jest.fn() + jest.spyOn( + useLockedContextModule, + 'useLockedContext' + ).mockImplementation(() => ({ setLockStatus: setLockedStatusMocked })) + usePeriod.mockImplementationOnce(() => ({ + id: 'period-id', + startDate: '2024-01-01', + endDate: '2024-01-31', + })) + useMetadata.mockImplementationOnce(() => ({ + data: { + dataSets: { + 'data-set-id': { + id: 'data-set-id', + expiryDays: 10, + dataInputPeriods: [], + }, + }, + }, + })) + + renderHook(() => useCheckLockStatus()) + expect(setLockedStatusMocked).toHaveBeenCalledWith({ + lockDate: null, + state: 'Open', + }) + }) +}) diff --git a/src/shared/metadata/selectors.js b/src/shared/metadata/selectors.js index e8b01022c..bd56421e0 100644 --- a/src/shared/metadata/selectors.js +++ b/src/shared/metadata/selectors.js @@ -4,6 +4,7 @@ import { } from '@dhis2/multi-calendar-dates' import { createCachedSelector } from 're-reselect' import { createSelector } from 'reselect' +import { isDateAGreaterThanDateB, isDateALessThanDateB } from '../date/index.js' import { cartesian } from '../utils.js' // Helper to group array items by an identifier @@ -436,23 +437,48 @@ const isOptionWithinPeriod = ({ periodStartDate, periodEndDate, categoryOption, + calendar = 'gregory', }) => { // option has not start and end dates if (!categoryOption.startDate && !categoryOption.endDate) { return true } + // dates are all server dates so we can ignore time zone adjustment + // use string comparison for time being to better handle non-gregory dates + // date comparison + if (categoryOption.startDate) { - const startDate = new Date(categoryOption.startDate) - if (periodStartDate < startDate) { + const categoryOptionStartDate = categoryOption.startDate + if ( + // 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 } } if (categoryOption.endDate) { - const endDate = new Date(categoryOption.endDate) - if (periodEndDate > endDate) { + const categoryOptionEndDate = categoryOption.endDate + // date comparison (periodEndDate: system calendar, categoryOptionEndDate: ISO) + if ( + isDateAGreaterThanDateB( + { date: periodEndDate, calendar }, + { date: categoryOptionEndDate, calendar: 'gregory' }, + { + calendar, + inclusive: false, + } + ) + ) { // option end date is before period end date return false } @@ -486,11 +512,8 @@ export const getCategoriesWithOptionsWithinPeriodWithOrgUnit = getDataSetById, (_, __, periodId) => periodId, (_, __, ___, orgUnitId) => orgUnitId, - (_, __, ___, ____, fromClientDate) => fromClientDate, - (metadata, dataSet, periodId, orgUnitId, fromClientDate) => { - // @TODO(calendar) - const calendar = 'gregory' - + (_, __, ___, ____, calendar) => calendar, + (metadata, dataSet, periodId, orgUnitId, calendar = 'gregory') => { if (!dataSet?.id || !periodId) { return [] } @@ -519,16 +542,12 @@ export const getCategoriesWithOptionsWithinPeriodWithOrgUnit = if (!period) { return [] } - const clientPeriodStartDate = new Date(period.startDate) - const periodStartDate = fromClientDate( - clientPeriodStartDate - ).serverDate - - const [followingPeriod] = getAdjacentFixedPeriods({ - period, - calendar, - steps: 1, - }) + + const periodStartDate = period.startDate + + // we want to check if option's end date with openPeriodsAfterCoEndDate adjustment is after period end date + // this is difficult to calculate, so we instead check if option's end date is after (period end date - optionPeriodsAfterCoEndDate) + // that is, we adjust the period end date backwards instead of adjusting the category option end date forward const openPeriodsAfterCoEndDate = Math.max( dataSet?.openPeriodsAfterCoEndDate || 0, @@ -536,20 +555,19 @@ export const getCategoriesWithOptionsWithinPeriodWithOrgUnit = ) const previousPeriodsCount = openPeriodsAfterCoEndDate * -1 + + // we want 1 day less than the first previous period's start date, + // which is the same as the end date of 1 period before the first period + // therefore we subtract an additional 1 for steps const previousPeriods = previousPeriodsCount ? getAdjacentFixedPeriods({ - steps: previousPeriodsCount, + steps: previousPeriodsCount - 1, period, - calendar: 'gregory', + calendar, }) : [] - const periodEndDate = new Date( - previousPeriods[0]?.startDate || followingPeriod.startDate - ) - - // remove 1 day because endDate is 1 less than subsequent startDate - periodEndDate.setDate(periodEndDate.getDate() - 1) + const periodEndDate = previousPeriods[0]?.endDate || period.endDate return relevantCategoriesWithOptions.map((category) => ({ ...category, @@ -559,6 +577,7 @@ export const getCategoriesWithOptionsWithinPeriodWithOrgUnit = periodStartDate, periodEndDate, categoryOption, + calendar, }) && isOptionAssignedToOrgUnit({ categoryOption, @@ -586,9 +605,9 @@ export const getApplicableDataInputPeriod = createCachedSelector( } return ( - dataSet.dataInputPeriods.filter( - (dip) => dip?.period?.id === periodId - )[0] || null + dataSet.dataInputPeriods.filter((dip) => { + return dip?.period?.id === periodId + })[0] || null ) } )((dataSetId, periodId) => `${dataSetId}:${periodId}`) diff --git a/src/shared/metadata/selectors.test.js b/src/shared/metadata/selectors.test.js index 018d1bec6..effb9d1dc 100644 --- a/src/shared/metadata/selectors.test.js +++ b/src/shared/metadata/selectors.test.js @@ -1,5 +1,3 @@ -import { renderHook } from '@testing-library/react-hooks' -import { useClientServerDateUtils, useServerTimeOffset } from '../date/index.js' import { getSectionByDataSetIdAndSectionId, getNrOfColumnsInCategoryCombo, @@ -38,11 +36,6 @@ import { getSections, } from './selectors.js' -jest.mock('../date/use-server-time-offset.js', () => ({ - __esModule: true, - default: jest.fn(() => 0), -})) - /* * * simple selectors @@ -1376,9 +1369,6 @@ describe('getCategoryOptionsByCategoryOptionComboId', () => { }) describe('getCategoriesWithOptionsWithinPeriodWithOrgUnit', () => { - useServerTimeOffset.mockImplementation(() => 7200000) - const { result } = renderHook(() => useClientServerDateUtils()) - it('should return all category options if none have end dates', () => { const datasetid = 'dataset-id-1a' const periodid = '202201' @@ -1419,6 +1409,8 @@ describe('getCategoryOptionsByCategoryOptionComboId', () => { }, } + const calendar = 'gregory' + const expected = [ { categoryOptions: [ @@ -1439,7 +1431,7 @@ describe('getCategoryOptionsByCategoryOptionComboId', () => { datasetid, periodid, orgunitid, - result.current.fromClientDate + calendar ) expect(actual).toEqual(expected) @@ -1485,6 +1477,8 @@ describe('getCategoryOptionsByCategoryOptionComboId', () => { }, } + const calendar = 'gregory' + const expected = [ { categoryOptions: [{ id: 'cat-id-c' }], id: 'co-id-letter' }, { @@ -1501,7 +1495,7 @@ describe('getCategoryOptionsByCategoryOptionComboId', () => { datasetid, periodid, orgunitid, - result.current.fromClientDate + calendar ) expect(actual).toEqual(expected) @@ -1548,6 +1542,8 @@ describe('getCategoryOptionsByCategoryOptionComboId', () => { }, } + const calendar = 'gregory' + const expected = [ { categoryOptions: [ @@ -1570,7 +1566,7 @@ describe('getCategoryOptionsByCategoryOptionComboId', () => { datasetid, periodid, orgunitid, - result.current.fromClientDate + calendar ) expect(actual).toEqual(expected) @@ -1616,6 +1612,8 @@ describe('getCategoryOptionsByCategoryOptionComboId', () => { }, } + const calendar = 'gregory' + const expected = [ { categoryOptions: [ @@ -1639,7 +1637,7 @@ describe('getCategoryOptionsByCategoryOptionComboId', () => { datasetid, periodid, orgunitid, - result.current.fromClientDate + calendar ) expect(actual).toEqual(expected) @@ -1697,6 +1695,8 @@ describe('getCategoryOptionsByCategoryOptionComboId', () => { }, } + const calendar = 'gregory' + const expected = [ { categoryOptions: [], @@ -1716,7 +1716,7 @@ describe('getCategoryOptionsByCategoryOptionComboId', () => { datasetid, periodid, orgunitid, - result.current.fromClientDate + calendar ) expect(actual).toEqual(expected) diff --git a/src/shared/period/use-period.js b/src/shared/period/use-period.js index 6eb124587..58665ec12 100644 --- a/src/shared/period/use-period.js +++ b/src/shared/period/use-period.js @@ -1,19 +1,28 @@ +import { useConfig } from '@dhis2/app-runtime' import { createFixedPeriodFromPeriodId } from '@dhis2/multi-calendar-dates' import { useMemo } from 'react' +import { useUserInfo } from '../use-user-info/index.js' export default function usePeriod(periodId) { - // @TODO(calendar) - const calendar = 'gregory' + const { systemInfo = {} } = useConfig() + const { calendar = 'gregory' } = systemInfo + const { data: userInfo } = useUserInfo() + const { keyUiLocale: locale } = userInfo.settings ?? {} + return useMemo(() => { if (!periodId) { return null } try { - return createFixedPeriodFromPeriodId({ periodId, calendar }) + return createFixedPeriodFromPeriodId({ + periodId, + calendar, + locale, + }) } catch (e) { console.error(e) return null } - }, [periodId]) + }, [periodId, calendar, locale]) } diff --git a/src/shared/use-context-selection/use-manage-inter-param-dependencies.js b/src/shared/use-context-selection/use-manage-inter-param-dependencies.js index 475c5ad04..7217d41e2 100644 --- a/src/shared/use-context-selection/use-manage-inter-param-dependencies.js +++ b/src/shared/use-context-selection/use-manage-inter-param-dependencies.js @@ -1,8 +1,7 @@ -import { useAlert } from '@dhis2/app-runtime' +import { useAlert, useConfig } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' import { createFixedPeriodFromPeriodId } from '@dhis2/multi-calendar-dates' import { useEffect, useState } from 'react' -import { useClientServerDateUtils } from '../date/index.js' import { useMetadata, selectors } from '../metadata/index.js' import { periodTypesMapping } from '../period/index.js' import { filterObject } from '../utils.js' @@ -22,10 +21,7 @@ export default function useManageInterParamDependencies() { useHandleSectionFilterChange() } -function convertPeriodIdToPeriodType(periodId) { - // @TODO(calendar) - const calendar = 'gregory' - +function convertPeriodIdToPeriodType(periodId, calendar) { if (!periodId) { return '' } @@ -45,9 +41,11 @@ function convertPeriodIdToPeriodType(periodId) { } function useHandleDataSetIdChange() { + const { systemInfo = {} } = useConfig() + const { calendar = 'gregory' } = systemInfo const [periodId, setPeriodId] = usePeriodId() const [previousPeriodType, setPreviousPeriodType] = useState(() => - convertPeriodIdToPeriodType(periodId) + convertPeriodIdToPeriodType(periodId, calendar) ) const [attributeOptionComboSelection, setAttributeOptionComboSelection] = useAttributeOptionComboSelection() @@ -129,15 +127,16 @@ function useHandleOrgUnitIdChange() { const [prevOrgUnitId, setPrevOrgUnitId] = useState(orgUnitId) const [attributeOptionComboSelection, setAttributeOptionComboSelection] = useAttributeOptionComboSelection() + const { systemInfo = {} } = useConfig() + const { calendar = 'gregory' } = systemInfo - const clientServerDateUtils = useClientServerDateUtils() const relevantCategoriesWithOptions = selectors.getCategoriesWithOptionsWithinPeriodWithOrgUnit( metadata, dataSetId, periodId, orgUnitId, - clientServerDateUtils.fromClientDate + calendar ) useEffect(() => { @@ -211,14 +210,15 @@ function useHandlePeriodIdChange() { const [orgUnitId] = useOrgUnitId() const [periodId] = usePeriodId() const [prevPeriodId, setPrevPeriodId] = useState(periodId) - const clientServerDateUtils = useClientServerDateUtils() + const { systemInfo = {} } = useConfig() + const { calendar = 'gregory' } = systemInfo const relevantCategoriesWithOptions = selectors.getCategoriesWithOptionsWithinPeriodWithOrgUnit( metadata, dataSetId, periodId, orgUnitId, - clientServerDateUtils.fromClientDate + calendar ) useEffect(() => { 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 d913000d7..a5621b7e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2609,20 +2609,22 @@ resolved "https://registry.yarnpkg.com/@dhis2/cypress-plugins/-/cypress-plugins-10.0.1.tgz#735ccfbbdfe4cc9ad102decf9a71df2b5b56ac2d" integrity sha512-siWpoJlZOnrEcfZhXP4nDe9Uh9lQtjPr6/HI+16ORt9W2PPffp6e78Xc6hMM94j3hwl23Czu0LXSfUcZdymL6Q== -"@dhis2/d2-i18n@^1.1.1": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@dhis2/d2-i18n/-/d2-i18n-1.1.1.tgz#acaca32cd00b60fd6b6f1dee571f2817a50e243c" - integrity sha512-X0jOCIKPaYv/2z0/sdkEvcbRiYu5o1FrOwvitiS6aKFxSL/GJ872I+UdHwpWJtL+yM7Z8E1epljazW0LnHUz0Q== +"@dhis2/d2-i18n@1.1.3", "@dhis2/d2-i18n@^1.1.1", "@dhis2/d2-i18n@^1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@dhis2/d2-i18n/-/d2-i18n-1.1.3.tgz#ad73030f7cfceeed1b5bcaad86a9b336130bdfb1" + integrity sha512-vOu6RDNumOJM396mHt35bETk9ai9b6XJyAwlUy1HstUZNvfET61F8rjCmMuXZU6zJ8ELux8kMFqlH8IG0vDJmA== dependencies: + "@types/i18next" "^11.9.0" i18next "^10.3" moment "^2.24.0" -"@dhis2/multi-calendar-dates@1.0.2", "@dhis2/multi-calendar-dates@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@dhis2/multi-calendar-dates/-/multi-calendar-dates-1.1.0.tgz#897d27eefa70eb60d3e8e72348f09ee739755ebb" - integrity sha512-VAuq3dIcI42mt1pCL3Xp9pebAOpUEz2ngShTIPD1ll7oMeanRAXkweBT1gburc5W3SfD6jbS8GuwemJ4zg4d3Q== +"@dhis2/multi-calendar-dates@1.0.2", "@dhis2/multi-calendar-dates@^1.3.1": + 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: - "@js-temporal/polyfill" "^0.4.2" + "@dhis2/d2-i18n" "^1.1.3" + "@js-temporal/polyfill" "0.4.3" classnames "^2.3.2" "@dhis2/prop-types@^3.0.0-beta.1", "@dhis2/prop-types@^3.1.2": @@ -3257,7 +3259,7 @@ "@jridgewell/resolve-uri" "3.1.0" "@jridgewell/sourcemap-codec" "1.4.14" -"@js-temporal/polyfill@^0.4.2": +"@js-temporal/polyfill@0.4.3": version "0.4.3" resolved "https://registry.yarnpkg.com/@js-temporal/polyfill/-/polyfill-0.4.3.tgz#e8f8cf86745eb5050679c46a5ebedb9a9cc1f09b" integrity sha512-6Fmjo/HlkyVCmJzAPnvtEWlcbQUSRhi8qlN9EtJA/wP7FqXsevLLrlojR44kzNzrRkpf7eDJ+z7b4xQD/Ycypw== @@ -3819,6 +3821,11 @@ dependencies: "@types/node" "*" +"@types/i18next@^11.9.0": + version "11.9.3" + resolved "https://registry.yarnpkg.com/@types/i18next/-/i18next-11.9.3.tgz#04d84c6539908ad69665d26d8967f942d1638550" + integrity sha512-snM7bMKy6gt7UYdpjsxycqSCAy0fr2JVPY0B8tJ2vp9bN58cE7C880k20PWFM4KXxQ3KsstKM8DLCawGCIH0tg== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44"