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

date-fns-tz v2 proposal #36

Closed
marnusw opened this issue Dec 28, 2019 · 25 comments
Closed

date-fns-tz v2 proposal #36

marnusw opened this issue Dec 28, 2019 · 25 comments

Comments

@marnusw
Copy link
Owner

marnusw commented Dec 28, 2019

These are the proposed functions for date-fns-tz version 2. Comments are welcome.

parseUTC

Parse a date string representing UTC time to a Date instance. This is a reexport of date-fns/parseJSON with a more semantically relevant name.

localToZonedTime

Transform a Date instance in the local system time zone, i.e. when it is passed to format it would show the local time, to a zoned Date instance of the target time zone, i.e. when passed to format it would show the equivalent time in that time zone.

This is achieved by modifying the underlying timestamp of the Date. In other words, since a JS Date can only ever be in the system time zone, the way to fake another time zone is to change the timestamp.

Matching the convention set by date-fns this function does not accept string arguments. A date string should be parsed with parseUTC, parseISO or parse first.

zonedToLocalTime

Transform a Date instance representing the time in some time zone other than the local system time zone to a Date instance with the equivalent value in the local system time zone, i.e. formatting the result or calling .toString() will show the equivalent local time.

This is achieved by modifying the underlying timestamp of the Date. It is the inverse of localToZonedTime, so in this case it is assumed the input is faking another time zone, and the timestamp is changed to be correct in the current local time zone.

Matching the convention set by date-fns this function does not accept string arguments. A date string should be parsed with parse first.

utcToZonedTime

This is an alias of localToZonedTime which can be used when it makes better semantic sense, such as when a UTC date received from an API is being parsed for display in a targed time zone. Since a UTC time will usually be provided as a string value, this function accepts UTC date strings that can be parsed by the parseUTC function and does so internally.

zonedTimeToUTC

This is an alias of zonedToLocalTime which can be used when it makes better semantic sense, such as when the intent is to save the UTC time of a date to an API. The resulting Date which formats correctly in the system time zone has the desired internal UTC time, and thus the actual UTC value can be obtained from zonedTimeToUTC(...).toISOString() or .getTime().

format

Full time zone formatting support, without any modification to the date instance. (No changes from format in v1, except that it will no longer accept string inputs.)

formatAsZonedTime

A combination of utcToZonedTime (or localToZonedTime) and format. In other words the date will be transformed to the target time zone specified in the options prior to formatting. Since utcToZonedTime is used internally the date argument can also be a string that can be parsed by parseUTC.

parseISO

As in date-fns the previous implementation of toDate in date-fns-tz@1 has been renamed to parseISO. It extends the date-fns version of this function with better time zone support. It is a rather large function, though, and should only be used when dates are not in ISO format or date strings represent a time zone other than UTC time. When this is not the case parseUTC should be used instead.

@dcousens, @kroleg

@kroleg
Copy link

kroleg commented Dec 28, 2019

since a JS Date can only ever be in the system time zone

