From 97a524d949532d30f94acbeab1ec01d6888a8e09 Mon Sep 17 00:00:00 2001 From: LinHuiqing Date: Fri, 31 Mar 2023 02:40:28 -0400 Subject: [PATCH 01/15] fix: remove timezone from datepicker --- .../DatePicker/DatePickerContext.tsx | 31 +++++------------ frontend/src/components/DatePicker/types.ts | 6 ---- .../DateRangePickerContext.tsx | 34 ++++++------------- 3 files changed, 20 insertions(+), 51 deletions(-) diff --git a/frontend/src/components/DatePicker/DatePickerContext.tsx b/frontend/src/components/DatePicker/DatePickerContext.tsx index 10b9fc14b4..37f3975b78 100644 --- a/frontend/src/components/DatePicker/DatePickerContext.tsx +++ b/frontend/src/components/DatePicker/DatePickerContext.tsx @@ -20,7 +20,6 @@ import { useMultiStyleConfig, } from '@chakra-ui/react' import { format, isValid, parse } from 'date-fns' -import { zonedTimeToUtc } from 'date-fns-tz' import { ThemeColorScheme } from '~theme/foundations/colours' import { useIsMobile } from '~hooks/useIsMobile' @@ -84,7 +83,6 @@ const useProvideDatePicker = ({ isReadOnly: isReadOnlyProp, isRequired: isRequiredProp, isInvalid: isInvalidProp, - timeZone = 'UTC', locale, isDateUnavailable, allowManualInput = true, @@ -120,9 +118,9 @@ const useProvideDatePicker = ({ const formatInputValue = useCallback( (date: Date | null) => { if (!date || !isValid(date)) return '' - return format(zonedTimeToUtc(date, timeZone), displayFormat, { locale }) + return format(date, displayFormat, { locale }) }, - [displayFormat, locale, timeZone], + [displayFormat, locale], ) // What is rendered as a string in the input according to given display format. @@ -142,11 +140,7 @@ const useProvideDatePicker = ({ const handleInputBlur: FocusEventHandler = useCallback( (e) => { - const date = parse( - internalInputValue, - dateFormat, - zonedTimeToUtc(new Date(), timeZone), - ) + const date = parse(internalInputValue, dateFormat, new Date()) // Clear if input is invalid on blur if invalid dates are not allowed. if (!allowInvalidDates && !isValid(date)) { setInternalValue(null) @@ -161,7 +155,6 @@ const useProvideDatePicker = ({ onBlur, setInternalInputValue, setInternalValue, - timeZone, ], ) @@ -179,12 +172,11 @@ const useProvideDatePicker = ({ const handleDateChange = useCallback( (date: Date | null) => { - const zonedDate = date ? zonedTimeToUtc(date, timeZone) : null - if (allowInvalidDates || isValid(zonedDate) || !zonedDate) { - setInternalValue(zonedDate) + if (allowInvalidDates || isValid(date) || !date) { + setInternalValue(date) } - if (zonedDate) { - setInternalInputValue(format(zonedDate, displayFormat, { locale })) + if (date) { + setInternalInputValue(format(date, displayFormat, { locale })) } else { setInternalInputValue('') } @@ -198,23 +190,18 @@ const useProvideDatePicker = ({ locale, setInternalInputValue, setInternalValue, - timeZone, ], ) const handleInputChange = useCallback( (event: React.ChangeEvent) => { - const date = parse( - event.target.value, - dateFormat, - zonedTimeToUtc(new Date(), timeZone), - ) + const date = parse(event.target.value, dateFormat, new Date()) setInternalInputValue(event.target.value) if (isValid(date)) { setInternalValue(date) } }, - [dateFormat, setInternalInputValue, setInternalValue, timeZone], + [dateFormat, setInternalInputValue, setInternalValue], ) const handleInputClick: MouseEventHandler = useCallback( diff --git a/frontend/src/components/DatePicker/types.ts b/frontend/src/components/DatePicker/types.ts index fbec861fa8..37754857c6 100644 --- a/frontend/src/components/DatePicker/types.ts +++ b/frontend/src/components/DatePicker/types.ts @@ -33,10 +33,4 @@ export interface DatePickerBaseProps refocusOnClose?: boolean /** date-fns's Locale of the date to be applied if provided. */ locale?: Locale - /** - * Time zone of date created. - * Defaults to `'UTC'`. - * Accepts all possible `Intl.Locale.prototype.timeZones` values - */ - timeZone?: string } diff --git a/frontend/src/components/DateRangePicker/DateRangePickerContext.tsx b/frontend/src/components/DateRangePicker/DateRangePickerContext.tsx index 0f974a41f8..5fd63ec28b 100644 --- a/frontend/src/components/DateRangePicker/DateRangePickerContext.tsx +++ b/frontend/src/components/DateRangePicker/DateRangePickerContext.tsx @@ -21,7 +21,6 @@ import { useMultiStyleConfig, } from '@chakra-ui/react' import { format, isValid, parse } from 'date-fns' -import { zonedTimeToUtc } from 'date-fns-tz' import { ThemeColorScheme } from '~theme/foundations/colours' import { useIsMobile } from '~hooks/useIsMobile' @@ -92,7 +91,6 @@ const useProvideDateRangePicker = ({ isReadOnly: isReadOnlyProp, isRequired: isRequiredProp, isInvalid: isInvalidProp, - timeZone = 'UTC', locale, isDateUnavailable, allowManualInput = true, @@ -129,13 +127,13 @@ const useProvideDateRangePicker = ({ // What is rendered as a string in the start date range input according to given display format. const [startInputDisplay, setStartInputDisplay] = useState( startDate && isValid(startDate) - ? format(zonedTimeToUtc(startDate, timeZone), displayFormat, { locale }) + ? format(startDate, displayFormat, { locale }) : '', ) // What is rendered as a string in the end date range input according to given display format. const [endInputDisplay, setEndInputDisplay] = useState( endDate && isValid(endDate) - ? format(zonedTimeToUtc(endDate, timeZone), displayFormat, { locale }) + ? format(endDate, displayFormat, { locale }) : '', ) @@ -151,24 +149,18 @@ const useProvideDateRangePicker = ({ ) as DateRangeValue const [nextStart, nextEnd] = sortedRange - const zonedStartDate = nextStart - ? zonedTimeToUtc(nextStart, timeZone) - : null - const zonedEndDate = nextEnd ? zonedTimeToUtc(nextEnd, timeZone) : null - if (zonedStartDate) { - if (isValid(zonedStartDate)) { - setStartInputDisplay( - format(zonedStartDate, displayFormat, { locale }), - ) + if (nextStart) { + if (isValid(nextStart)) { + setStartInputDisplay(format(nextStart, displayFormat, { locale })) } else if (!allowInvalidDates) { setStartInputDisplay('') } } else { setStartInputDisplay('') } - if (zonedEndDate) { - if (isValid(zonedEndDate)) { - setEndInputDisplay(format(zonedEndDate, displayFormat, { locale })) + if (nextEnd) { + if (isValid(nextEnd)) { + setEndInputDisplay(format(nextEnd, displayFormat, { locale })) } else if (!allowInvalidDates) { setEndInputDisplay('') } @@ -177,7 +169,7 @@ const useProvideDateRangePicker = ({ } setInternalValue(validRange) }, - [allowInvalidDates, displayFormat, locale, setInternalValue, timeZone], + [allowInvalidDates, displayFormat, locale, setInternalValue], ) const fcProps = useFormControlProps({ @@ -274,11 +266,8 @@ const useProvideDateRangePicker = ({ const handleCalendarDateChange = useCallback( (date: DateRangeValue) => { - const zonedDateRange = date.map((d) => - d ? zonedTimeToUtc(d, timeZone) : null, - ) as DateRangeValue - const [nextStartDate, nextEndDate] = zonedDateRange - setInternalValue(zonedDateRange) + const [nextStartDate, nextEndDate] = date + setInternalValue(date) setStartInputDisplay( nextStartDate ? format(nextStartDate, displayFormat, { locale }) : '', ) @@ -296,7 +285,6 @@ const useProvideDateRangePicker = ({ displayFormat, locale, setInternalValue, - timeZone, ], ) From 419080ae345f3c4565866da88df2545af0e8625e Mon Sep 17 00:00:00 2001 From: LinHuiqing Date: Wed, 12 Apr 2023 21:38:47 -1100 Subject: [PATCH 02/15] fix: datepicker bugs due to timezones - (FE) normalize dates to UTC before storing in DB - (FE) load normalized dates to local timezone - (BE) remove adjustments due to timezone --- .../edit-fieldtype/EditDate/EditDate.tsx | 44 ++++++++++++++++--- .../src/templates/Field/Date/DateField.tsx | 14 +++--- frontend/src/utils/date.ts | 10 +++-- frontend/src/utils/fieldValidation.ts | 15 ++++--- .../validators/dateValidator.ts | 4 +- 5 files changed, 62 insertions(+), 25 deletions(-) diff --git a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditDate/EditDate.tsx b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditDate/EditDate.tsx index 0c02f86bc6..dec9a7afcc 100644 --- a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditDate/EditDate.tsx +++ b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditDate/EditDate.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useCallback, useMemo } from 'react' import { Controller, RegisterOptions } from 'react-hook-form' import { Box, FormControl, SimpleGrid } from '@chakra-ui/react' import { isBefore, isEqual, isValid } from 'date-fns' @@ -10,7 +10,11 @@ import { DateValidationOptions, } from '~shared/types/field' -import { fromUtcToLocalDate, isDateOutOfRange } from '~utils/date' +import { + isDateOutOfRange, + loadDateFromNormalizedDate, + normalizeDateToUtc, +} from '~utils/date' import { createBaseValidationRules } from '~utils/fieldValidation' import { DatePicker } from '~components/DatePicker' import { SingleSelect } from '~components/Dropdown' @@ -49,10 +53,14 @@ const transformDateFieldToEditForm = (field: DateFieldBase): EditDateInputs => { selectedDateValidation: field.dateValidation.selectedDateValidation ?? ('' as const), customMaxDate: field.dateValidation.selectedDateValidation - ? field.dateValidation.customMaxDate ?? null + ? field.dateValidation.customMaxDate + ? loadDateFromNormalizedDate(field.dateValidation.customMaxDate) + : null : null, customMinDate: field.dateValidation.selectedDateValidation - ? field.dateValidation.customMinDate ?? null + ? field.dateValidation.customMinDate + ? loadDateFromNormalizedDate(field.dateValidation.customMinDate) + : null : null, } return { @@ -97,6 +105,29 @@ const transformDateEditFormToField = ( } export const EditDate = ({ field }: EditDateProps): JSX.Element => { + const preSubmitTransform = useCallback( + (inputs: EditDateInputs, output: DateFieldBase): DateFieldBase => { + // normalize time to UTC before saving + return { + ...output, + dateValidation: { + ...inputs.dateValidation, + ...(inputs.dateValidation.customMinDate !== null && { + customMinDate: normalizeDateToUtc( + inputs.dateValidation.customMinDate, + ), + }), + ...(inputs.dateValidation.customMaxDate !== null && { + customMaxDate: normalizeDateToUtc( + inputs.dateValidation.customMaxDate, + ), + }), + }, + } as DateFieldBase + }, + [], + ) + const { register, formState: { errors }, @@ -111,6 +142,7 @@ export const EditDate = ({ field }: EditDateProps): JSX.Element => { transform: { input: transformDateFieldToEditForm, output: transformDateEditFormToField, + preSubmit: preSubmitTransform, }, }) @@ -222,9 +254,7 @@ export const EditDate = ({ field }: EditDateProps): JSX.Element => { isDateUnavailable={(d) => isDateOutOfRange( d, - fromUtcToLocalDate( - getValues('dateValidation.customMinDate'), - ), + getValues('dateValidation.customMinDate'), ) } {...field} diff --git a/frontend/src/templates/Field/Date/DateField.tsx b/frontend/src/templates/Field/Date/DateField.tsx index 1286800088..2fba1a5bd4 100644 --- a/frontend/src/templates/Field/Date/DateField.tsx +++ b/frontend/src/templates/Field/Date/DateField.tsx @@ -5,10 +5,10 @@ import { FormColorTheme } from '~shared/types' import { DateSelectedValidation } from '~shared/types/field' import { - fromUtcToLocalDate, isDateAfterToday, isDateBeforeToday, isDateOutOfRange, + loadDateFromNormalizedDate, } from '~utils/date' import { createDateValidationRules } from '~utils/fieldValidation' import { DatePicker } from '~components/DatePicker' @@ -51,11 +51,13 @@ export const DateField = ({ const { customMinDate, customMaxDate } = schema.dateValidation // customMinDate and customMaxDate are in UTC from the server, // need to convert to local time but with the same date as UTC. - return isDateOutOfRange( - date, - fromUtcToLocalDate(customMinDate), - fromUtcToLocalDate(customMaxDate), - ) + const customMinNoTime = customMinDate + ? loadDateFromNormalizedDate(customMinDate) + : null + const customMaxNoTime = customMaxDate + ? loadDateFromNormalizedDate(customMaxDate) + : null + return isDateOutOfRange(date, customMinNoTime, customMaxNoTime) } default: return false diff --git a/frontend/src/utils/date.ts b/frontend/src/utils/date.ts index e49e2acd3b..36351e58f4 100644 --- a/frontend/src/utils/date.ts +++ b/frontend/src/utils/date.ts @@ -38,10 +38,12 @@ export const isDateAfterToday = (date: number | Date) => { return isAfter(date, endOfToday()) } -// Converts UTC time to the same date in local time, ignoring original timezone. -export const fromUtcToLocalDate = (date?: Date | null) => { - if (!date) return date - return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) +export const normalizeDateToUtc = (date: Date) => { + return new Date(date.valueOf() - date.getTimezoneOffset() * 60 * 1000) +} + +export const loadDateFromNormalizedDate = (date: Date) => { + return new Date(date.valueOf() + date.getTimezoneOffset() * 60 * 1000) } /** diff --git a/frontend/src/utils/fieldValidation.ts b/frontend/src/utils/fieldValidation.ts index 8c8d2448e4..ecaf2a5b4a 100644 --- a/frontend/src/utils/fieldValidation.ts +++ b/frontend/src/utils/fieldValidation.ts @@ -53,10 +53,10 @@ import { import { VerifiableFieldBase } from '~features/verifiable-fields/types' import { - fromUtcToLocalDate, isDateAfterToday, isDateBeforeToday, isDateOutOfRange, + loadDateFromNormalizedDate, } from './date' import { formatNumberToLocaleString } from './stringFormat' @@ -431,12 +431,15 @@ export const createDateValidationRules: ValidationRuleFn = ( } const { customMinDate, customMaxDate } = schema.dateValidation ?? {} + const customMinNoTime = customMinDate + ? loadDateFromNormalizedDate(customMinDate) + : null + const customMaxNoTime = customMaxDate + ? loadDateFromNormalizedDate(customMaxDate) + : null return ( - !isDateOutOfRange( - parseDate(val), - fromUtcToLocalDate(customMinDate), - fromUtcToLocalDate(customMaxDate), - ) || 'Selected date is not within the allowed date range' + !isDateOutOfRange(parseDate(val), customMinNoTime, customMaxNoTime) || + 'Selected date is not within the allowed date range' ) }, }, diff --git a/src/app/utils/field-validation/validators/dateValidator.ts b/src/app/utils/field-validation/validators/dateValidator.ts index af41a0ac52..79e3227f05 100644 --- a/src/app/utils/field-validation/validators/dateValidator.ts +++ b/src/app/utils/field-validation/validators/dateValidator.ts @@ -45,7 +45,7 @@ const pastOnlyValidator: DateValidator = (response) => { // Add 14 hours here to account for up to UTC + 14 timezone // This allows validation to pass as long as user is on the correct date (locally) // Even if they are in a different timezone - const todayMax = moment().utc().add(14, 'hours').startOf('day') + const todayMax = moment() const { answer } = response const answerDate = createMomentFromDateString(answer) @@ -62,7 +62,7 @@ const futureOnlyValidator: DateValidator = (response) => { // Subtract 12 hours here to account for up to UTC - 12 timezone // This allows validation to pass as long as user is on the correct date (locally) // Even if they are in a different timezone - const todayMin = moment().utc().subtract(12, 'hours').startOf('day') + const todayMin = moment() const { answer } = response const answerDate = createMomentFromDateString(answer) From eb902dc00f0d0b783d3671663ce98398d2d12953 Mon Sep 17 00:00:00 2001 From: LinHuiqing Date: Thu, 13 Apr 2023 18:54:37 +0800 Subject: [PATCH 03/15] docs: futureOnly and pastOnly validators --- .../utils/field-validation/validators/dateValidator.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/app/utils/field-validation/validators/dateValidator.ts b/src/app/utils/field-validation/validators/dateValidator.ts index 79e3227f05..198c29107e 100644 --- a/src/app/utils/field-validation/validators/dateValidator.ts +++ b/src/app/utils/field-validation/validators/dateValidator.ts @@ -42,9 +42,7 @@ const dateFormatValidator: DateValidator = (response) => { */ const pastOnlyValidator: DateValidator = (response) => { // Today takes two possible values - a min (in makeFutureOnlyValidator) and max (here) - // Add 14 hours here to account for up to UTC + 14 timezone - // This allows validation to pass as long as user is on the correct date (locally) - // Even if they are in a different timezone + // Dates are converted to use local timezones when loaded by the DateField so no conversion required const todayMax = moment() const { answer } = response const answerDate = createMomentFromDateString(answer) @@ -59,9 +57,7 @@ const pastOnlyValidator: DateValidator = (response) => { */ const futureOnlyValidator: DateValidator = (response) => { // Today takes two possible values - a min (here) and max (in makePastOnlyValidator) - // Subtract 12 hours here to account for up to UTC - 12 timezone - // This allows validation to pass as long as user is on the correct date (locally) - // Even if they are in a different timezone + // Dates are converted to use local timezones when loaded by the DateField so no conversion required const todayMin = moment() const { answer } = response const answerDate = createMomentFromDateString(answer) From f99cae789049ebdb3b95e9b957acb7b2ed8fcd0f Mon Sep 17 00:00:00 2001 From: LinHuiqing Date: Thu, 13 Apr 2023 20:07:37 +0800 Subject: [PATCH 04/15] refactor: allow null inputs for new date util fns --- .../edit-fieldtype/EditDate/EditDate.tsx | 24 +++++++------------ .../src/templates/Field/Date/DateField.tsx | 8 ++----- frontend/src/utils/date.ts | 6 +++-- frontend/src/utils/fieldValidation.ts | 8 ++----- 4 files changed, 16 insertions(+), 30 deletions(-) diff --git a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditDate/EditDate.tsx b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditDate/EditDate.tsx index dec9a7afcc..0cffa16ca8 100644 --- a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditDate/EditDate.tsx +++ b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditDate/EditDate.tsx @@ -53,14 +53,10 @@ const transformDateFieldToEditForm = (field: DateFieldBase): EditDateInputs => { selectedDateValidation: field.dateValidation.selectedDateValidation ?? ('' as const), customMaxDate: field.dateValidation.selectedDateValidation - ? field.dateValidation.customMaxDate - ? loadDateFromNormalizedDate(field.dateValidation.customMaxDate) - : null + ? loadDateFromNormalizedDate(field.dateValidation.customMaxDate) : null, customMinDate: field.dateValidation.selectedDateValidation - ? field.dateValidation.customMinDate - ? loadDateFromNormalizedDate(field.dateValidation.customMinDate) - : null + ? loadDateFromNormalizedDate(field.dateValidation.customMinDate) : null, } return { @@ -112,16 +108,12 @@ export const EditDate = ({ field }: EditDateProps): JSX.Element => { ...output, dateValidation: { ...inputs.dateValidation, - ...(inputs.dateValidation.customMinDate !== null && { - customMinDate: normalizeDateToUtc( - inputs.dateValidation.customMinDate, - ), - }), - ...(inputs.dateValidation.customMaxDate !== null && { - customMaxDate: normalizeDateToUtc( - inputs.dateValidation.customMaxDate, - ), - }), + customMinDate: normalizeDateToUtc( + inputs.dateValidation.customMinDate, + ), + customMaxDate: normalizeDateToUtc( + inputs.dateValidation.customMaxDate, + ), }, } as DateFieldBase }, diff --git a/frontend/src/templates/Field/Date/DateField.tsx b/frontend/src/templates/Field/Date/DateField.tsx index 2fba1a5bd4..a219449f0d 100644 --- a/frontend/src/templates/Field/Date/DateField.tsx +++ b/frontend/src/templates/Field/Date/DateField.tsx @@ -51,12 +51,8 @@ export const DateField = ({ const { customMinDate, customMaxDate } = schema.dateValidation // customMinDate and customMaxDate are in UTC from the server, // need to convert to local time but with the same date as UTC. - const customMinNoTime = customMinDate - ? loadDateFromNormalizedDate(customMinDate) - : null - const customMaxNoTime = customMaxDate - ? loadDateFromNormalizedDate(customMaxDate) - : null + const customMinNoTime = loadDateFromNormalizedDate(customMinDate) + const customMaxNoTime = loadDateFromNormalizedDate(customMaxDate) return isDateOutOfRange(date, customMinNoTime, customMaxNoTime) } default: diff --git a/frontend/src/utils/date.ts b/frontend/src/utils/date.ts index 36351e58f4..4d1d4f9bd0 100644 --- a/frontend/src/utils/date.ts +++ b/frontend/src/utils/date.ts @@ -38,11 +38,13 @@ export const isDateAfterToday = (date: number | Date) => { return isAfter(date, endOfToday()) } -export const normalizeDateToUtc = (date: Date) => { +export const normalizeDateToUtc = (date: Date | null) => { + if (!date) return date return new Date(date.valueOf() - date.getTimezoneOffset() * 60 * 1000) } -export const loadDateFromNormalizedDate = (date: Date) => { +export const loadDateFromNormalizedDate = (date: Date | null) => { + if (!date) return date return new Date(date.valueOf() + date.getTimezoneOffset() * 60 * 1000) } diff --git a/frontend/src/utils/fieldValidation.ts b/frontend/src/utils/fieldValidation.ts index ecaf2a5b4a..1fbedcdda6 100644 --- a/frontend/src/utils/fieldValidation.ts +++ b/frontend/src/utils/fieldValidation.ts @@ -431,12 +431,8 @@ export const createDateValidationRules: ValidationRuleFn = ( } const { customMinDate, customMaxDate } = schema.dateValidation ?? {} - const customMinNoTime = customMinDate - ? loadDateFromNormalizedDate(customMinDate) - : null - const customMaxNoTime = customMaxDate - ? loadDateFromNormalizedDate(customMaxDate) - : null + const customMinNoTime = loadDateFromNormalizedDate(customMinDate) + const customMaxNoTime = loadDateFromNormalizedDate(customMaxDate) return ( !isDateOutOfRange(parseDate(val), customMinNoTime, customMaxNoTime) || 'Selected date is not within the allowed date range' From d2d879539cd0c3ca1048048cccbc9ae75016e8cb Mon Sep 17 00:00:00 2001 From: LinHuiqing Date: Fri, 14 Apr 2023 16:26:28 +1200 Subject: [PATCH 05/15] fix: date normalization fns to use date as numbers --- frontend/src/utils/date.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/utils/date.ts b/frontend/src/utils/date.ts index 4d1d4f9bd0..93d4f26e9d 100644 --- a/frontend/src/utils/date.ts +++ b/frontend/src/utils/date.ts @@ -40,12 +40,12 @@ export const isDateAfterToday = (date: number | Date) => { export const normalizeDateToUtc = (date: Date | null) => { if (!date) return date - return new Date(date.valueOf() - date.getTimezoneOffset() * 60 * 1000) + return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) } export const loadDateFromNormalizedDate = (date: Date | null) => { if (!date) return date - return new Date(date.valueOf() + date.getTimezoneOffset() * 60 * 1000) + return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) } /** From e2d6a8775091d429b2a8aee2051d1d22ad30274e Mon Sep 17 00:00:00 2001 From: LinHuiqing Date: Fri, 14 Apr 2023 16:27:17 +1200 Subject: [PATCH 06/15] refactor: remove unnecessary var assignment --- frontend/src/templates/Field/Date/DateField.tsx | 8 +++++--- frontend/src/utils/fieldValidation.ts | 9 +++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/frontend/src/templates/Field/Date/DateField.tsx b/frontend/src/templates/Field/Date/DateField.tsx index a219449f0d..dd3bd120ae 100644 --- a/frontend/src/templates/Field/Date/DateField.tsx +++ b/frontend/src/templates/Field/Date/DateField.tsx @@ -51,9 +51,11 @@ export const DateField = ({ const { customMinDate, customMaxDate } = schema.dateValidation // customMinDate and customMaxDate are in UTC from the server, // need to convert to local time but with the same date as UTC. - const customMinNoTime = loadDateFromNormalizedDate(customMinDate) - const customMaxNoTime = loadDateFromNormalizedDate(customMaxDate) - return isDateOutOfRange(date, customMinNoTime, customMaxNoTime) + return isDateOutOfRange( + date, + loadDateFromNormalizedDate(customMinDate), + loadDateFromNormalizedDate(customMaxDate), + ) } default: return false diff --git a/frontend/src/utils/fieldValidation.ts b/frontend/src/utils/fieldValidation.ts index 1fbedcdda6..9f2b163bf0 100644 --- a/frontend/src/utils/fieldValidation.ts +++ b/frontend/src/utils/fieldValidation.ts @@ -431,11 +431,12 @@ export const createDateValidationRules: ValidationRuleFn = ( } const { customMinDate, customMaxDate } = schema.dateValidation ?? {} - const customMinNoTime = loadDateFromNormalizedDate(customMinDate) - const customMaxNoTime = loadDateFromNormalizedDate(customMaxDate) return ( - !isDateOutOfRange(parseDate(val), customMinNoTime, customMaxNoTime) || - 'Selected date is not within the allowed date range' + !isDateOutOfRange( + parseDate(val), + loadDateFromNormalizedDate(customMinDate), + loadDateFromNormalizedDate(customMaxDate), + ) || 'Selected date is not within the allowed date range' ) }, }, From 709842198f3627c771dfc607e2daa403c551424e Mon Sep 17 00:00:00 2001 From: LinHuiqing Date: Mon, 17 Apr 2023 15:21:19 +0800 Subject: [PATCH 07/15] fix: unset date validation --- .../EditFieldDrawer/edit-fieldtype/EditDate/EditDate.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditDate/EditDate.tsx b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditDate/EditDate.tsx index 0cffa16ca8..26a075834b 100644 --- a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditDate/EditDate.tsx +++ b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditDate/EditDate.tsx @@ -107,7 +107,10 @@ export const EditDate = ({ field }: EditDateProps): JSX.Element => { return { ...output, dateValidation: { - ...inputs.dateValidation, + selectedDateValidation: + inputs.dateValidation.selectedDateValidation === '' + ? null + : inputs.dateValidation.selectedDateValidation, customMinDate: normalizeDateToUtc( inputs.dateValidation.customMinDate, ), From 10338165efc425dd973d94b51e516b2a16deba73 Mon Sep 17 00:00:00 2001 From: LinHuiqing Date: Mon, 17 Apr 2023 16:07:30 +0800 Subject: [PATCH 08/15] fix: compare to dates in earliest & latest tzs --- .../utils/field-validation/validators/dateValidator.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/utils/field-validation/validators/dateValidator.ts b/src/app/utils/field-validation/validators/dateValidator.ts index 198c29107e..fd2ae2dc7b 100644 --- a/src/app/utils/field-validation/validators/dateValidator.ts +++ b/src/app/utils/field-validation/validators/dateValidator.ts @@ -42,8 +42,9 @@ const dateFormatValidator: DateValidator = (response) => { */ const pastOnlyValidator: DateValidator = (response) => { // Today takes two possible values - a min (in makeFutureOnlyValidator) and max (here) - // Dates are converted to use local timezones when loaded by the DateField so no conversion required - const todayMax = moment() + // Compares the input date with the maximum date for today anywhere in the world so + // respondent can be from any timezone. + const todayMax = moment().zone('+14:00').startOf('day') const { answer } = response const answerDate = createMomentFromDateString(answer) @@ -57,8 +58,9 @@ const pastOnlyValidator: DateValidator = (response) => { */ const futureOnlyValidator: DateValidator = (response) => { // Today takes two possible values - a min (here) and max (in makePastOnlyValidator) - // Dates are converted to use local timezones when loaded by the DateField so no conversion required - const todayMin = moment() + // Compares the input date with the minimum date for today anywhere in the world so + // respondent can be from any timezone. + const todayMin = moment().zone('-12:00').startOf('day') const { answer } = response const answerDate = createMomentFromDateString(answer) From 942ab6a9eff4eafff783222c3e9b68bb6fdace53 Mon Sep 17 00:00:00 2001 From: LinHuiqing Date: Wed, 3 May 2023 14:40:00 +0800 Subject: [PATCH 09/15] fix: date validation bug in release v6.41.0 --- .../field-validation/validators/dateValidator.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/app/utils/field-validation/validators/dateValidator.ts b/src/app/utils/field-validation/validators/dateValidator.ts index fd2ae2dc7b..65a28b755f 100644 --- a/src/app/utils/field-validation/validators/dateValidator.ts +++ b/src/app/utils/field-validation/validators/dateValidator.ts @@ -42,9 +42,10 @@ const dateFormatValidator: DateValidator = (response) => { */ const pastOnlyValidator: DateValidator = (response) => { // Today takes two possible values - a min (in makeFutureOnlyValidator) and max (here) - // Compares the input date with the maximum date for today anywhere in the world so - // respondent can be from any timezone. - const todayMax = moment().zone('+14:00').startOf('day') + // Compares the input time (date casted to midnight in UTC time) with the maximum time + // for 'today' anywhere in the world (ie 23:59:59 in +14:00 for the current date in +14:00) + // so respondent can be from any timezone. + const todayMax = moment().utcOffset('+14:00').endOf('day') const { answer } = response const answerDate = createMomentFromDateString(answer) @@ -58,9 +59,10 @@ const pastOnlyValidator: DateValidator = (response) => { */ const futureOnlyValidator: DateValidator = (response) => { // Today takes two possible values - a min (here) and max (in makePastOnlyValidator) - // Compares the input date with the minimum date for today anywhere in the world so - // respondent can be from any timezone. - const todayMin = moment().zone('-12:00').startOf('day') + // Compares the input time (date casted to midnight in UTC time) with the minimum time + // for 'today' anywhere in the world (ie 00:00:00 in -12:00 for the current date in -12:00) + // so respondent can be from any timezone. + const todayMin = moment().utcOffset('-12:00').startOf('day') const { answer } = response const answerDate = createMomentFromDateString(answer) From c369b8e0b903d152d4fb55e761d2b9446b5b9ae0 Mon Sep 17 00:00:00 2001 From: LinHuiqing Date: Thu, 4 May 2023 09:33:16 +0800 Subject: [PATCH 10/15] fix: restore original date validators --- .../field-validation/validators/dateValidator.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app/utils/field-validation/validators/dateValidator.ts b/src/app/utils/field-validation/validators/dateValidator.ts index 65a28b755f..af41a0ac52 100644 --- a/src/app/utils/field-validation/validators/dateValidator.ts +++ b/src/app/utils/field-validation/validators/dateValidator.ts @@ -42,10 +42,10 @@ const dateFormatValidator: DateValidator = (response) => { */ const pastOnlyValidator: DateValidator = (response) => { // Today takes two possible values - a min (in makeFutureOnlyValidator) and max (here) - // Compares the input time (date casted to midnight in UTC time) with the maximum time - // for 'today' anywhere in the world (ie 23:59:59 in +14:00 for the current date in +14:00) - // so respondent can be from any timezone. - const todayMax = moment().utcOffset('+14:00').endOf('day') + // Add 14 hours here to account for up to UTC + 14 timezone + // This allows validation to pass as long as user is on the correct date (locally) + // Even if they are in a different timezone + const todayMax = moment().utc().add(14, 'hours').startOf('day') const { answer } = response const answerDate = createMomentFromDateString(answer) @@ -59,10 +59,10 @@ const pastOnlyValidator: DateValidator = (response) => { */ const futureOnlyValidator: DateValidator = (response) => { // Today takes two possible values - a min (here) and max (in makePastOnlyValidator) - // Compares the input time (date casted to midnight in UTC time) with the minimum time - // for 'today' anywhere in the world (ie 00:00:00 in -12:00 for the current date in -12:00) - // so respondent can be from any timezone. - const todayMin = moment().utcOffset('-12:00').startOf('day') + // Subtract 12 hours here to account for up to UTC - 12 timezone + // This allows validation to pass as long as user is on the correct date (locally) + // Even if they are in a different timezone + const todayMin = moment().utc().subtract(12, 'hours').startOf('day') const { answer } = response const answerDate = createMomentFromDateString(answer) From 3d14e1ac49153bfc21449ec9c2990028d241fdbd Mon Sep 17 00:00:00 2001 From: LinHuiqing Date: Tue, 16 May 2023 20:07:59 +1200 Subject: [PATCH 11/15] fix: normalizeDateToUtc to return Date object --- frontend/src/utils/date.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/utils/date.ts b/frontend/src/utils/date.ts index 93d4f26e9d..8b6508a34c 100644 --- a/frontend/src/utils/date.ts +++ b/frontend/src/utils/date.ts @@ -40,7 +40,7 @@ export const isDateAfterToday = (date: number | Date) => { export const normalizeDateToUtc = (date: Date | null) => { if (!date) return date - return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) + return new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())) } export const loadDateFromNormalizedDate = (date: Date | null) => { From 7102c32ebff795878775466451a2e6d8090bb355 Mon Sep 17 00:00:00 2001 From: LinHuiqing Date: Tue, 16 May 2023 20:09:06 +1200 Subject: [PATCH 12/15] test: frontend date utils --- frontend/src/utils/date.test.ts | 107 ++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 frontend/src/utils/date.test.ts diff --git a/frontend/src/utils/date.test.ts b/frontend/src/utils/date.test.ts new file mode 100644 index 0000000000..e39901a0be --- /dev/null +++ b/frontend/src/utils/date.test.ts @@ -0,0 +1,107 @@ +import * as DateUtils from './date' + +describe('date', () => { + describe('isDateBeforeToday', () => { + it('should return true when the input date is in the past', () => { + const result = DateUtils.isDateBeforeToday(new Date('2023-04-23')) + + expect(result).toBe(true) + }) + it('should return true when the input date is yesterday', () => { + const now = new Date() + const result = DateUtils.isDateBeforeToday( + new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1), + ) + + expect(result).toBe(true) + }) + it('should return false when the input date is within today (e.g. now)', () => { + const result = DateUtils.isDateBeforeToday(new Date()) + + expect(result).toBe(false) + }) + it('should return false when the input date is today', () => { + const now = new Date() + const result = DateUtils.isDateBeforeToday( + new Date(now.getFullYear(), now.getMonth(), now.getDate()), + ) + + expect(result).toBe(false) + }) + it('should return false when the input date is in the future', () => { + const now = new Date() + const result = DateUtils.isDateBeforeToday( + new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1), + ) + + expect(result).toBe(false) + }) + }) + + describe('isDateAfterToday', () => { + it('should return true when the input date is in the future', () => { + const result = DateUtils.isDateAfterToday(new Date('3023-04-23')) + + expect(result).toBe(true) + }) + it('should return true when the input date is tomorrow', () => { + const now = new Date() + const result = DateUtils.isDateAfterToday( + new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1), + ) + + expect(result).toBe(true) + }) + it('should return false when the input date is within today (e.g. now)', () => { + const result = DateUtils.isDateAfterToday(new Date()) + + expect(result).toBe(false) + }) + it('should return false when the input date is today', () => { + const now = new Date() + const result = DateUtils.isDateAfterToday( + new Date(now.getFullYear(), now.getMonth(), now.getDate()), + ) + + expect(result).toBe(false) + }) + it('should return false when the input date is in the past', () => { + const now = new Date() + const result = DateUtils.isDateAfterToday( + new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1), + ) + + expect(result).toBe(false) + }) + }) + + describe('normalizeDateToUtc', () => { + it('should convert local dates to UTC', () => { + // We can only test using this function in different system times by + // manually changing system time and rerunning this test + + const dateString = '2023-04-23T00:00:00' + const localDate = new Date(Date.parse(dateString)) + const utcDate = new Date(Date.parse(`${dateString}+00:00`)) + + const result = DateUtils.normalizeDateToUtc(localDate) + + expect(result).toStrictEqual(utcDate) + }) + }) + + describe('loadDateFromNormalizedDate', () => { + it('should convert normalised (UTC) dates to local date', () => { + // We can only test using this function in different system times by + // manually changing system time and rerunning this test + + const dateString = '2023-04-23T00:00:00' + const utcDate = new Date(Date.parse(`${dateString}+00:00`)) + const localDate = new Date(Date.parse(dateString)) + + const result = DateUtils.loadDateFromNormalizedDate(utcDate) + + expect(result).toStrictEqual(localDate) + }) + }) +}) From f8f6f93eee237031379b3bdc7239783940843da9 Mon Sep 17 00:00:00 2001 From: LinHuiqing Date: Tue, 16 May 2023 20:39:02 +1200 Subject: [PATCH 13/15] build(deps): add timezone-mock to mock local time --- frontend/package-lock.json | 11 +++++++++++ frontend/package.json | 1 + 2 files changed, 12 insertions(+) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fad22b439c..8ae9c71fb6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -77,6 +77,7 @@ "simplur": "^3.0.1", "spark-md5": "^3.0.2", "stripe": "^11.1.0", + "timezone-mock": "^1.3.6", "type-fest": "^2.8.0", "typescript": "^4.5.3", "use-debounce": "^7.0.1", @@ -44498,6 +44499,11 @@ "node": ">=0.6.0" } }, + "node_modules/timezone-mock": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/timezone-mock/-/timezone-mock-1.3.6.tgz", + "integrity": "sha512-YcloWmZfLD9Li5m2VcobkCDNVaLMx8ohAb/97l/wYS3m+0TIEK5PFNMZZfRcusc6sFjIfxu8qcJT0CNnOdpqmg==" + }, "node_modules/timsort": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz", @@ -81865,6 +81871,11 @@ "setimmediate": "^1.0.4" } }, + "timezone-mock": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/timezone-mock/-/timezone-mock-1.3.6.tgz", + "integrity": "sha512-YcloWmZfLD9Li5m2VcobkCDNVaLMx8ohAb/97l/wYS3m+0TIEK5PFNMZZfRcusc6sFjIfxu8qcJT0CNnOdpqmg==" + }, "timsort": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7fb3b0a7df..902f8d6a53 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -72,6 +72,7 @@ "simplur": "^3.0.1", "spark-md5": "^3.0.2", "stripe": "^11.1.0", + "timezone-mock": "^1.3.6", "type-fest": "^2.8.0", "typescript": "^4.5.3", "use-debounce": "^7.0.1", From 51a81691a9bac49a4ead6c8299cbebff4f8594e2 Mon Sep 17 00:00:00 2001 From: LinHuiqing Date: Tue, 16 May 2023 20:45:53 +1200 Subject: [PATCH 14/15] test: date utils in the most extreme timezones --- frontend/src/utils/date.test.ts | 71 ++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/frontend/src/utils/date.test.ts b/frontend/src/utils/date.test.ts index e39901a0be..5d3f028fb1 100644 --- a/frontend/src/utils/date.test.ts +++ b/frontend/src/utils/date.test.ts @@ -1,3 +1,5 @@ +import timezoneMock from 'timezone-mock' + import * as DateUtils from './date' describe('date', () => { @@ -76,25 +78,52 @@ describe('date', () => { }) describe('normalizeDateToUtc', () => { + beforeEach(() => { + timezoneMock.unregister() + }) it('should convert local dates to UTC', () => { - // We can only test using this function in different system times by - // manually changing system time and rerunning this test - const dateString = '2023-04-23T00:00:00' const localDate = new Date(Date.parse(dateString)) const utcDate = new Date(Date.parse(`${dateString}+00:00`)) const result = DateUtils.normalizeDateToUtc(localDate) + expect(result).toStrictEqual(utcDate) + }) + it('should convert negative UTC (local) dates to UTC', () => { + const dateString = '2023-04-23T00:00:00' + + // First, create Date value for UTC + timezoneMock.register('UTC') + const utcDate = new Date(Date.parse(dateString)) + + // Simulate 'local' timezone when users are in UTC-12 + timezoneMock.register('Etc/GMT+12') + const negativeUtcDate = new Date(Date.parse(dateString)) + + const result = DateUtils.normalizeDateToUtc(negativeUtcDate) + + expect(result).toStrictEqual(utcDate) + }) + it('should convert positive UTC (local) dates to UTC', () => { + const dateString = '2023-04-23T00:00:00' + + // First, create Date value for UTC + timezoneMock.register('UTC') + const utcDate = new Date(Date.parse(dateString)) + + // Simulate 'local' timezone when users are in UTC+14 + timezoneMock.register('Etc/GMT-14') + const positiveUtcDate = new Date(Date.parse(dateString)) + + const result = DateUtils.normalizeDateToUtc(positiveUtcDate) + expect(result).toStrictEqual(utcDate) }) }) describe('loadDateFromNormalizedDate', () => { it('should convert normalised (UTC) dates to local date', () => { - // We can only test using this function in different system times by - // manually changing system time and rerunning this test - const dateString = '2023-04-23T00:00:00' const utcDate = new Date(Date.parse(`${dateString}+00:00`)) const localDate = new Date(Date.parse(dateString)) @@ -103,5 +132,35 @@ describe('date', () => { expect(result).toStrictEqual(localDate) }) + it('should convert normalised (UTC) dates to negative UTC (local) date', () => { + const dateString = '2023-04-23T00:00:00' + + // First, create Date value for UTC + timezoneMock.register('UTC') + const utcDate = new Date(Date.parse(dateString)) + + // Simulate 'local' timezone when users are in UTC-12 + timezoneMock.register('Etc/GMT+12') + const negativeUtcDate = new Date(Date.parse(dateString)) + + const result = DateUtils.loadDateFromNormalizedDate(utcDate) + + expect(result).toStrictEqual(negativeUtcDate) + }) + it('should convert normalised (UTC) dates to positive UTC (local) date', () => { + const dateString = '2023-04-23T00:00:00' + + // First, create Date value for UTC + timezoneMock.register('UTC') + const utcDate = new Date(Date.parse(dateString)) + + // Simulate 'local' timezone when users are in UTC+14 + timezoneMock.register('Etc/GMT-14') + const positiveUtcDate = new Date(Date.parse(dateString)) + + const result = DateUtils.loadDateFromNormalizedDate(utcDate) + + expect(result).toStrictEqual(positiveUtcDate) + }) }) }) From d232fad80731bd43a00af5cd640898846fc69706 Mon Sep 17 00:00:00 2001 From: LinHuiqing Date: Wed, 17 May 2023 14:48:11 +0800 Subject: [PATCH 15/15] test: add check to ensure that timezone-mock works --- frontend/src/utils/date.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/frontend/src/utils/date.test.ts b/frontend/src/utils/date.test.ts index 5d3f028fb1..90d7499e39 100644 --- a/frontend/src/utils/date.test.ts +++ b/frontend/src/utils/date.test.ts @@ -101,6 +101,11 @@ describe('date', () => { timezoneMock.register('Etc/GMT+12') const negativeUtcDate = new Date(Date.parse(dateString)) + // Check that the negative UTC date created is different from UTC time and + // negative UTC's midnight should happen after UTC's midnight + expect(negativeUtcDate).not.toStrictEqual(utcDate) + expect(negativeUtcDate.getTime()).toBeGreaterThan(utcDate.getTime()) + const result = DateUtils.normalizeDateToUtc(negativeUtcDate) expect(result).toStrictEqual(utcDate) @@ -116,6 +121,11 @@ describe('date', () => { timezoneMock.register('Etc/GMT-14') const positiveUtcDate = new Date(Date.parse(dateString)) + // Check that the positive UTC date created is different from UTC time and + // positive UTC's midnight should happen before UTC's midnight + expect(positiveUtcDate).not.toStrictEqual(utcDate) + expect(positiveUtcDate.getTime()).toBeLessThan(utcDate.getTime()) + const result = DateUtils.normalizeDateToUtc(positiveUtcDate) expect(result).toStrictEqual(utcDate) @@ -143,6 +153,11 @@ describe('date', () => { timezoneMock.register('Etc/GMT+12') const negativeUtcDate = new Date(Date.parse(dateString)) + // Check that the negative UTC date created is different from UTC time and + // negative UTC's midnight should happen after UTC's midnight + expect(negativeUtcDate).not.toStrictEqual(utcDate) + expect(negativeUtcDate.getTime()).toBeGreaterThan(utcDate.getTime()) + const result = DateUtils.loadDateFromNormalizedDate(utcDate) expect(result).toStrictEqual(negativeUtcDate) @@ -158,6 +173,11 @@ describe('date', () => { timezoneMock.register('Etc/GMT-14') const positiveUtcDate = new Date(Date.parse(dateString)) + // Check that the positive UTC date created is different from UTC time and + // positive UTC's midnight should happen before UTC's midnight + expect(positiveUtcDate).not.toStrictEqual(utcDate) + expect(positiveUtcDate.getTime()).toBeLessThan(utcDate.getTime()) + const result = DateUtils.loadDateFromNormalizedDate(utcDate) expect(result).toStrictEqual(positiveUtcDate)