date-fns / tz

date-fns timezone utils
MIT License
78 stars 6 forks source link

How to replace date-fns-tz #9

Open pascuflow opened 1 month ago

pascuflow commented 1 month ago

Since date-fns 4.1.0 now includes first-class time zone support, would like to stop using date-fns-tz but having trouble getting Date from TZDate in a simple way.

For example, how would one migrate toZonedTime?

import { toZonedTime } from 'date-fns-tz'

const { isoDate, timeZone } = fetchInitialValues() // 2014-06-25T10:00:00.000Z, America/New_York

const date = toZonedTime(isoDate, timeZone) // In June 10am UTC is 6am in New York (-04:00)

renderDatePicker(date) // 2014-06-25 06:00:00 (in the system time zone)
renderTimeZoneSelect(timeZone) // America/New_York

import { TZDate } from "@date-fns/tz";

const tzDate = new TZDate(isoDate, timeZone); <----- Need a Date object here
johnny-T commented 1 month ago

Hi, I am thinking about the same thing. We need something like toZonedTime from date-fns-tz, so I was trying to simulate it with TZDate. But after playing around with it, I found out some behaviours that do not seem intuitive to me when creating instances, so I wanted to share. For reference, I am working in Pacific Time zone (GMT-7) and creating dates in zone Europe/Prague (GMT+2). I am located in Prague but for testing purposes for our project, I switch to Pacific time using the Windows Date & time settings.

// this results in Sun Oct 20 2024 03:00:00 GMT+0200
new TZDate(2024, 9, 20, 3, 'Europe/Prague').toString()

// this is Sun Oct 20 2024 12:00:00 GMT+0200
new TZDate('2024-10-20 03:00:00', 'Europe/Prague').toString()

// and this is again Sun Oct 20 2024 03:00:00 GMT+0200
parse('2024-10-20 03:00:00', 'yyyy-MM-dd HH:mm:ss', TZDate.tz('Europe/Prague')).toString()

What I find mainly interesting is that the first two examples return different dates actually. The first one takes the number arguments and sets the month/day/hours in the target timezone. While the second one takes the string date and converts it from my local timezone to the target timezone, therefore adding 9 hours. The last example seems the most obvious in that the string is parsed using the reference date, which is in GMT+2.

So my question is, is this the intended behaviour? In our app, we would benefit from both approaches - converting a date to target timezone and creating a date in the target timezone. We would like to get rid of date-fns-tz, but there is no straightforward alternative in @date-fns/tz. What would be the correct approach to this? Should I perhaps create a separate issue for this?

Thanks :)

Garfield550 commented 1 month ago

I created my own simplified toZonedTime function using tzOffset from @date-fns/tz.

import { tzOffset } from '@date-fns/tz'

/**
 * Returns a date instance with values representing the local time in the time zone specified of the UTC time from the date provided.
 * In other words, when the new date is formatted it will show the equivalent hours in the target time zone regardless of the current system time zone.
 * Taken from date-fns-tz https://github.com/marnusw/date-fns-tz/blob/00b48b2cd4505a69203aac5734773114bce13204/src/toZonedTime/index.ts
 *
 * Why this function is needed:
 * - `date-fns-tz` v3.x is not compatible with `date-fns` v4.x yet, see: https://github.com/marnusw/date-fns-tz/issues/300
 * - `date-fns` now provides its own `@date-fns/tz` package
 * - `@date-fns/tz` doesn't have an equivalent of the `toZonedTime()` function, see: https://github.com/date-fns/tz/issues/9
 * - Unfortunately `toZonedTime()` is essential in our business logic
 *
 * @param date — the date with the relevant UTC time
 * @param timeZone — Time zone name (IANA or UTC offset)
 */
export function toZonedTime(date: Date | string | number, timeZone: string): Date {
  const _date: Date = typeof date === 'string' || typeof date === 'number' ? new Date(date) : date

  // The tzOffset function allows to get the time zone UTC offset in minutes from the given time zone and a date
  const offsetMilliseconds = tzOffset(timeZone, _date) * 60 * 1000

  const d = new Date(_date.getTime() + offsetMilliseconds)

  const resultDate = new Date(0)

  resultDate.setFullYear(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())
  resultDate.setHours(d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds())

  return resultDate
}
const today = toZonedTime(new Date(), 'Europe/Moscow')
KexyBiscuit commented 1 month ago

Hi, I am thinking about the same thing. We need something like toZonedTime from date-fns-tz, so I was trying to simulate it with TZDate. But after playing around with it, I found out some behaviours that do not seem intuitive to me when creating instances, so I wanted to share. For reference, I am working in Pacific Time zone (GMT-7) and creating dates in zone Europe/Prague (GMT+2). I am located in Prague but for testing purposes for our project, I switch to Pacific time using the Windows Date & time settings.

// this results in Sun Oct 20 2024 03:00:00 GMT+0200
new TZDate(2024, 9, 20, 3, 'Europe/Prague').toString()

// this is Sun Oct 20 2024 12:00:00 GMT+0200
new TZDate('2024-10-20 03:00:00', 'Europe/Prague').toString()

// and this is again Sun Oct 20 2024 03:00:00 GMT+0200
parse('2024-10-20 03:00:00', 'yyyy-MM-dd HH:mm:ss', TZDate.tz('Europe/Prague')).toString()

What I find mainly interesting is that the first two examples return different dates actually. The first one takes the number arguments and sets the month/day/hours in the target timezone. While the second one takes the string date and converts it from my local timezone to the target timezone, therefore adding 9 hours. The last example seems the most obvious in that the string is parsed using the reference date, which is in GMT+2.

So my question is, is this the intended behaviour? In our app, we would benefit from both approaches - converting a date to target timezone and creating a date in the target timezone. We would like to get rid of date-fns-tz, but there is no straightforward alternative in @date-fns/tz. What would be the correct approach to this? Should I perhaps create a separate issue for this?

Thanks :)

Might be related: #10

KexyBiscuit commented 1 month ago

Might be related: #6

johnny-T commented 1 month ago

Yes, the transpose function does the job, thanks, I completely missed that. So I assume that the different behaviour for different constructors is meant to be this way? It is slightly confusing that the resulting dates have different underlying timestamp, but I guess it can be somehow justified :).

ElectricCodeGuy commented 1 month ago

I just searched for all import { toZonedTime } from 'date-fns-tz' in my IDE and replaced it with import { TZDate } from "@date-fns/tz";

Then i searched for "toZonedTime" and replaced it with "new TZDate"

Everything seem to work as expected

johnny-T commented 1 month ago

That should work in most cases perhaps, but for example we get date string from backend that is without time zone and we need to interpret it as if it was in a particular time zone, no matter where the user is located. So we actually need the underlying milliseconds to change and for that the transpose function is great.

camsteffen commented 1 month ago
new TZDate('2024-10-20 03:00:00', 'Europe/Prague')

I think the underlying problem is that TZDate wants to be a thin wrapper around Date, and so that date/time string ends up getting parsed by the Date constructor, and then the resulting absolute time will be a result of the local system timezone. This is unfortunate because clearly you don't want anything to do with the local system timezone in that code.

tl;dr - use parseISO or parse