I think that JS Date actually always UTC (because it only stores timestamps inside) and it appears as it is in system time zone because when you call Date.toString() or any other date formatting function JS adds system timezone offset and formats timezoned date. Instance of Date represents a point in time and should be treated as such i.e. there should be no need to add offsets only for formatting purpose.
So from this point of view i think that date-fns-tz needs to export only 2 methods: parseFromTimezone(datetime: string, timeZone: string) and formatAsZonedTime(datetime: Date, { timeZone, ...otherDateFnsOptions).

@dcousens
Copy link
Contributor

dcousens commented Dec 29, 2019

I think reasoning about local or UTC is confusing. It is a Date, with it's semantics.
How about the following?

shiftDateTo(date, timeZone)

Shift date such that date.valueOf is changed by the target time zone's relative offset to the local time.

For example, if in Australia (AEDT)

const fiveAm = new Date('2019-12-29 05:00') // 5am in local time
const result = shiftDateTo(fiveAm, 'Asia/Tokyo')

// result is 3am in local time (Australia), 5am in Tokyo

unshiftDateFrom(date, timeZone)

Unshift date such that date.valueOf changes to "return" to local time a date that is wrong or was missing timezone information. For example, the input was kept in a timezone ambiguous format like 2019-12-29 05:00 that is was local to Asia/Tokyo, and you need the time in actual local time.

const fiveAm = new Date('2019-12-29 05:00') // 5am in local time
const result = unshiftDateFrom(fiveAm, 'Asia/Tokyo')

// result is 7am in local time (Australia), 5am in Tokyo

That way, the following identity should hold

const a = new Date('2019-12-29 05:00') // 5am in local time
const b = shiftDateTo(a, 'Asia/Tokyo') // 3am in local time
const c = unshiftDateFrom(b, 'Asia/Tokyo') // 5am in local time

edit: renamed again

@kroleg
Copy link

kroleg commented Dec 29, 2019

@dcousens why would you shift date in the first place? Isn't it easier to just pass a time zone whe you parse string date like parseFromTimeZone('2019-12-29 05:00', 'Asia/Tokyo')?

@dcousens
Copy link
Contributor

dcousens commented Dec 29, 2019

@kroleg I'll try and outline a few scenarios.

Scenario 1

Imagine we have a server in Asia/Tokyo, and a user in Australia/Sydney.
I want to see if their last post was after 11am user time.

const lastPostedAt = new Date(user.posts[0].createdAt)
const local11am = new Date()
local11am.setHours(11)
local11am.setMinutes(0)
local11am.setSeconds(0)
local11am.setMilliseconds(0)

const users11am = shiftDateTo(local11am, user.timezone)

if (lastPostedAt > users11am) {
   // the last post was after 11am user-time on this calendar day
} 
Scenario 2

For a second scenario, I have a friend in San Francisco that has provided me with a date and time string 2019-12-30 14:00 for a web meeting; and I know their timezone is US/Pacific.

I need to know what the local time for that web meeting would be so I can attend.

const provided = new Date(`2019-12-30 14:00`)
const local = unshiftDateFrom(provided, `US/Pacific`)

// local is 9am on Tuesday 31st October (local time)

Arguably, yes, the second scenario could use parseFromTimeZone, however, there are complicated instances that I unfortunately need/want to use the Date instead.

@kroleg
Copy link

kroleg commented Dec 29, 2019

@dcousens for scenario 1 there is a much easier way to get local time:

const local11am = parseFromTimeZone('11:00', user.timezone);

And then you compare Date instances lastPostedAt > users11am

Edit: As i mentioned above Date is just a timestamp. There are only 2 Date use cases: 1) you need to parse date (from string or timestamp). 2) You need to display date in some time zone

@dcousens
Copy link
Contributor

dcousens commented Dec 29, 2019

@kroleg 11:00 is an Invalid Date.

I don't always use today's calendar date.

I don't want to transform a Date instance to a string accounting for local time myself. I am using this library for that.

@kroleg
Copy link

kroleg commented Dec 30, 2019

@dcousens you gave me 2 problems and i told you how you can solve them with only 2 functions. Do you have another problems/secenarios you forgot to mention?

11:00 is an Invalid Date.

parseFromTimezone fucntion may assume "today" if date is missing (not sure if it does right now)

I don't always use today's calendar date.

then pass full datetime like parseFromTimezone('2019-10-11 11:10')

@dcousens
Copy link
Contributor

dcousens commented Dec 30, 2019

You actually haven't provided 2 solutions, you've merely pointed at a string-parsing equivalent of shiftDateTo and claimed it can resolve both problem(s).

Please post a working solution for when you have a Date object that has a valueOf that is in some other's local time, not UTC - see Scenario 2, but imagine all you are given is the provided date object, no strings.

@marnusw
Copy link
Owner Author

marnusw commented Jan 2, 2020

Thank you both for the discussion so far.

I believe it makes sense to have functions for shifting Date instances as well as functions with string input/outputs, i.e. parse and format. Firstly, the former is a requirement to implement the latter and as such might as well be exposed as low level functions for use when it makes sense. Aside from the examples given by @dcousens (which might be implementable starting/ending with strings, but depending on preference in a less elegant way), a further issue would be use alongside other libraries. Most notably date picker elements often take Date instances as input/output, so it must be possible to work with Dates.

I like the idea of not having aliased functions but rather two shifting functions that don't reference local/UTC if that is confusing (and I agree it can be). The suggestions are good, and I'll give the names a bit more thought.

Other than those I'm starting to like the idea of then overriding the format, parse, parseISO, and parseJSON as parseUTC functions from date-fns to add built in time zone support, i.e. they will shift the dates and format/parse in one step. Then these could take the time zone as an explicit parameter rather than on the options, which will also make them better suited for convenient functional programming variants.

@dcousens
Copy link
Contributor

dcousens commented Jan 2, 2020

Most notably date picker elements often take Date instances as input/output, so it must be possible to work with Dates.

I somehow failed to convey that this is exactly the scenario as to why strings were not suitable 👍 - 3rd party libraries.

@kyrylkov
Copy link

kyrylkov commented Mar 2, 2020

formatAsZonedTime is very welcome, because currently it looks like it must be done as below:

format(
  utcToZonedTime(dateStringInUtc, TIME_ZONE),
  DATE_FORMAT,
  {timeZone: TIME_ZONE},
);

