Migrate from date-fns-tz to @date-fns/tz (#26809)

* Add @date-fns/tz

* Update calc_date

* Refactor ha-date-range-picker

* Refactor calendar panel

* Refactor todo panel

* Remove date-fns-tz

* Cleanup

* Move util functions

* Fix comment

* Reuse

* Restore old check for rrulejs, update to new format
This commit is contained in:
Aidan Timson
2025-09-15 05:38:08 +01:00
committed by GitHub
parent 7ec3b08444
commit bf6eefb692
8 changed files with 113 additions and 91 deletions

View File

@@ -35,6 +35,7 @@
"@codemirror/search": "6.5.11",
"@codemirror/state": "6.5.2",
"@codemirror/view": "6.38.2",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.18.0",
"@formatjs/intl-displaynames": "6.8.11",
@@ -102,7 +103,6 @@
"cropperjs": "1.6.2",
"culori": "4.0.2",
"date-fns": "4.1.0",
"date-fns-tz": "3.2.0",
"deep-clone-simple": "1.1.1",
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",

View File

@@ -11,7 +11,7 @@ import {
differenceInDays,
addDays,
} from "date-fns";
import { toZonedTime, fromZonedTime } from "date-fns-tz";
import { TZDate } from "@date-fns/tz";
import type { HassConfig } from "home-assistant-js-websocket";
import type { FrontendLocaleData } from "../../data/translation";
import { TimeZone } from "../../data/translation";
@@ -22,12 +22,13 @@ const calcZonedDate = (
fn: (date: Date, options?: any) => Date | number | boolean,
options?
) => {
const inputZoned = toZonedTime(date, tz);
const fnZoned = fn(inputZoned, options);
if (fnZoned instanceof Date) {
return fromZonedTime(fnZoned, tz) as Date;
const tzDate = new TZDate(date, tz);
const fnResult = fn(tzDate, options);
if (fnResult instanceof Date) {
// Convert back to regular Date in the specified timezone
return new Date(fnResult.getTime());
}
return fnZoned;
return fnResult;
};
export const calcDate = (
@@ -65,7 +66,7 @@ export const calcDateDifferenceProperty = (
locale,
config,
locale.time_zone === TimeZone.server
? toZonedTime(startDate, config.time_zone)
? new TZDate(startDate, config.time_zone)
: startDate
);
@@ -144,3 +145,36 @@ export const shiftDateRange = (
}
return { start, end };
};
/**
* @description Parses a date in browser display timezone
* @param date - The date to parse
* @param timezone - The timezone to parse the date in
* @returns The parsed date as a Date object
*/
export const parseDate = (date: string, timezone: string): Date => {
const tzDate = new TZDate(date, timezone);
return new Date(tzDate.getTime());
};
/**
* @description Formats a date in browser display timezone
* @param date - The date to format
* @param timezone - The timezone to format the date in
* @returns The formatted date in YYYY-MM-DD format
*/
export const formatDate = (date: Date, timezone: string): string => {
const tzDate = new TZDate(date, timezone);
return tzDate.toISOString().split("T")[0];
};
/**
* @description Formats a time in browser display timezone
* @param date - The date to format
* @param timezone - The timezone to format the time in
* @returns The formatted time in HH:mm:ss format
*/
export const formatTime = (date: Date, timezone: string): string => {
const tzDate = new TZDate(date, timezone);
return tzDate.toISOString().split("T")[1].split(".")[0];
};

View File

@@ -2,7 +2,7 @@ import type { ActionDetail } from "@material/mwc-list/mwc-list-foundation";
import { mdiCalendar } from "@mdi/js";
import { isThisYear } from "date-fns";
import { fromZonedTime, toZonedTime } from "date-fns-tz";
import { TZDate } from "@date-fns/tz";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -275,8 +275,8 @@ export class HaDateRangePicker extends LitElement {
}
if (this.hass.locale.time_zone === TimeZone.server) {
start = fromZonedTime(start, this.hass.config.time_zone);
end = fromZonedTime(end, this.hass.config.time_zone);
start = new Date(new TZDate(start, this.hass.config.time_zone).getTime());
end = new Date(new TZDate(end, this.hass.config.time_zone).getTime());
}
if (
@@ -290,7 +290,7 @@ export class HaDateRangePicker extends LitElement {
private _formatDate(date: Date): string {
if (this.hass.locale.time_zone === TimeZone.server) {
return toZonedTime(date, this.hass.config.time_zone).toISOString();
return new TZDate(date, this.hass.config.time_zone).toISOString();
}
return date.toISOString();
}

View File

@@ -1,5 +1,5 @@
import { mdiCalendarClock } from "@mdi/js";
import { toDate } from "date-fns-tz";
import { TZDate } from "@date-fns/tz";
import { addDays, isSameDay } from "date-fns";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
@@ -143,8 +143,8 @@ class DialogCalendarEventDetail extends LitElement {
this.hass.locale.time_zone,
this.hass.config.time_zone
);
const start = toDate(this._data!.dtstart, { timeZone: timeZone });
const endValue = toDate(this._data!.dtend, { timeZone: timeZone });
const start = new TZDate(this._data!.dtstart, timeZone);
const endValue = new TZDate(this._data!.dtend, timeZone);
// All day events should be displayed as a day earlier
const end = isDate(this._data.dtend) ? addDays(endValue, -1) : endValue;
// The range can be shortened when the start and end are on the same day.

View File

@@ -5,7 +5,6 @@ import {
differenceInMilliseconds,
startOfHour,
} from "date-fns";
import { formatInTimeZone, toDate } from "date-fns-tz";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
@@ -40,6 +39,11 @@ import "../lovelace/components/hui-generic-entity-row";
import "./ha-recurrence-rule-editor";
import { showConfirmEventDialog } from "./show-confirm-event-dialog-box";
import type { CalendarEventEditDialogParams } from "./show-dialog-calendar-event-editor";
import {
formatDate,
formatTime,
parseDate,
} from "../../common/datetime/calc_date";
const CALENDAR_DOMAINS = ["calendar"];
@@ -303,28 +307,13 @@ class DialogCalendarEventEditor extends LitElement {
private _getLocaleStrings = memoizeOne(
(startDate?: Date, endDate?: Date) => ({
startDate: this._formatDate(startDate!),
startTime: this._formatTime(startDate!),
endDate: this._formatDate(endDate!),
endTime: this._formatTime(endDate!),
startDate: formatDate(startDate!, this._timeZone!),
startTime: formatTime(startDate!, this._timeZone!),
endDate: formatDate(endDate!, this._timeZone!),
endTime: formatTime(endDate!, this._timeZone!),
})
);
// Formats a date in specified timezone, or defaulting to browser display timezone
private _formatDate(date: Date, timeZone: string = this._timeZone!): string {
return formatInTimeZone(date, timeZone, "yyyy-MM-dd");
}
// Formats a time in specified timezone, or defaulting to browser display timezone
private _formatTime(date: Date, timeZone: string = this._timeZone!): string {
return formatInTimeZone(date, timeZone, "HH:mm:ss"); // 24 hr
}
// Parse a date in the browser timezone
private _parseDate(dateStr: string): Date {
return toDate(dateStr, { timeZone: this._timeZone! });
}
private _clearInfo() {
this._info = undefined;
}
@@ -349,8 +338,9 @@ class DialogCalendarEventEditor extends LitElement {
// Store previous event duration
const duration = differenceInMilliseconds(this._dtend!, this._dtstart!);
this._dtstart = this._parseDate(
`${ev.detail.value}T${this._formatTime(this._dtstart!)}`
this._dtstart = parseDate(
`${ev.detail.value}T${formatTime(this._dtstart!, this._timeZone!)}`,
this._timeZone!
);
// Prevent that the end time can be before the start time. Try to keep the
@@ -364,8 +354,9 @@ class DialogCalendarEventEditor extends LitElement {
}
private _endDateChanged(ev: CustomEvent) {
this._dtend = this._parseDate(
`${ev.detail.value}T${this._formatTime(this._dtend!)}`
this._dtend = parseDate(
`${ev.detail.value}T${formatTime(this._dtend!, this._timeZone!)}`,
this._timeZone!
);
}
@@ -373,8 +364,9 @@ class DialogCalendarEventEditor extends LitElement {
// Store previous event duration
const duration = differenceInMilliseconds(this._dtend!, this._dtstart!);
this._dtstart = this._parseDate(
`${this._formatDate(this._dtstart!)}T${ev.detail.value}`
this._dtstart = parseDate(
`${formatDate(this._dtstart!, this._timeZone!)}T${ev.detail.value}`,
this._timeZone!
);
// Prevent that the end time can be before the start time. Try to keep the
@@ -388,8 +380,9 @@ class DialogCalendarEventEditor extends LitElement {
}
private _endTimeChanged(ev: CustomEvent) {
this._dtend = this._parseDate(
`${this._formatDate(this._dtend!)}T${ev.detail.value}`
this._dtend = parseDate(
`${formatDate(this._dtend!, this._timeZone!)}T${ev.detail.value}`,
this._timeZone!
);
}
@@ -402,18 +395,18 @@ class DialogCalendarEventEditor extends LitElement {
dtend: "",
};
if (this._allDay) {
data.dtstart = this._formatDate(this._dtstart!);
data.dtstart = formatDate(this._dtstart!, this._timeZone!);
// End date/time is exclusive when persisted
data.dtend = this._formatDate(addDays(this._dtend!, 1));
data.dtend = formatDate(addDays(this._dtend!, 1), this._timeZone!);
} else {
data.dtstart = `${this._formatDate(
data.dtstart = `${formatDate(
this._dtstart!,
this.hass.config.time_zone
)}T${this._formatTime(this._dtstart!, this.hass.config.time_zone)}`;
data.dtend = `${this._formatDate(
)}T${formatTime(this._dtstart!, this.hass.config.time_zone)}`;
data.dtend = `${formatDate(
this._dtend!,
this.hass.config.time_zone
)}T${this._formatTime(this._dtend!, this.hass.config.time_zone)}`;
)}T${formatTime(this._dtend!, this.hass.config.time_zone)}`;
}
return data;
}

View File

@@ -1,5 +1,5 @@
import type { SelectedDetail } from "@material/mwc-list";
import { formatInTimeZone, toDate } from "date-fns-tz";
import { TZDate } from "@date-fns/tz";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -33,6 +33,7 @@ import {
ruleByWeekDay,
untilValue,
} from "./recurrence";
import { formatDate, formatTime } from "../../common/datetime/calc_date";
@customElement("ha-recurrence-rule-editor")
export class RecurrenceRuleEditor extends LitElement {
@@ -168,7 +169,9 @@ export class RecurrenceRuleEditor extends LitElement {
}
if (rrule.until) {
this._end = "on";
this._untilDay = toDate(rrule.until, { timeZone: this.timezone });
this._untilDay = new Date(
new TZDate(rrule.until, this.timezone).getTime()
);
} else if (rrule.count) {
this._end = "after";
this._count = rrule.count;
@@ -335,7 +338,7 @@ export class RecurrenceRuleEditor extends LitElement {
"ui.components.calendar.event.repeat.end_on.label"
)}
.locale=${this.locale}
.value=${this._formatDate(this._untilDay!)}
.value=${formatDate(this._untilDay!, this.timezone!)}
@value-changed=${this._onUntilChange}
></ha-date-input>
`
@@ -421,9 +424,9 @@ export class RecurrenceRuleEditor extends LitElement {
private _onUntilChange(e: CustomEvent) {
e.stopPropagation();
this._untilDay = toDate(e.detail.value + "T00:00:00", {
timeZone: this.timezone,
});
this._untilDay = new Date(
new TZDate(e.detail.value + "T00:00:00", this.timezone).getTime()
);
}
// Reset the weekday selected when there is only a single value
@@ -458,20 +461,22 @@ export class RecurrenceRuleEditor extends LitElement {
let contentline = RRule.optionsToString(options);
if (this._untilDay) {
// The UNTIL value should be inclusive of the last event instance
const until = toDate(
this._formatDate(this._untilDay!) +
const until = new TZDate(
formatDate(this._untilDay!, this.timezone!) +
"T" +
this._formatTime(this.dtstart!),
{ timeZone: this.timezone }
formatTime(this.dtstart!, this.timezone!),
this.timezone
);
// rrule.js can't compute some UNTIL variations so we compute that ourself. Must be
// in the same format as dtstart.
const format = this.allDay ? "yyyyMMdd" : "yyyyMMdd'T'HHmmss";
const newUntilValue = formatInTimeZone(
until,
this.hass.config.time_zone,
format
);
let newUntilValue;
if (this.allDay) {
// For all-day events, only use the date part
newUntilValue = until.toISOString().split("T")[0].replace(/-/g, "");
} else {
// For timed events, include the time part
newUntilValue = until.toISOString().replace(/[-:]/g, "").split(".")[0];
}
contentline += `;UNTIL=${newUntilValue}`;
}
return contentline.slice(6); // Strip "RRULE:" prefix
@@ -492,16 +497,6 @@ export class RecurrenceRuleEditor extends LitElement {
);
}
// Formats a date in browser display timezone
private _formatDate(date: Date): string {
return formatInTimeZone(date, this.timezone!, "yyyy-MM-dd");
}
// Formats a time in browser display timezone
private _formatTime(date: Date): string {
return formatInTimeZone(date, this.timezone!, "HH:mm:ss");
}
static styles = css`
ha-textfield,
ha-select {

View File

@@ -1,4 +1,4 @@
import { formatInTimeZone, toDate } from "date-fns-tz";
import { TZDate } from "@date-fns/tz";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -239,7 +239,8 @@ class DialogTodoItemEditor extends LitElement {
// Formats a date in specified timezone, or defaulting to browser display timezone
private _formatDate(date: Date, timeZone: string = this._timeZone!): string {
return formatInTimeZone(date, timeZone, "yyyy-MM-dd");
const tzDate = new TZDate(date, timeZone);
return tzDate.toISOString().split("T")[0]; // Get YYYY-MM-DD format
}
// Formats a time in specified timezone, or defaulting to browser display timezone
@@ -247,14 +248,15 @@ class DialogTodoItemEditor extends LitElement {
date: Date,
timeZone: string = this._timeZone!
): string | undefined {
return this._hasTime
? formatInTimeZone(date, timeZone, "HH:mm:ss")
: undefined; // 24 hr
if (!this._hasTime) return undefined;
const tzDate = new TZDate(date, timeZone);
return tzDate.toISOString().split("T")[1].split(".")[0]; // Get HH:mm:ss format
}
// Parse a date in the browser timezone
private _parseDate(dateStr: string): Date {
return toDate(dateStr, { timeZone: this._timeZone! });
const tzDate = new TZDate(dateStr, this._timeZone!);
return new Date(tzDate.getTime());
}
private _checkedCanged(ev) {

View File

@@ -1330,6 +1330,13 @@ __metadata:
languageName: node
linkType: hard
"@date-fns/tz@npm:1.4.1":
version: 1.4.1
resolution: "@date-fns/tz@npm:1.4.1"
checksum: 10/062097590005cce3da4c7d9880f9c77d386cff5b4dd58fa3dde3c346a8b2e4f4a8025a613306351a7cad8eb71178a0f67b4840d5884f73aa4c759085fac92063
languageName: node
linkType: hard
"@egjs/hammerjs@npm:2.0.17":
version: 2.0.17
resolution: "@egjs/hammerjs@npm:2.0.17"
@@ -7082,15 +7089,6 @@ __metadata:
languageName: node
linkType: hard
"date-fns-tz@npm:3.2.0":
version: 3.2.0
resolution: "date-fns-tz@npm:3.2.0"
peerDependencies:
date-fns: ^3.0.0 || ^4.0.0
checksum: 10/8ab4745f00b40381220f0a7a2ec16e217cb629d4018a19047264d289dd260322baa23e19b3ed63c7e553f9ad34bea9dea105391132930a3e141e9a0a53e54af2
languageName: node
linkType: hard
"date-fns@npm:4.1.0":
version: 4.1.0
resolution: "date-fns@npm:4.1.0"
@@ -9292,6 +9290,7 @@ __metadata:
"@codemirror/search": "npm:6.5.11"
"@codemirror/state": "npm:6.5.2"
"@codemirror/view": "npm:6.38.2"
"@date-fns/tz": "npm:1.4.1"
"@egjs/hammerjs": "npm:2.0.17"
"@formatjs/intl-datetimeformat": "npm:6.18.0"
"@formatjs/intl-displaynames": "npm:6.8.11"
@@ -9388,7 +9387,6 @@ __metadata:
cropperjs: "npm:1.6.2"
culori: "npm:4.0.2"
date-fns: "npm:4.1.0"
date-fns-tz: "npm:3.2.0"
deep-clone-simple: "npm:1.1.1"
deep-freeze: "npm:0.0.1"
del: "npm:8.0.0"