Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Timezone Switching and TimeGutter Slots #1478

Closed
cutterbl opened this issue Oct 3, 2019 · 14 comments
Closed

Timezone Switching and TimeGutter Slots #1478

cutterbl opened this issue Oct 3, 2019 · 14 comments

Comments

@cutterbl
Copy link
Collaborator

cutterbl commented Oct 3, 2019

So, I'm trying to write support for timezone switching for my calendar. I have a lot of things working, based on comments in an issue. The one thing I cannot get though is the proper min/max time display in the TimeGutter, and a current now. I'm using the momentLocalizer like so:

// in util file
export const getMoment = timezone => {
  const m = (...args) => moment.tz(...args, timezone);
  m.localeData = moment.localeData;
  return m;
};

// in my calendar wrapper
const moment = getMoment(timezone);
const localizer = momentLocalizer(moment);
// ...
<Calendar
  localizer={localizer}

This covers a good chunk of what I'm trying to do. When I switch timezones, my events shift accordingly to their proper timeslot. What fails is in using min and max to set the starting and ending hour timeslots on my day and week views. These take JS date objects, even though it only appears to need the 'time' (and really the 24 hour hour). Initially I set these like so:

min={new Date(1970,1,1,7,0)}
max={new Date(1970,1,1,18,0)}

I also use getNow to set the current datetime at render. This also takes a JS date object, and thoughts were that you needed to convert this to the timezone as well:

const convert = m => new Date(m.year(), m.month(), m.date(), m.hour(), m.minute());
...
getNow={() => convert(moment())} // remember, moment now does tz automatically ^

That didn't do it either. I then saw someone referenced using defaultDate instead of getNow:

defaultDate={convert(moment())}

No such luck. What I see is that 'right now' it's October 3rd in Chicago, but 'right now' is October 4th in Japan. No matter what I do the highlighted date is always 'my' (Chicago) 'right now'.

So back to times. I then thought that maybe I needed to convert my min and max, using the timezone, and then set the hour:

min={moment().hour(5).minute(0).toDate()}
max={moment().hour(18).minute(0).toDate()}

and that caused complete chaos. Seems to be unnecessary. Especially once I started looking at code...