vs

moment.tz(dateStringInUtc, TIME_ZONE).format(DATE_FORMAT)

The time zone must be specified twice, which is overly verbose.

Correct me if I'm wrong.

@jgonera
Copy link

jgonera commented Mar 11, 2020

I'd love date-fns-tz to be just a wrapper around format, parse, parseISO, parseJSON (and perhaps a few other, like startOfDay, etc.). I would probably not rename parseJSON to parseUTC. I agree that the name is awkward but it might be confusing for date-fns-tz and date-fns to use different nomenclature.

I'd like to add my two cents about the shifting functions. Even though the shifting logic is necessary to use Intl for formatting/parsing it still seems confusing to me to expose the shifting functions as a part of the official API. My main issue with them is that they basically return sort of a "broken" Date object that by itself doesn't really contain all the data necessary to understand what time it is representing.

JavaScript's Date, as @kroleg mentioned, is internally always UTC. That makes it unambiguous what point in time it represents. Unfortunately, Date does not allow storing any time zone information, so shifting the time in it puts it in a weird state when we no longer can be sure what point in time it represents without the context it was created in (and that context can be easily lost in bigger code bases).

I do understand @dcousens's argument that these functions could be useful for integrating with other libraries. In my opinion, it would be less confusing if they were clearly marked as a more of a plumbing API rather than the primary API of date-fns-tz. Perhaps they could be exported as date-fns/compat or similar with docs stating their purpose (and the problem with the Date objects they returned that I mentioned above).

An alternative approach would be to provide a wrapper class around Date that contains the time zone, but that would go against date-fns philosophy.

@marnusw
Copy link
Owner Author

marnusw commented Mar 12, 2020

Thanks for your thoughts @jgonera. The point about renaming parseJSON is well taken.

I don't think I'll put the shifting functions in a separate date-fns-tz/compat, but it does make sense to treat them more like plumbing functions than the primary API. I think the key is to never shift a date and then pass it around in a large code base. Shifting should happen when dates go into or come out of the UI, and that's about it. This can be reflected in the documentation of those functions.

@cben
Copy link

cben commented Aug 2, 2020

I'm just starting to use date-fns[-tz] and I love all of it ❤️ except utcToZonedTime / zonedTimeToUtc scare me 😟
Normally I think of Date as a moment in time, with accessor methods that just happen to compute calendar day/hour/... components in local TZ.
But when shifting, I'd be also passing around "naive" Date that no longer represent a moment in time at all, but rather a struct of calendar day/hour/... components. It feels dangerous to mix both semantic types represented by same class.

I agree there are low-level use cases for the 2nd "calendar" meaning, I just wish the API was shaped differently so code using the 1st vs 2nd meaning look different...

Actually, in a sense the base format from date-fns is already interpretting its Date parameter in the "calendar components" sense. That's subjective — it also fits the "moment+local interpretation" sense — but if you think of the time-zone format patterns it supports (XX..X, xx..x, zz..z, OO..O) those are weird because they don't represent anything about the passed-in Date! They always represent the system TZ.
And the extended format from date-fns-tz collapses the ambiguity towards "calendar" meaning by supporting a timeZone param that overrides the timezone, but does NOT adjust the time:

> require('date-fns').format(new Date(), 'HH:mm XXX')
'18:46 +03:00'
> require('date-fns').format(new Date(), 'HH:mm XXX', {timeZone: 'Asia/Singapore'})  // just ignored
'18:46 +03:00'
> require('date-fns-tz').format(new Date(), 'HH:mm XXX')
'18:46 +03:00'
> require('date-fns-tz').format(new Date(), 'HH:mm XXX', {timeZone: 'Asia/Singapore'})
'18:46 +08:00'
> require('date-fns-tz').format(new Date(), 'HH:mm XXX', {timeZone: 'UTC'})
'18:46 Z'

@cben
Copy link

cben commented Aug 3, 2020

