Skip to content

Commit

Permalink
UIEXT-941: Work around date-fns-tz issues
Browse files Browse the repository at this point in the history
See the open issue here: marnusw/date-fns-tz#302

UIEXT-941 (Date&Time widget displays always browser timezone)
  • Loading branch information
pbaern committed Nov 19, 2024
1 parent d198d03 commit e8870fb
Show file tree
Hide file tree
Showing 4 changed files with 247 additions and 53 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { DateTimeInput } from "@knime/components/date-time-input";
import ErrorMessage from "../baseElements/text/ErrorMessage.vue";
import { getLocalTimeZone, updateTime } from "@knime/utils";
import { format, zonedTimeToUtc, utcToZonedTime } from "date-fns-tz";
import { format } from "date-fns-tz";
import { fromZonedTime, toZonedTime } from "@/util/widgetUtil/dateTime";
/**
* DateTimeWidget.
Expand Down Expand Up @@ -109,7 +110,7 @@ export default {
return this.parseKnimeDateString(this.value.datestring);
},
dateObject() {
return zonedTimeToUtc(this.dateValue.datestring, this.timezone);
return fromZonedTime(this.dateValue.datestring, this.timezone);
},
timezone() {
return this.dateValue.zonestring;
Expand All @@ -126,9 +127,9 @@ export default {
this.viewRep.min,
);
if (this.viewRep.useminexectime) {
return zonedTimeToUtc(this.execTime, this.localTimeZone);
return this.execTime;
}
return zonedTimeToUtc(datestring, zonestring);
return fromZonedTime(datestring, zonestring);
}
return null;
},
Expand All @@ -138,9 +139,9 @@ export default {
this.viewRep.max,
);
if (this.viewRep.usemaxexectime) {
return zonedTimeToUtc(this.execTime, this.localTimeZone);
return this.execTime;
}
return zonedTimeToUtc(datestring, zonestring);
return fromZonedTime(datestring, zonestring);
}
return null;
},
Expand All @@ -161,7 +162,9 @@ export default {
* @returns {{zonestring: String, datestring: String}}
*/
parseKnimeDateString(dateAndZoneString) {
let match = dateAndZoneString.match(/(.+)\[(.+)]/) || [null, "", ""];
let match = dateAndZoneString.match(
/(.+)(?:Z|[+-]\d\d:?(?:\d\d)?)\[(.+)]/,
) || [null, "", ""];
return {
datestring: match[1],
zonestring: match[2],
Expand All @@ -171,8 +174,11 @@ export default {
return format(date, "yyyy-MM-dd'T'HH:mm:ss.SSS");
},
onChange(date, timezone) {
let zonedDate = utcToZonedTime(date, timezone);
let value = this.formatDate(zonedDate);
let zonedDate = toZonedTime(date, timezone);
// this.formatDate takes the local timezone into account, so we do not want to use it here
let value = zonedDate.toISOString().replace("Z", "");
this.dateValue.datestring = value;
this.dateValue.zonestring = timezone;
this.publishUpdate(value, timezone);
},
publishUpdate(datestring, zonestring) {
Expand All @@ -190,7 +196,17 @@ export default {
this.onChange(date, this.timezone);
},
onTimezoneChange(timezone) {
this.onChange(this.dateObject, timezone);
const existingTimeAsZonedTime = toZonedTime(
this.dateObject,
this.timezone,
);
const shiftedTime = fromZonedTime(existingTimeAsZonedTime, timezone);
/**
* Calling
* this.onChange(this.dateObject, timezone);
* would instead update the date object with the new timezone, which is not what we want.
*/
this.onChange(shiftedTime, timezone);
},
nowButtonClicked() {
let now = new Date(Date.now());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,7 @@ describe("DateTimeWidget.vue", () => {
});

describe("events and actions", () => {
it("emits @updateWidget if timezone changes", () => {
it("emits @updateWidget if timezone changes while keeping the local time", () => {
let wrapper = mount(DateTimeWidget, {
props: propsAll,
stubs: {
Expand All @@ -495,8 +495,11 @@ describe("DateTimeWidget.vue", () => {
expect(
wrapper.emitted("updateWidget")[1][0].update[
"viewRepresentation.currentValue"
].zonestring,
).toBe("Asia/Bangkok");
],
).toStrictEqual({
zonestring: "Asia/Bangkok",
datestring: "2020-05-03T09:54:55.000",
});
});

it("now button sets date, time and timezone to current values and location", () => {
Expand Down Expand Up @@ -556,47 +559,72 @@ describe("DateTimeWidget.vue", () => {
).toBe(0);
});

it("emits @updateWidget if DateTimeInput emits @input", () => {
let wrapper = mount(DateTimeWidget, {
props: propsAll,
stubs: {
"client-only": "<div><slot /></div>",
},
...context,
});

const testValue = "2020-10-14T13:32:45.153";
const input = wrapper.findComponent(DateTimeInput);
input.vm.$emit("update:modelValue", new Date(testValue));

expect(wrapper.emitted("updateWidget")).toBeTruthy();
expect(wrapper.emitted("updateWidget")[1][0]).toStrictEqual({
nodeId: propsAll.nodeId,
update: {
"viewRepresentation.currentValue": {
datestring: testValue,
zonestring: "Europe/Rome",
it.each([
{
timezone: "UTC",
offset: 0,
},
{
timezone: "Europe/Rome",
offset: 2,
},
])(
"emits @updateWidget if DateTimeInput emits @input",
({ timezone, offset }) => {
let wrapper = mount(DateTimeWidget, {
props: {
...propsAll,
valuePair: {
datestring: "2020-01-01T00:00:00.000",
zonestring: timezone,
},
},
},
});
});
stubs: {
"client-only": "<div><slot /></div>",
},
...context,
});

const input = wrapper.findComponent(DateTimeInput);
const testHours = 13;
input.vm.$emit(
"update:modelValue",
Date.UTC(2020, 9, 14, testHours, 32, 45, 153),
);

expect(wrapper.emitted("updateWidget")).toBeTruthy();
expect(wrapper.emitted("updateWidget")[0][0]).toStrictEqual({
nodeId: propsAll.nodeId,
update: {
"viewRepresentation.currentValue": {
// Shifted due to the offset of the
datestring: `2020-10-14T${testHours + offset}:32:45.153`,
zonestring: timezone,
},
},
});
},
);
});

describe("methods", () => {
it("parses knime date and timezone strings", () => {
let wrapper = mount(DateTimeWidget, {
props: propsAll,
stubs: {
"client-only": "<div><slot /></div>",
},
...context,
});
const res = wrapper.vm.parseKnimeDateString(
"2020-10-10T13:32:45.153[Europe/Berlin]",
);
expect(res.datestring).toBe("2020-10-10T13:32:45.153");
expect(res.zonestring).toBe("Europe/Berlin");
});
it.each(["+02:00", "+02", "+0200", "Z"])(
"parses knime date and timezone strings",
(offset) => {
let wrapper = mount(DateTimeWidget, {
props: propsAll,
stubs: {
"client-only": "<div><slot /></div>",
},
...context,
});
const res = wrapper.vm.parseKnimeDateString(
`2020-10-10T13:32:45.153${offset}[Europe/Rome]`,
);
expect(res.datestring).toBe("2020-10-10T13:32:45.153");
expect(res.zonestring).toBe("Europe/Rome");
},
);

it("parses broken knime date and timezone strings", () => {
let wrapper = mount(DateTimeWidget, {
Expand All @@ -606,7 +634,9 @@ describe("DateTimeWidget.vue", () => {
},
...context,
});
const res = wrapper.vm.parseKnimeDateString("2020-10-10T13:32:45.153[");
const res = wrapper.vm.parseKnimeDateString(
"2020-10-10T13:32:45.153[UTC]",
);
expect(res.datestring).toBe("");
expect(res.zonestring).toBe("");
});
Expand Down Expand Up @@ -645,7 +675,7 @@ describe("DateTimeWidget.vue", () => {
it("invalidates if min bound is not kept", () => {
propsAll.nodeConfig.viewRepresentation.usemin = true;
propsAll.nodeConfig.viewRepresentation.min =
"2020-10-10T13:32:45.153[Europe/Berlin]";
"2020-10-10T13:32:45.153+02:00[Europe/Berlin]";
propsAll.nodeConfig.viewRepresentation.usemax = false;
let wrapper = mount(DateTimeWidget, {
props: propsAll,
Expand All @@ -665,7 +695,7 @@ describe("DateTimeWidget.vue", () => {
it("invalidates if max bound is not kept", () => {
propsAll.nodeConfig.viewRepresentation.usemax = true;
propsAll.nodeConfig.viewRepresentation.max =
"2020-04-10T13:32:45.153[Europe/Berlin]";
"2020-04-10T13:32:45.153+02:00[Europe/Berlin]";
propsAll.nodeConfig.viewRepresentation.usemin = false;
let wrapper = mount(DateTimeWidget, {
props: propsAll,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { fromZonedTime, toZonedTime } from "..";

describe("fromZonedTime", () => {
const createUTCDate = ({
year = 2021,
monthIndex = 0,
day = 1,
hours = 0,
minutes = 0,
seconds = 0,
milliseconds = 0,
}: {
year?: number;
monthIndex?: number;
day?: number;
hours?: number;
minutes?: number;
seconds?: number;
milliseconds?: number;
} = {}) => {
return new Date(
Date.UTC(year, monthIndex, day, hours, minutes, seconds, milliseconds),
);
};

it.each([fromZonedTime, toZonedTime])(
"is idempotent for a UTC date to itself",
(fromOrToZonedTime) => {
const utcTime = createUTCDate();
expect(fromOrToZonedTime(utcTime, "UTC")).toStrictEqual(utcTime);
},
);

const cetUtcPairs = [
[
"winter",
{
cetTime: createUTCDate({ hours: 23 }),
utcTime: createUTCDate({ hours: 22 }),
},
] as const,
[
"summer",
{
cetTime: createUTCDate({ monthIndex: 6, day: 1, hours: 0 }),
utcTime: createUTCDate({ monthIndex: 5, day: 30, hours: 22 }),
},
] as const,
];

it.each(cetUtcPairs)(
"should convert CET to UTC in the %s",
(_, { cetTime, utcTime }) => {
expect(fromZonedTime(cetTime, "CET")).toStrictEqual(utcTime);
},
);

it.each(cetUtcPairs)(
"should convert UTC to CET in the %s",
(_, { cetTime, utcTime }) => {
expect(toZonedTime(utcTime, "CET")).toStrictEqual(cetTime);
},
);

it("can convert a string to a zoned date", () => {
const { utcTime, cetTime } = cetUtcPairs[0][1];
const cetTimeString = cetTime.toISOString();
expect(fromZonedTime(cetTimeString, "CET")).toStrictEqual(utcTime);
expect(fromZonedTime(cetTimeString.replace("Z", ""), "CET")).toStrictEqual(
utcTime,
);
});

it("takes offsets in strings into account", () => {
expect(fromZonedTime("2021-01-01T00:00:00+01:00", "UTC")).toStrictEqual(
createUTCDate({ hours: -1 }),
);
});
});
68 changes: 68 additions & 0 deletions org.knime.js.pagebuilder/src/util/widgetUtil/dateTime/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { toDate, type OptionsWithTZ } from "date-fns-tz";
// @ts-expect-error
import tzParseTimezone from "@@/node_modules/date-fns-tz/_lib/tzParseTimezone";
// @ts-expect-error
import tzPattern from "@@/node_modules/date-fns-tz/_lib/tzPattern";
/**
* This method is used to circumvent the following open issue in date-fns-tz
* https://github.com/marnusw/date-fns-tz/issues/302
*
* The problem is that the zonedTimeToUtc returns a Date object so that
* when e.g. getHours is called, the respective UTC time hours are returned.
* But since getHours depends on the systems timezone,
* the actual underlying UTC time is shifted accordingly.
*
*
* The code is an adapted version of date-fns-tz 3.2.0
* https://www.npmjs.com/package/date-fns-tz?activeTab=code
* /date-fns-tz/dist/cjs/fromZonedTime/index.js
*/
export const fromZonedTime = (
date: string | Date,
timeZone: string,
options?: OptionsWithTZ,
) => {
// Same code
if (typeof date === "string" && !date.match(tzPattern)) {
return toDate(
date,
Object.assign(Object.assign({}, options), { timeZone }),
);
}
date = toDate(date, options);
/**
* Here we differ. Original code:
const utc = newDateUTC(date.getFullYear(), date.getMonth(), date.getDate(),
date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds())
.getTime();
const offsetMilliseconds = tzParseTimezone(timeZone, new Date(utc));
return new Date(utc + offsetMilliseconds);
*/
const offsetMilliseconds = tzParseTimezone(timeZone, date);
return new Date(date.getTime() + offsetMilliseconds);
};

/**
* Similarly to fromTimeZone, we need this method to replace the utcToZonedTime method,
* since this method is the inverse of the (incorrect) zonedTimeToUtc method.
*
* The code is an adapted version of date-fns-tz 3.2.0
* https://www.npmjs.com/package/date-fns-tz?activeTab=code
* /date-fns-tz/dist/cjs/toZonedTime/index.js
*/
export const toZonedTime = (
date: string | Date,
timeZone: string,
options?: OptionsWithTZ,
) => {
date = toDate(date, options);
const offsetMilliseconds = tzParseTimezone(timeZone, date, true);
return new Date(date.getTime() - offsetMilliseconds);
/**
* The original code does not return here but instead assigns what return to a variable d and transforms this:
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;
*/
};

0 comments on commit e8870fb

Please sign in to comment.