iamkun / dayjs

⏰ Day.js 2kB immutable date-time library alternative to Moment.js with the same modern API
https://day.js.org
MIT License
46.34k stars 2.27k forks source link

Is there an equivalent for moment.parseZone() in DayJS? #651

Open joetidee opened 4 years ago

joetidee commented 4 years ago

Is there an equivalent for moment.parseZone() in DayJS?

iamkun commented 4 years ago

Any demo code, please?

DdZ-Fred commented 4 years ago

I'm also interested in this feature. You'll find below a copy of the moment.parseZone doc, It will be clearer this way I think.

Moment's string parsing functions like moment(string) and moment.utc(string) accept offset information if provided, but convert the resulting Moment object to local or UTC time. In contrast, moment.parseZone() parses the string but keeps the resulting Moment object in a fixed-offset timezone with the provided offset in the string.

moment.parseZone("2013-01-01T00:00:00-13:00").utcOffset(); // -780 ("-13:00" in total minutes)
moment.parseZone('2013 01 01 05 -13:00', 'YYYY MM DD HH ZZ').utcOffset(); // -780  ("-13:00" in total minutes)
moment.parseZone('2013-01-01-13:00', ['DD MM YYYY ZZ', 'YYYY MM DD ZZ']).utcOffset(); // -780  ("-13:00" in total minutes);

It also allows you to pass locale and strictness arguments.

moment.parseZone("2013 01 01 -13:00", 'YYYY MM DD ZZ', true).utcOffset(); // -780  ("-13:00" in total minutes)
moment.parseZone("2013-01-01-13:00", 'YYYY MM DD ZZ', true).utcOffset(); // NaN (doesn't pass the strictness check)
moment.parseZone("2013 01 01 -13:00", 'YYYY MM DD ZZ', 'fr', true).utcOffset(); // -780 (with locale and strictness argument)
moment.parseZone("2013 01 01 -13:00", ['DD MM YYYY ZZ', 'YYYY MM DD ZZ'], 'fr', true).utcOffset(); // -780 (with locale and strictness argument alongside an array of formats)

moment.parseZone is equivalent to parsing the string and using moment#utcOffset to parse the zone.

var s = "2013-01-01T00:00:00-13:00";
moment(s).utcOffset(s);
matchifang commented 3 years ago

I tried dayjs('2013-01-01T00:00:00-13:00').format('YYYY MM DD ZZ') and it seems to give the same result as moments' parseZone()

addisonElliott commented 3 years ago

@matchifang Your snippet does not produce the same results as moment.parseZone.

The following test using DayJS v1.10.0:

console.log(dayjs('2013-01-01T00:00:00-13:00').format('YYYY MM DD ZZ'));
console.log(moment.parseZone('2013-01-01T00:00:00-13:00').format('YYYY MM DD ZZ'));
console.log(dayjs('2013-01-01T00:00:00-13:00').format());
console.log(moment.parseZone('2013-01-01T00:00:00-13:00').format());

returns this:

2013 01 01 +0000
2013 01 01 -1300
2013-01-01T13:00:00+00:00
2013-01-01T00:00:00-13:00

moment.parseZone keeps the timezone passed to it, while DayJS does not. This is the issue with using DayJS. It will remove the timezone offset and you essentially lose this information. On the browser, this probably won't matter, but with NodeJS applications, there are times you want to preserve the UTC offset.

Solution

I came up with a plugin that mimics moment.parseZone. This requires the UTC plugin because that adds support for storing UTC offset. In other words, you can get/set the offset via dayjs.utcOffset. With this logic, my plugin is doing the following dayjs(date).utcOffset(offset).

I use Typescript so below is a Typescript function and definition file for adding dayjs.create which mimics moment.parseZone. You can rename it if you want. Also, if you don't use Typescript, it's trivial to remove type definitions.

Note: I don't use the official plugin setup because it's pretty trivial code.

dayjs.ts

dayjs.create = function(date?: dayjs.ConfigType) {
    if (typeof date === 'string') {
        const match = date.match(ISO8601_OFFSET_FORMAT);
        if (match !== null) {
            if (match[0] === 'Z') {
                return dayjs(date, {
                    utc: true,
                    // eslint-disable-next-line prefer-rest-params
                    args: arguments,
                } as dayjs.OptionType);
            } else if (match[0][0] === '+') {
                const hour = parseInt(match[2]);
                const minute = parseInt(match[3]);

                return dayjs(match[1], {
                    $offset: hour * 60 + minute,
                    // eslint-disable-next-line prefer-rest-params
                    args: arguments,
                } as dayjs.OptionType);
            } else {
                const hour = parseInt(match[2]);
                const minute = parseInt(match[3]);

                return dayjs(match[1], {
                    $offset: hour * -60 + minute,
                    // eslint-disable-next-line prefer-rest-params
                    args: arguments,
                } as dayjs.OptionType);
            }
        }
    }

    return dayjs(date, {
        // eslint-disable-next-line prefer-rest-params
        args: arguments,
    } as dayjs.OptionType);
};

types/dayjs.d.ts

import dayjs from 'dayjs';

declare module 'dayjs' {
    // Create datetime while preserving the timezone specified
    // Only use this if the timezone needs to be preserved
    export function create(date?: dayjs.ConfigType): dayjs.Dayjs;
    export function create(date?: dayjs.ConfigType, format?: dayjs.OptionType, strict?: boolean): dayjs.Dayjs;
    export function create(date?: dayjs.ConfigType, format?: dayjs.OptionType, locale?: string, strict?: boolean):
        dayjs.Dayjs;
}

export {};

Just import the dayjs.ts file and this will add the function.

Example usage

console.log(dayjs.create('2013-01-01T00:00:00-13:00').format('YYYY MM DD ZZ'));
console.log(moment.parseZone('2013-01-01T00:00:00-13:00').format('YYYY MM DD ZZ'));
console.log(dayjs.create('2013-01-01T00:00:00-13:00').format());
console.log(moment.parseZone('2013-01-01T00:00:00-13:00').format());

Results:

2013 01 01 -1300
2013 01 01 -1300
2013-01-01T00:00:00-13:00
2013-01-01T00:00:00-13:00

Benchmarking

Did some trivial benchmarking with the DayJS to see how much it costs to run this function. This was run on Debian Buster with Node.js. The point was to see how much of a performance hit it was from running the native dayjs function.

start = performance.now();
for (let i = 0; i < 1000000; ++i) {
    x = dayjs.utc('2020-12-10T00:00:00Z');
}
end = performance.now();
console.log(`10.1 took ${(end - start)}ms`);

start = performance.now();
for (let i = 0; i < 1000000; ++i) {
    x = dayjs.utc('2020-12-10T00:00:00-07:00');
}
end = performance.now();
console.log(`10.2 took ${(end - start)}ms`);

start = performance.now();
for (let i = 0; i < 1000000; ++i) {
    x = dayjs.utc('2020-12-10T00:00:00+07:00');
}
end = performance.now();
console.log(`10.3 took ${(end - start)}ms`);

start = performance.now();
for (let i = 0; i < 1000000; ++i) {
    x = dayjs.create('2020-12-10T00:00:00Z');
}
end = performance.now();
console.log(`10.4 took ${(end - start)}ms`);

start = performance.now();
for (let i = 0; i < 1000000; ++i) {
    x = dayjs.create('2020-12-10T00:00:00-07:00');
}
end = performance.now();
console.log(`10.5 took ${(end - start)}ms`);

start = performance.now();
for (let i = 0; i < 1000000; ++i) {
    x = dayjs.create('2020-12-10T00:00:00+07:00');
}
end = performance.now();
console.log(`10.6 took ${(end - start)}ms`);

Results:

10.1 took 478.73444271087646ms                 (0.478us per)
10.2 took 3002.3106746673584ms                 (3.002us per)
10.3 took 3019.467839241028ms                   (3.019us per)
10.4 took 750.308009147644ms                     (0.750us per)
10.5 took 1206.277268409729ms                   (1.206us per)
10.6 took 1155.0354480743408ms                 (1.155us per)

In summary, it's actually quicker to call dayjs.create with a non-UTC timezone because the Javascript Date class doesn't have to convert to UTC. But, it's slightly slower to call dayjs.create with a Z timezone.

Overall, the metrics seemed good to me so I'm not concerned about dayjs() versus dayjs.create(). Plenty of other bottlenecks in my applications than this.

How it works & implications

You can read the code if you like but I wanted to summarize how it works.

First, if the DayJS is not passed a string, it does nothing and forwards it to the regular dayjs() function.

Otherwise, it takes the string and applies a regex to it to search for the offset at the end (or Z). So it'll look for a string that ends with "Z" or "+/-hh:mm". Note it doesn't handle the fancy case where the minutes aren't included, so "+06" won't be valid. If no match is found, then string is forwarded to dayjs() function as normal.

If string ends with "Z", it essentially is like calling dayjs.utc(string).

If string ends with offset, it lops off that offset and uses that date string as the input to dayjs(). But, it calculates the offset (hours * 60 + minutes) and stores that offset in the DayJS object. If you call utcOffset it returns that number instead.

That's all it does.

Submit PR

I'm not really interested in submitting a PR for this. If anyone wants to go through the trouble, be my guest.

neodon commented 3 years ago

@matchifang I ran into this same thing and thought I might be crazy. It's because the time zone offset you are putting in happens to be the same as your local time zone offset. Change it to anything else and the results will be different for format().

L-K-Mist commented 3 years ago

@addisonElliott Yes, I'd like to go through the trouble. One question: Where does that ISO8601_OFFSET_FORMAT come from at 3rd line?

addisonElliott commented 3 years ago

Don't remember where it came from, but here's what I have.

const ISO8601_OFFSET_FORMAT = /^(.*)[+-](\d{2}):(\d{2})|(Z)$/;
L-K-Mist commented 3 years ago

Aah, thanks a bunch!

patgod85 commented 3 years ago

@matchifang Your snippet does not produce the same results as moment.parseZone.

The following test using DayJS v1.10.0: ...

Thanks for your code. But it works wrong for case with "plus" time zone:

Example usage

console.log(dayjs.create('2013-01-01T00:00:00-13:00').format('YYYY MM DD ZZ'));
console.log(dayjs.create('2013-01-01T00:00:00+13:00').format('YYYY MM DD ZZ'));

Results:

2013 01 01 -1300
2013 01 01 -1300

May be you've specified a wrong regexp here const ISO8601_OFFSET_FORMAT = /^(.*)[+-](\d{2}):(\d{2})|(Z)$/;

I offer to slightly change the code

const ISO8601_OFFSET_FORMAT = /^(.*)([+-])(\d{2}):(\d{2})|(Z)$/;

dayjs.create = function(date?: dayjs.ConfigType) {
    if (typeof date === 'string') {
        const match = date.match(ISO8601_OFFSET_FORMAT);
        if (match !== null) {
            if (match[0] === 'Z') {
                return dayjs(date, {
                    utc: true,
                    // eslint-disable-next-line prefer-rest-params
                    args: arguments,
                } as dayjs.OptionType);
            }
            const [, dateTime, sign, tzHour, tzMinute] = match;

            const h = parseInt(tzHour, 10);
            const m = parseInt(tzMinute, 10);
            const uOffset = h * 60 + m;
            const offset = sign === '+' ? uOffset : -uOffset;

            return dayjs(dateTime, {
                $offset: offset,
                // eslint-disable-next-line prefer-rest-params
                args: arguments,
            } as dayjs.OptionType);
        }
    }

    return dayjs(date, {
        // eslint-disable-next-line prefer-rest-params
        args: arguments,
    } as dayjs.OptionType);
};
emrysal commented 2 years ago

The fact that Dayjs does not provide the option to keep the utcOffset is in my opinion a bug, in a Next application it happens often that you want to get a string in the same format client side and server side and this makes that significantly harder - even if you know the time zone the user is in. It would be good to have a flag that allows keeping the UTC offset.

When you parse a string using Dayjs you should expect the same value in as out:

parse(2021-09-15T03:00:00+03:00) => format()=2021-09-15T03:00:00+03:00 // expected
parse(2021-09-15T03:00:00+03:00) => format()=2021-09-15T00:00:00+00:00 // reality
viktorio2 commented 2 years ago

I would like to submit a PR. @L-K-Mist are you working on this?

L-K-Mist commented 2 years ago

Please go ahead @viktorio2 , we ended up keeping momentJs for the time being. If you could get it done, it would be much appreciated all around. :+1:

emrysal commented 2 years ago

This may be useful - I had an explicit requirement not to adopt MomentJS, so I opted to write a userspace decorator adding zone support whilst parsing:

https://github.com/calendso/calendso/blob/main/lib/parseZone.ts

wh1t3h47 commented 2 years ago

Thank you for your contribution @viktorio2, I made a few modifications and I'm using this idea in typescript from an external project, it just works!

import * as dayjs from 'dayjs';

const ISO8601_OFFSET_FORMAT = /^(.*)([+-])(\d{2}):(\d{2})|(Z)$/;

/**
 * @see https://github.com/iamkun/dayjs/issues/651#issuecomment-763033265
 * decorates dayjs in order to keep the utcOffset of the given date string
 * natively dayjs auto-converts to local time & losing utcOffset info.
 */