Thinking/reading more about it, I see many functions in date-fns treat dates in "calendar" sense, eg. startOfDay (date-fns/date-fns#669).
These don't make sense as "function of a moment" because they're timezone-dependent, but they do mostly make sense as "function of calendaric date&time". So they can be mostly used via shifting, except that gets problematic for those moments when some date/time just didn't exist in some timezone (or exists twice), due to DST / one-off political jumps :-(

@mkaatman
Copy link

Agree @cben a date is a moment in time that accounts for DST as well.

moment can handle ISO8601 timestamps with offset such as 2013-02-01T12:52:34+09:00

I would expect a date library to format that timestamp into a specific timezone that was passed in as a 2nd parameter.

@nfantone
Copy link

nfantone commented Oct 4, 2020

How would one go about parsing a date string with a known format zoned to a known TZ? Please correct me if I'm wrong but, as I understand it, there's not built-in way (neither in the current version or the proposed v2 API) to do this.

Here's how you would accomplish this in other popular date handling libraries:

// dayjs using `timezone` plugin
dayjs.tz("11-20-2020", "America/Los_Angeles").format();
// '2020-11-20T00:00:00-08:00'
// moment using `moment-timezone`
moment.tz('11-20-2020', 'MM-DD-YYYY', 'America/Los_Angeles').format();
// '2020-11-20T00:00:00-08:00'
DateTime.fromFormat('11-20-2020', 'MM-dd-yyyy', { zone: 'America/Los_Angeles' }).toISO();
// '2020-11-20T00:00:00-08:00'

IMHO, this is a fairly basic use case scenario when it comes to timezones and date-fns should provide a way of allowing for this.

@marnusw
Copy link
Owner Author

marnusw commented Dec 17, 2021

@nfantone the functionality you are after is possible by using the parse function from date-fns:

import {parse} from 'date-fns'
import {format, zonedTimeToUtc} from 'date-fns-tz'

const dateForLA = parse('11-20-2020', 'MM-dd-yyyy', new Date()) // new Date is a required arg but ignored, see date-fns#2849

// We know this date represents the time in LA, so it is passed to format as is,
// with the `timeZone` needed to get the correct value for the xxx token
const output = format(dateForLA, 'yyyy-MM-dd\'T\'HH:mm:ssxxx', { timeZone: 'America/Los_Angeles' })
console.log(output) // 2020-11-20T00:00:00-08:00

// If you want to send the proper UTC time to a server or save to a database, use
const date = zonedTimeToUtc(parsed, 'America/Los_Angeles')
console.log(date.toISOString()) // 2020-11-20T08:00:00.000Z

The question is whether there is a way to make this more intuitive.

Edit:

After thinking about it this is probably the proper way of doing it in v2:

import {parse, formatAsZonedTime} from 'date-fns-tz'
// or parseAsZonedTime?

const date = parse('11-20-2020', 'America/Los_Angeles', 'MM-dd-yyyy')
console.log(date.toISOString()) // 2020-11-20T08:00:00.000Z

const output = formatAsZonedTime(date, 'America/Los_Angeles', 'yyyy-MM-dd\'T\'HH:mm:ssxxx')
console.log(output) // 2020-11-20T00:00:00-08:00

@marnusw marnusw pinned this issue Dec 17, 2021
@nfantone
Copy link

@marnusw Hi! Thanks for your answer.

May be so - honestly, it's been over a year since I posted the above. Regardless, the fact that you even had to think about how to go about parsing a zoned data kinda goes in the same line as my original point.

@marnusw
Copy link
Owner Author

marnusw commented Dec 20, 2021

@nfantone O I see. When I looked at it I thought it was this past October.

Thank you for your input, though, it has made me think and contributes toward the final API.

@vladshcherbin
Copy link

vladshcherbin commented Feb 18, 2022

Not sure if it's possible now, get GMT version of timezone offset: smth('Africa/Casablanca') -> GMT+01:00.

I believe it's OOOO in format, but I have no idea how to get it with just string timezone, an example would be also great.

@marnusw
Copy link
Owner Author

marnusw commented Feb 19, 2022

@vladshcherbin use format with any Date and time zone on the options and just pass OOOO as the format string.

@vladshcherbin
Copy link

@marnusw the thing is, I only have a timezone, just Africa/Casablanca.

Maybe there could be a simple helper for such things, e.g. formatTimezone('Africa/Casablanca', 'OOOO') -> GMT+01:00

What do you think?

@mkaatman
Copy link

Africa/Casablanca is a timezone.

+01:00 is an offset.

@cben
Copy link

cben commented Feb 20, 2022

Timezones change their offsets over time, regularly due to daylight-savings, and irregularly due to political decisions...
Here is an example of winter vs summer difference:

> require('date-fns-tz').format(new Date(2021, 12), 'OOOO', {timeZone: 'Asia/Jerusalem'})
'GMT+02:00'
> require('date-fns-tz').format(new Date(2021, 06), 'OOOO', {timeZone: 'Asia/Jerusalem'})
'GMT+03:00'

So a simple mapping TZ->offset is ill-defined. You can pass new Date() for present offset, but beware that writing code that treats such offset as the offset for a particular TZ risks being buggy if it later applies it to any other timestamp.

@marnusw marnusw closed this as not planned Won't fix, can't repro, duplicate, stale Mar 30, 2024
@marnusw marnusw unpinned this issue Apr 6, 2024
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

9 participants