I got into TimeGrid and found that dates.merge() was going to create a new Date object to pass to the TimeGutter, and in the TimeGutter I found that the slotMetrics set up each group. So I went to find out how the slotMetrics were built and found how it adjusted for DST offset (which is based on the JS Date object, and always relative to current browser date in it's offset).

So, my localizer couldn't help me. Or, if it can I have no idea how. The localizer isn't used for any of these calculations, so they become moot. Or so it seems. If there is a way to do this, I haven't found it yet. Can anyone help? What combination of settings and localizer solve all of these issues?

General Real-World Use Case:
I have an organization that manages scheduling clinic appointments for multiple clinics in multiple timezones. The scheduler managing that scheduling must see a clinic's schedule in that clinic's timezone. So if the clinic is in Knoxville (Eastern Time), and is open from 7AM to 5PM, the scheduler (sitting in Memphis, which is Central Time) must see that clinic's hours as 7AM - 5AM.
Should Happen: user views Knoxville clinic schedule, and hours are 7am-5pm
Currently Happens: user views Knoxville clinic (which automatically switches tz to Eastern time) and the hours show 8am - 4pm

@cutterbl
Copy link
Collaborator Author

cutterbl commented Oct 4, 2019

Here's a codesandbox that shows exactly what I'm talking about. Timezone will default to user current timezone, and you can switch timezones using the Select at the top of the screen. Event start and end times adjust accordingly, but you'll notice the timeslot min/max switch out as well.

@cutterbl
Copy link
Collaborator Author

cutterbl commented Oct 4, 2019

I figured out the magic sauce for getting the TimeGutter correct. The core issue was that now and my min and max JS Date objects didn't have any offset applied for the selected timezone. I was able to fix this by passing my moment (with timezone) into my createDate/Time methods, and changing them to create dates using moment's format() method. The moment library's default format() output is in ISO 8601 format, which includes the offset, and is the right way to set new Date().

utils.js

/**
 * We do this to get a copy of moment w/timezones without polluting the global scope
 */
let moment = require('moment');
// moment-timezone makes changes to existing moment object, returning adjusted
moment = require('moment-timezone/builds/moment-timezone-with-data-2012-2022');

// in case you need the raw moment
export const moment;

export const currentTimezone = moment.tz.guess();
/**
 * This will create a 'moment' object that *is* moment.tz(), and automatically use the
 * 'timezone' used when you called 'getMoment()'
 */
export const getMoment = (timezone = currentTimezone) => {
  const m = (...args) => moment.tz(...args, timezone);
  m.localeData = moment.localeData;
  return m;
};

/**
 * 'datetime' is a JS Date object
 * 'tzMoment is the 'moment' object you got from 'getMoment()'
 */
export const convertDateTimeToDate = (datetime, tzMoment) => {
  return new Date(tzMoment(datetime).format()); // sets Date using ISO 8601 format
};

/**
 * 'hour' is an integer from 0 - 23 specifying the hour to set on the Date
 * 'tzMoment is the 'moment' object you got from 'getMoment()'
 */
export const getTimeAsDate = (hour, tzMoment) => {
  const m = tzMoment('1970-01-01');
  return new Date(
    m
      .hour(hour)
      .minute(0)
      .format()
  );
};

/*
 * 'now' is your 'getNow' method
 * 'tzMoment is the 'moment' object you got from 'getMoment()'
 */
export const getNow = (now, tzMoment) => convertDateTimeToDate(now(), tzMoment);

Scheduler.component.js

import React, { useState } from 'react';
import {
  Calendar as Component,
  momentLocalizer,
  Views
} from 'react-big-calendar';
import withDragAndDrop from 'react-big-calendar/lib/addons/dragAndDrop';
import {
  currentTimezone,
  getMoment,
  getNow,
  getTimeAsDate
} from '../utils';
// this converts my date strings to JS Date objects (local time)
import useNormalizedDates from './useNormalizedDates.hook';

const Calendar = withDragAndDrop(Component);
const views = [Views.DAY, Views.WEEK, Views.MONTH];

const Scheduler = ({
  timezone = currentTimezone,
  now = () => new Date(),
  events = [],
  ...props
}) => {
  const [calEvents, setCalEvents] = useState([]);
  // convert my db date strings to true JS Date objects
  useNormalizedDates(events, setCalEvents);

  const moment = getMoment(timezone);
  const localizer = momentLocalizer(moment);
  return (
    <div className="scheduler-container">
      <Calendar
        localizer={localizer}
        events={calEvents}
        views={views}
        defaultView={Views.WEEK}
        startAccessor="start"
        endAccessor="end"
        titleAccessor="patient"
        getNow={() => getNow(now, moment)}
        min={getTimeAsDate(7, moment)}
        max={getTimeAsDate(18, moment)}
      />
    </div>
  );
};

export default Scheduler;

Hope this helps someone else fight the good fight. I created a GitHub project too.

@cutterbl cutterbl closed this as completed Oct 4, 2019
@cutterbl
Copy link
Collaborator Author

cutterbl commented Feb 18, 2020

OK, I was close ^, unless your start and end occur across different days. To give you a good example of what I mean:

let's say I want to show 24 hours of slots on my Calendar, I can do this:

min={getTimeAsDate(0,moment)}
max={getTimeAsDate(23,moment)}

and, if my timezone is the same as my browser local I am perfectly fine. That's because my date conversions make:

start: Thu Jan 01 1970 00:00:00 GMT-0600 (Central Standard Time)
end: Thu Jan 01 1970 23:00:00 GMT-0600 (Central Standard Time)

But let's say I want to set my timezone to America/Los Angeles. This then gives me:

start: Wed Dec 31 1969 02:00:00 GMT-0600 (Central Standard Time)
end: Thu Jan 01 1970 01:00:00 GMT-0600 (Central Standard Time)

Looking at the date portion, you see we now span across two separate days. But that's alright, right? Well, no. As you can see below, this totally messes with the TimeGrid:
Screen Shot 2020-02-18 at 3 05 11 PM
I had to really dig, to see what was happening, and finally came up with the culprit. TimeGrid, in it's renderEvents() method, includes the DayColumn and the TimeGutter with the following attributes:

min={dates.merge(date, min)}
max={dates.merge(date, max)}

In this the date is the current range array item value, and min and max are what you see above. The problem lies in the dates.merge() that comes from utils/dates.js. It 'merges' a date, and a time, without allowing for offset. So changing the timezone sets min and max correctly, but the DayColumn components gets:

start: Sun Feb 10 2019 02:00:00 GMT-0600 (Central Standard Time)
end: Sun Feb 10 2019 01:00:00 GMT-0600 (Central Standard Time)

As you can see, without the offset this is very, very wrong. These values hit the TimeSlot.js getSlotMetrics() method and blow it all up.

@cutterbl cutterbl reopened this Feb 18, 2020
@cutterbl
Copy link
Collaborator Author

cutterbl commented Feb 1, 2021

@jquense I sent you an email, a few weeks back, asking about sponsoring full timezone support in RBC. Did you receive it?

@jquense
Copy link
Owner

jquense commented Feb 1, 2021

@cutterbl My guess is that the issue is the date manipulation library doesn't preserve or handle TZs correctly. One possible approach to fixing that is abstract manipulation (optionally) into the localizers, so that add/subtract etc. can be provided by the underlying date library. This would probably be pretty straightforward for Moment, since date-arithmetic copies it's API. For other localizers if the methods are implemented just fall back to the current behavior etc

@egorkavin
Copy link

@cutterbl Hi, any updates?

@cutterbl
Copy link
Collaborator Author

@egorkavin I haven't had any time to look into this myself. It's on my radar to take a pass at it in the future, if somebody doesn't get there first.

@nlevchuk
Copy link
Contributor

@cutterbl Hi. Thank you for your efforts of supporting time zones. This is really hard work.

Do you have any issues with dates where DST begins/ends? Let me try to explain

I have Mountain Daylight Time (-6) now and it will move back to 1 hour on November 7th and will be Mountain Standard Time (-7).

I have the following code chunk:

import moment from 'moment-timezone';

moment.tz.setDefault('Canada/Mountain');
momentLocalizer(moment);

const getDateByMinutesOffset = (date, offsetInMin) => {
  return moment(date).startOf('day').add(time, 'minutes').toDate();
};
...
const currentDate = moment('2021-11-07T05:00:00-06:00');
const min = getDateByMinutesOffset(currentDate, 420); // => 7:00am
const max = getDateByMinutesOffset(currentDate, 840); // => 2:00pm

<Calendar
  ...
  localizer={localizer}
  min={min}
  max={max}
/>

And it gives me wrong 1-hour back shift on the calendar because time zone calculation going through DST shift on the 7th of Nov.

The shift issue is under the moment package responsibility. I checked out moment docs and it says:

There are also special considerations to keep in mind when adding time that crosses over daylight saving time. If you are adding years, months, weeks, or days, the original hour will always match the added hour.

But

If you are adding hours, minutes, seconds, or milliseconds, the assumption is that you want precision to the hour, and will result in a different hour.

https://momentjs.com/docs/#/manipulating/add/

It looks correct for math calculation but for human schedule where people need to get up at 7am every morning doesn't.

So we don't have a way to add minutes to the beginning of the day (the day when DST begins/ends) with preserving hours.

Maybe you have ran into similar issue? Thanks

@cutterbl
Copy link
Collaborator Author

@nlevchuk Moment-Timezone automatically handles offset shifts for DST changes in math calculations. While you are trying to set the min and max, with Moment you don't need to manually add the minutes, you just need to set the hour.

import moment from 'moment-timezone'; // I'd use a specific 'data' set here

moment.tz.setDefault('Canada/Mountain');
momentLocalizer(moment);

...
// Moment is not immutable, so adjustments would be cumulative.
// Always clone a base prior to applying an adjustment
const currentDate = moment('2021-11-07T05:00:00-06:00').startOf('day');
const min = moment(currentDate).hour(7).toDate(); // => 7:00am
const max = moment(currentDate).hour(14).toDate(); // => 2:00pm

<Calendar
  ...
  localizer={localizer}
  min={min}
  max={max}
/>

@nlevchuk
Copy link
Contributor

nlevchuk commented Oct 26, 2021

@cutterbl

I store some meaningful data as minutes since the beginning of a day. I need either to convert it to hours and minutes every time when I do some math calculation or revise my architecture.

Anyway thanks for reply!

PS
Two days in a year break everything 😅

@cutterbl
Copy link
Collaborator Author

Yeah, I have to say that Luxon is better for those cross TZ calculations, though it does require a flag. Can't remember all of the details though.

@nlevchuk
Copy link
Contributor

Yeah, I have to say that Luxon is better for those cross TZ calculations, though it does require a flag. Can't remember all of the details though.

Thanks

@Gaurav7004
Copy link

Is it possible to stop overlapping of multiple events in single time slot?

@cutterbl
Copy link
Collaborator Author

@Gaurav7004 Yes, using the dayLayoutAlgorithm prop

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants