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"