From 9544fc70cd3429595bace248abc851b56a03579b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20S=C3=A1ros?= Date: Thu, 19 Sep 2024 18:11:52 +0200 Subject: [PATCH] fix(ui-calendar): fix duplicate dates for certain timezones --- package-lock.json | 1 + packages/ui-calendar/package.json | 1 + packages/ui-calendar/src/Calendar/README.md | 393 +----------------- packages/ui-calendar/src/Calendar/index.tsx | 18 +- .../src/DateTimeInput/index.tsx | 11 + packages/ui-i18n/src/DateTime.ts | 2 +- 6 files changed, 31 insertions(+), 395 deletions(-) diff --git a/package-lock.json b/package-lock.json index 790cba7627..9bf3b39011 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41396,6 +41396,7 @@ "@instructure/ui-utils": "10.2.2", "@instructure/ui-view": "10.2.2", "@instructure/uid": "10.2.2", + "moment-timezone": "^0.5.45", "prop-types": "^15.8.1" }, "devDependencies": { diff --git a/packages/ui-calendar/package.json b/packages/ui-calendar/package.json index 15b9ee4334..08d2564654 100644 --- a/packages/ui-calendar/package.json +++ b/packages/ui-calendar/package.json @@ -48,6 +48,7 @@ "@instructure/ui-utils": "10.2.2", "@instructure/ui-view": "10.2.2", "@instructure/uid": "10.2.2", + "moment-timezone": "^0.5.45", "prop-types": "^15.8.1" }, "peerDependencies": { diff --git a/packages/ui-calendar/src/Calendar/README.md b/packages/ui-calendar/src/Calendar/README.md index 6d56fd8057..809b455d75 100644 --- a/packages/ui-calendar/src/Calendar/README.md +++ b/packages/ui-calendar/src/Calendar/README.md @@ -14,396 +14,5 @@ to the `Calendar` component. --- type: example --- - + ``` - -### Default config with additional props - -- ```js - class Example extends React.Component { - state = { - selectedDate: '', - visibleMonth: '2025-05' - } - - render = () => ( - - this.setState({ visibleMonth: requestedMonth }) - } - onRequestRenderPrevMonth={(_e, requestedMonth) => - this.setState({ visibleMonth: requestedMonth }) - } - onDateSelected={(date) => { - this.setState({ selectedDate: date }) - }} - /> - ) - } - render() - ``` - -- ```js - const Example = () => { - const [selectedDate, setSelectedDate] = useState('') - const [visibleMonth, setVisibleMonth] = useState('2025-05') - - return ( - - setVisibleMonth(requestedMonth) - } - onRequestRenderPrevMonth={(_e, requestedMonth) => - setVisibleMonth(requestedMonth) - } - onDateSelected={(date) => { - setSelectedDate(date) - }} - /> - ) - } - render() - ``` - -### With year picker - -- ```js - class Example extends React.Component { - state = { - selectedDate: '', - visibleMonth: '2024-02' - } - - render = () => ( - - this.setState({ visibleMonth: requestedMonth }) - } - onRequestRenderPrevMonth={(_e, requestedMonth) => - this.setState({ visibleMonth: requestedMonth }) - } - onDateSelected={(date) => { - this.setState({ selectedDate: date }) - }} - withYearPicker={{ - screenReaderLabel: 'Year picker', - startYear: 1999, - endYear: 2024, - maxHeight: '200px' - }} - /> - ) - } - render() - ``` - -- ```js - const Example = () => { - const [selectedDate, setSelectedDate] = useState('') - const [visibleMonth, setVisibleMonth] = useState('2024-02') - - return ( - - setVisibleMonth(requestedMonth) - } - onRequestRenderPrevMonth={(_e, requestedMonth) => - setVisibleMonth(requestedMonth) - } - onDateSelected={(date) => { - setSelectedDate(date) - }} - withYearPicker={{ - screenReaderLabel: 'Year picker', - startYear: 1999, - endYear: 2024, - maxHeight: '200px' - }} - /> - ) - } - render() - ``` - -### Composing a Calendar in your Application - -By design, the `Calendar` component does not dictate which date libraries or -formats you use in your application. The following example demonstrates how a -basic `Calendar` might be created using utilities from -[Moment.js](https://momentjs.com/docs/#/parsing/). - -- ```js - class Example extends React.Component { - state = { - todayDate: parseDate('2019-08-16').toISOString(), - renderedDate: parseDate('2019-08-02').toISOString() - } - - generateMonth = () => { - const date = parseDate(this.state.renderedDate) - .startOf('month') - .startOf('week') - - return Array.apply(null, Array(Calendar.DAY_COUNT)).map(() => { - const currentDate = date.clone() - date.add(1, 'days') - return currentDate - }) - } - - renderWeekdayLabels = () => { - const date = parseDate(this.state.renderedDate).startOf('week') - - return Array.apply(null, Array(7)).map(() => { - const currentDate = date.clone() - date.add(1, 'day') - - return ( - - {currentDate.format('dd')} - - ) - }) - } - - handleRenderNextMonth = (event) => { - this.modifyRenderedMonth(1) - } - - handleRenderPrevMonth = (event) => { - this.modifyRenderedMonth(-1) - } - - modifyRenderedMonth = (step) => { - this.setState(({ renderedDate }) => { - const date = parseDate(renderedDate) - date.add(step, 'month') - return { renderedDate: date.toISOString() } - }) - } - - renderDay(date) { - const { renderedDate, todayDate } = this.state - - return ( - - {date.format('D')} - - ) - } - - render() { - const date = parseDate(this.state.renderedDate) - - const buttonProps = (type = 'prev') => ({ - size: 'small', - withBackground: false, - withBorder: false, - renderIcon: - type === 'prev' ? ( - - ) : ( - - ), - screenReaderLabel: type === 'prev' ? 'Previous month' : 'Next month' - }) - - return ( - } - renderNextMonthButton={} - renderNavigationLabel={ - -
{date.format('MMMM')}
-
{date.format('YYYY')}
-
- } - renderWeekdayLabels={this.renderWeekdayLabels()} - onRequestRenderNextMonth={this.handleRenderNextMonth} - onRequestRenderPrevMonth={this.handleRenderPrevMonth} - > - {this.generateMonth().map((date) => this.renderDay(date))} -
- ) - } - } - - const locale = 'en-us' - const timezone = 'America/Denver' - - const parseDate = (dateStr) => { - return moment.tz(dateStr, [moment.ISO_8601], locale, timezone) - } - - render() - ``` - -- ```js - const Example = () => { - const [renderedDate, setRenderedDate] = useState( - parseDate('2019-08-02').toISOString() - ) - - const todayDate = parseDate('2019-08-16').toISOString() - const generateMonth = () => { - const date = parseDate(renderedDate).startOf('month').startOf('week') - - return Array.apply(null, Array(Calendar.DAY_COUNT)).map(() => { - const currentDate = date.clone() - date.add(1, 'days') - return currentDate - }) - } - - const renderWeekdayLabels = () => { - const date = parseDate(renderedDate).startOf('week') - - return Array.apply(null, Array(7)).map(() => { - const currentDate = date.clone() - date.add(1, 'day') - - return ( - - {currentDate.format('dd')} - - ) - }) - } - - const handleRenderNextMonth = (event) => { - modifyRenderedMonth(1) - } - - const handleRenderPrevMonth = (event) => { - modifyRenderedMonth(-1) - } - - const modifyRenderedMonth = (step) => { - const date = parseDate(renderedDate) - date.add(step, 'month') - setRenderedDate(date.toISOString()) - } - - const renderDay = (date) => { - return ( - - {date.format('D')} - - ) - } - - const date = parseDate(renderedDate) - - const buttonProps = (type = 'prev') => ({ - size: 'small', - withBackground: false, - withBorder: false, - renderIcon: - type === 'prev' ? ( - - ) : ( - - ), - screenReaderLabel: type === 'prev' ? 'Previous month' : 'Next month' - }) - - return ( - } - renderNextMonthButton={} - renderNavigationLabel={ - -
{date.format('MMMM')}
-
{date.format('YYYY')}
-
- } - renderWeekdayLabels={renderWeekdayLabels()} - onRequestRenderNextMonth={handleRenderNextMonth} - onRequestRenderPrevMonth={handleRenderPrevMonth} - > - {generateMonth().map((date) => renderDay(date))} -
- ) - } - - const locale = 'en-us' - const timezone = 'America/Denver' - - const parseDate = (dateStr) => { - return moment.tz(dateStr, [moment.ISO_8601], locale, timezone) - } - - render() - ``` - -#### Some dates to keep track of - -- `todayDate` - the date that represents today -- `renderedDate` - the date that the user is viewing as they navigate the `Calendar` - -#### Generating a month - -We generate a month based on the `renderedDate` value. The `Calendar` always -displays 6 weeks or 42 days (42 is defined as a constant `Calendar.DAY_COUNT`), -so we pad our month values with days from the previous and next month if -necessary. The complete implementation can be seen in the `generateMonth` function -in our example. - -#### Rendering days - -Using the month data, we can now map it to children of type `Calendar.Day`. -As we render each day, if it is outside the current month we can set the -`isOutsideMonth` prop. We can also set the `isToday` prop if it is the current -date. For accessibility, it is recommended that you provide more information to -each `Calendar.Day` using the label prop. This label will help screen readers to -have important context as the `Calendar` is navigated. It should include the day, -month, and the year (Ex. instead of `1` we would provide `1 August 2019`). - -#### Rendering weekday labels - -`Calendar` requires you to provide an array of 7 labels that correspond to each -day of the week via the `renderWeekdayLabels` prop. The visible portion of the -label should be abbreviated (no longer than three characters). Note that screen -readers will read this content preceding each date as the `Calendar` is navigated. -Consider using [AccessibleContent](#AccessibleContent) with the `alt` prop -containing the full day name for assistive technologies and the children containing -the abbreviation. ex. `[Sun, ...]` - -#### Rendering next and previous month buttons - -The `renderNextMonthButton` and `renderPrevMonthButton` can be supplied using the -[IconButton](#IconButton) component with the `size` prop set to -`small`, the `withBackground` and `withBorder` props both set to `false`, and the `renderIcon` prop set to [IconArrowOpenStart](#iconography) or -[IconArrowOpenEnd](#iconography). diff --git a/packages/ui-calendar/src/Calendar/index.tsx b/packages/ui-calendar/src/Calendar/index.tsx index 6c05fc0394..a9c5af00e7 100644 --- a/packages/ui-calendar/src/Calendar/index.tsx +++ b/packages/ui-calendar/src/Calendar/index.tsx @@ -41,6 +41,7 @@ import { testable } from '@instructure/ui-testable' import { withStyle, jsx } from '@instructure/emotion' import { Locale, DateTime, ApplyLocaleContext } from '@instructure/ui-i18n' +import moment from 'moment-timezone' import type { Moment } from '@instructure/ui-i18n' import generateStyle from './styles' @@ -474,10 +475,23 @@ class Calendar extends Component { const currDate = DateTime.getFirstDayOfWeek( visibleMonth.clone().startOf('month') ) + const arr: Moment[] = [] for (let i = 0; i < Calendar.DAY_COUNT; i++) { - arr.push(currDate.clone()) - currDate.add({ days: 1 }) + + // This is needed because moment's `.add({days: 1})` function has a bug that happens when the date added lands perfectly onto the DST cutoff, + // in these cases adding 1 day results in 23 hours added instead, + // so `moment.tz('2024-09-07T00:00:00', 'America/Santiago').add({days: 1})` results + // in "Sat Sep 07 2024 23:00:00 GMT-0400" instead of "Sun Sep 08 2024 00:00:00 GMT-0400". + // which would cause duplicate dates in the calendar. + // More info on the bug: https://github.com/moment/moment/issues/4743 + if (currDate.clone().format('HH') === '23') { + arr.push(currDate.clone().add({hours: 1})) + } else { + arr.push(currDate.clone()) + } + + currDate.add({days: 1}) } return arr.map((date) => { const dateStr = date.toISOString() diff --git a/packages/ui-date-time-input/src/DateTimeInput/index.tsx b/packages/ui-date-time-input/src/DateTimeInput/index.tsx index 081d7149ee..4bbfcb2f7e 100644 --- a/packages/ui-date-time-input/src/DateTimeInput/index.tsx +++ b/packages/ui-date-time-input/src/DateTimeInput/index.tsx @@ -432,6 +432,17 @@ class DateTimeInput extends Component { for (let i = 0; i < Calendar.DAY_COUNT; i++) { arr.push(currDate.clone()) currDate.add({ days: 1 }) + + // This is needed because moment's `.add({days: 123})` function has a bug that happens when the date added lands perfectly onto the DST cutoff, + // in these cases adding 1 day results in 23 hours added instead, + // so `moment.tz('2024-09-07T00:00:00', 'America/Santiago').add({days: 1})` results + // in "Sat Sep 07 2024 23:00:00 GMT-0400" instead of "Sun Sep 08 2024 00:00:00 GMT-0400". + // which would cause duplicate dates in the calendar. + // More info and source for the workaround: https://github.com/moment/moment/issues/4743 + // currDate = DateTime.safeAddDays(currDate, 1) + if (currDate.clone().format('HH') === '23') { + currDate.add({hours: 1}) + } } return arr.map((date) => { const dateStr = date.toISOString() diff --git a/packages/ui-i18n/src/DateTime.ts b/packages/ui-i18n/src/DateTime.ts index 6b186c574f..eda413daf5 100644 --- a/packages/ui-i18n/src/DateTime.ts +++ b/packages/ui-i18n/src/DateTime.ts @@ -29,7 +29,7 @@ import moment, { Moment } from 'moment-timezone' * category: utilities/i18n * --- * @deprecated - * #### DEPRECATION WARNING: Will be removed in v9, which wil include a + * #### DEPRECATION WARNING: Will be removed in a future version, which will include a * time library agnostic API. * A wrapper for [moment](https://momentjs.com/) utils. * @module DateTime