export function parseZone(
  date?: dayjs.ConfigType,
  format?: dayjs.OptionType,
  locale?: string,
  strict?: boolean,
) {
  if (typeof format === 'string') {
    format = { format: format };
  }
  if (typeof date !== 'string') {
    return dayjs(date, format, locale, strict);
  }
  const match = date.match(ISO8601_OFFSET_FORMAT);
  if (match === null) {
    return;
  }
  if (match[0] === 'Z') {
    return dayjs(
      date,
      {
        utc: true,
        ...format,
      },
      locale,
      strict,
    );
  }
  const [, dateTime, sign, tzHour, tzMinute] = match;
  const uOffset: number = parseInt(tzHour, 10) * 60 + parseInt(tzMinute, 10);
  const offset = sign === '+' ? uOffset : -uOffset;

  return dayjs(
    dateTime,
    {
      $offset: offset,
      ...format,
    } as unknown as dayjs.OptionType,
    locale,
    strict,
  );
}

export type DayjsParseZone = { parseZone: (d: Date | string) => dayjs.Dayjs };

In order to invoke this functionality, I use:

// Assigns parseZone to dayJs, this is necessary cause dayJs is not aware of timezones
  (dayjs as unknown as DayjsParseZone).parseZone = parseZone;

  const parsed = (dayjs as unknown as DayjsParseZone).parseZone(
    agenda.date.end,
  );
pinonpierre commented 2 years ago

Hi, Awesome. It should be integrated in the main library or at least as a plugin

IGx89 commented 2 years ago

Thanks @wh1t3h47 for the TypeScript conversion! I've taken it a step further myself and converted into a dayjs plugin, here's the code in case it helps anyone:

import dayjs from "dayjs";
import type { Dayjs, PluginFunc } from "dayjs";

const REGEX_TIMEZONE_OFFSET_FORMAT = /^(.*)([+-])(\d{2}):(\d{2})|(Z)$/;

/**
 * @see https://github.com/iamkun/dayjs/issues/651#issuecomment-763033265
 * decorates dayjs in order to keep the utcOffset of the given date string
 * natively dayjs auto-converts to local time & losing utcOffset info.
 */
const pluginFunc: PluginFunc<unknown> = (
  option: unknown,
  dayjsClass: typeof Dayjs,
  dayjsFactory: typeof dayjs
) => {
  dayjsFactory.parseZone = function (
    date?: dayjs.ConfigType,
    format?: dayjs.OptionType,
    locale?: string,
    strict?: boolean
  ) {
    if (typeof format === "string") {
      format = { format: format };
    }
    if (typeof date !== "string") {
      return dayjs(date, format, locale, strict);
    }
    const match = date.match(REGEX_TIMEZONE_OFFSET_FORMAT);
    if (match === null) {
      return dayjs();
    }
    if (match[0] === "Z") {
      return dayjs(
        date,
        {
          utc: true,
          ...format,
        },
        locale,
        strict
      );
    }
    const [, dateTime, sign, tzHour, tzMinute] = match;
    const uOffset: number = parseInt(tzHour, 10) * 60 + parseInt(tzMinute, 10);
    const offset = sign === "+" ? uOffset : -uOffset;

    return dayjs(
      dateTime,
      {
        $offset: offset,
        ...format,
      } as unknown as dayjs.OptionType,
      locale,
      strict
    );
  };
};

export default pluginFunc;

declare module "dayjs" {
  function parseZone(
    date?: dayjs.ConfigType,
    format?: dayjs.OptionType,
    locale?: string,
    strict?: boolean
  ): dayjs.Dayjs;
}

Usage:

import dayjs from "dayjs";
import parseZone from "@/helpers/parseZone";

dayjs.extend(parseZone);

const parsedDate = dayjs.parseZone("2016-12-21T07:01:21-08:00");

expect(parsedDate.format("MMMM D, YYYY, h:mm A")).toBe("December 21, 2016, 7:01 AM");
LucaColonnello commented 1 year ago

@IGx89 amazing work! Do you mind if I take your code and contribute back to dayjs adding it in a PR?

IGx89 commented 1 year ago

@LucaColonnello That would be great, I'm surprised no one else has done so yet. I can only take 1% of the credit though, people earlier in this thread get most of the credit for the logic :)

LucaColonnello commented 1 year ago

@IGx89 I'm going to keep the issue in description ad say credit goes to all people that contributed here!

steve-sargent commented 3 months ago

Is this thread still being watched?

I noticed that @LucaColonnello merged a plugin for parseZone, but I still am not seeing it in the latest npm modules.

This is still a necessary feature if dayJS is to replace moment.

uladzimirdev commented 2 months ago

@steve-sargent it's not merged https://github.com/iamkun/dayjs/pull/2060

proceau commented 4 weeks ago

The plugin it's not commit on https://cdn.jsdelivr.net/npm/dayjs@1/plugin/parseZone.js for use in replace of moment It's dead ?