Compare commits

...

1 Commits

Author SHA1 Message Date
Petar Petrov aa5e92b04f Fix wrong month in date picker calendar header 2026-08-03 11:43:45 +03:00
4 changed files with 99 additions and 71 deletions
+23
View File
@@ -294,3 +294,26 @@ export const formatCallyDateRange = (
return `${startDate}/${endDate}`;
};
/**
* August 2021, for calendar days coming out of cally.
*
* cally hands out calendar days as `Date` objects anchored to UTC midnight, and
* formats its own headings in UTC too. Anything derived from a cally event has
* to be formatted the same way: the resolved time zone shifts the day back for
* negative UTC offsets, which becomes a wrong month — and in January a wrong
* year — whenever the day is the 1st.
*/
export const formatCallyMonthYear = (
dateObj: Date,
locale: FrontendLocaleData
) => formatCallyMonthYearMem(locale.language).format(dateObj);
const formatCallyMonthYearMem = memoizeOne(
(language: string) =>
new Intl.DateTimeFormat(language, {
month: "long",
year: "numeric",
timeZone: "UTC",
})
);
+18 -36
View File
@@ -9,8 +9,8 @@ import { customElement, property, queryAll, state } from "lit/decorators";
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
import {
formatCallyDateRange,
formatDateMonth,
formatDateYear,
formatCallyMonthYear,
formatDateMonthYear,
formatISODateOnly,
} from "../../common/datetime/format_date";
import { transform } from "../../common/decorators/transform";
@@ -56,13 +56,16 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
})
private _hassConfig!: HassConfig;
/** used to show month in calendar-range header */
@state() private _pickerMonth?: string;
/** used to show month and year in calendar-range header */
@state() private _pickerMonthYear?: string;
/** used to show year in calendar-date header */
@state() private _pickerYear?: string;
/** used for today to navigate focus in calendar-range */
/**
* used for today to navigate focus in calendar-range
*
* Always mirrors the day calendar-range has focused, never cleared: once this
* has held a date, rendering `undefined` over it makes cally fall back to the
* selected range and page the calendar away from where the user is.
*/
@state() private _focusDate?: string;
@state() private _dateValue?: string;
@@ -88,12 +91,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._hassConfig
)
: undefined;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
this._pickerMonthYear = formatDateMonthYear(
date,
this._i18n.locale,
this._hassConfig
@@ -163,9 +161,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
slot="previous"
></ha-icon-button-prev>
<div class="heading" slot="heading">
<span class="month-year"
>${this._pickerMonth} ${this._pickerYear}</span
>
<span class="month-year">${this._pickerMonthYear}</span>
<ha-icon-button
@click=${this._focusToday}
.path=${mdiCalendarToday}
@@ -229,12 +225,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._i18n.locale,
this._hassConfig
);
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
this._pickerMonthYear = formatDateMonthYear(
date,
this._i18n.locale,
this._hassConfig
@@ -308,24 +299,15 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
}
private _focusChanged(ev: CustomEvent<Date>) {
const date = ev.detail;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._focusDate = undefined;
const dateElement = ev.target as HTMLElementTagNameMap["calendar-range"];
this._pickerMonthYear = formatCallyMonthYear(ev.detail, this._i18n.locale);
this._focusDate = dateElement.focusedDate;
}
private _handleChange(ev: CustomEvent) {
const dateElement = ev.target as HTMLElementTagNameMap["calendar-range"];
this._dateValue = dateElement.value;
this._focusDate = undefined;
this._focusDate = dateElement.focusedDate;
}
private _clickDateRangeChip(ev: Event) {
@@ -6,7 +6,8 @@ import type { HassConfig } from "home-assistant-js-websocket/dist/types";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import {
formatDateMonth,
formatCallyMonthYear,
formatDateMonthYear,
formatDateShort,
formatDateYear,
formatISODateOnly,
@@ -56,13 +57,16 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
dateString: string;
};
/** used to show month in calendar-date header */
@state() private _pickerMonth?: string;
/** used to show month and year in calendar-date header */
@state() private _pickerMonthYear?: string;
/** used to show year in calendar-date header */
@state() private _pickerYear?: string;
/** used for today to navigate focus in cally-calendar-date */
/**
* used for today to navigate focus in cally-calendar-date
*
* Always mirrors the day calendar-date has focused, never cleared: once this
* has held a date, rendering `undefined` over it makes cally fall back to the
* selected value and page the calendar away from where the user is.
*/
@state() private _focusDate?: string;
public connectedCallback() {
@@ -71,12 +75,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
if (this.params) {
const date = valueToDate(this.params.value);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerMonth = formatDateMonth(
this._pickerMonthYear = formatDateMonthYear(
date,
this._i18n.locale,
this._hassConfig
@@ -84,7 +83,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
this._value = this.params.value
? {
year: this._pickerYear,
year: formatDateYear(date, this._i18n.locale, this._hassConfig),
title: formatDateShort(date, this._i18n.locale, this._hassConfig),
dateString: formatISODateOnly(
date,
@@ -140,9 +139,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
slot="previous"
></ha-icon-button-prev>
<div class="heading" slot="heading">
<span class="month-year"
>${this._pickerMonth} ${this._pickerYear}</span
>
<span class="month-year">${this._pickerMonthYear}</span>
<ha-icon-button
@click=${this._setToday}
.path=${mdiCalendarToday}
@@ -185,12 +182,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
if (setFocusDay) {
this._focusDate = this._value.dateString;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
this._pickerMonthYear = formatDateMonthYear(
date,
this._i18n.locale,
this._hassConfig
@@ -199,18 +191,9 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
}
private _focusChanged(ev: CustomEvent<Date>) {
const date = ev.detail;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._focusDate = undefined;
const dateElement = ev.target as CalendarDate;
this._pickerMonthYear = formatCallyMonthYear(ev.detail, this._i18n.locale);
this._focusDate = dateElement.focusedDate;
}
private _clear() {
+40
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
formatCallyMonthYear,
formatDate,
formatDateWeekdayDay,
formatDateShort,
@@ -263,4 +264,43 @@ describe("formatDate", () => {
).toBe("Sat");
});
});
describe("formatCallyMonthYear", () => {
// How cally hands out a calendar day: a Date anchored to UTC midnight
const julyFirst = new Date(Date.UTC(2026, 6, 1));
const januaryFirst = new Date(Date.UTC(2026, 0, 1));
// demoConfig.time_zone is America/Los_Angeles, so this resolves to a
// negative UTC offset whatever zone the tests run in
const locale = {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
time_zone: TimeZone.server,
first_weekday: FirstWeekday.language,
};
it("Formats in UTC rather than the resolved time zone", () => {
// A time zone aware formatter reads UTC midnight on the 1st as the
// previous month, which desyncs the header from the calendar grid
expect(formatDateMonthYear(julyFirst, locale, demoConfig)).toBe(
"June 2026"
);
expect(formatCallyMonthYear(julyFirst, locale)).toBe("July 2026");
});
it("Keeps the year on a January page", () => {
expect(formatDateMonthYear(januaryFirst, locale, demoConfig)).toBe(
"December 2025"
);
expect(formatCallyMonthYear(januaryFirst, locale)).toBe("January 2026");
});
it("Orders month and year by locale", () => {
expect(
formatCallyMonthYear(julyFirst, { ...locale, language: "ja" })
).toBe("2026年7月");
});
});
});