diff --git a/package.json b/package.json index 2db10f8920..891ac01e35 100644 --- a/package.json +++ b/package.json @@ -137,6 +137,7 @@ "vis-network": "^8.5.4", "vue": "^2.6.12", "vue2-daterange-picker": "^0.5.1", + "weekstart": "^1.1.0", "workbox-cacheable-response": "^6.4.2", "workbox-core": "^6.4.2", "workbox-expiration": "^6.4.2", diff --git a/src/common/datetime/first_weekday.ts b/src/common/datetime/first_weekday.ts new file mode 100644 index 0000000000..48b7b75261 --- /dev/null +++ b/src/common/datetime/first_weekday.ts @@ -0,0 +1,29 @@ +import { getWeekStartByLocale } from "weekstart"; +import { FrontendLocaleData, FirstWeekday } from "../../data/translation"; + +export const weekdays = [ + "sunday", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", +] as const; + +export const firstWeekdayIndex = (locale: FrontendLocaleData): number => { + if (locale.first_weekday === FirstWeekday.language) { + // @ts-ignore + if ("weekInfo" in Intl.Locale.prototype) { + // @ts-ignore + return new Intl.Locale(locale.language).weekInfo.firstDay % 7; + } + return getWeekStartByLocale(locale.language) % 7; + } + return weekdays.indexOf(locale.first_weekday); +}; + +export const firstWeekday = (locale: FrontendLocaleData) => { + const index = firstWeekdayIndex(locale); + return weekdays[index]; +}; diff --git a/src/components/date-range-picker.ts b/src/components/date-range-picker.ts index 7224e047f1..8cb7610ee1 100644 --- a/src/components/date-range-picker.ts +++ b/src/components/date-range-picker.ts @@ -33,6 +33,10 @@ const Component = Vue.extend({ return new Date(); }, }, + firstDay: { + type: Number, + default: 1, + }, }, render(createElement) { // @ts-ignore @@ -48,6 +52,10 @@ const Component = Vue.extend({ disabled: this.disabled, // @ts-ignore ranges: this.ranges ? {} : false, + "locale-data": { + // @ts-ignore + firstDay: this.firstDay, + }, }, model: { value: { diff --git a/src/components/ha-date-input.ts b/src/components/ha-date-input.ts index c776519ceb..bd542fb797 100644 --- a/src/components/ha-date-input.ts +++ b/src/components/ha-date-input.ts @@ -2,6 +2,7 @@ import { mdiCalendar } from "@mdi/js"; import { css, CSSResultGroup, html, LitElement } from "lit"; import { customElement, property } from "lit/decorators"; import { formatDateNumeric } from "../common/datetime/format_date"; +import { firstWeekdayIndex } from "../common/datetime/first_weekday"; import { fireEvent } from "../common/dom/fire_event"; import { HomeAssistant } from "../types"; import "./ha-svg-icon"; @@ -14,6 +15,7 @@ export interface datePickerDialogParams { min?: string; max?: string; locale?: string; + firstWeekday?: number; onChange: (value: string) => void; } @@ -67,6 +69,7 @@ export class HaDateInput extends LitElement { value: this.value, onChange: (value) => this._valueChanged(value), locale: this.locale.language, + firstWeekday: firstWeekdayIndex(this.locale), }); } diff --git a/src/components/ha-date-range-picker.ts b/src/components/ha-date-range-picker.ts index 40e5d0d1d7..3353d5db26 100644 --- a/src/components/ha-date-range-picker.ts +++ b/src/components/ha-date-range-picker.ts @@ -14,6 +14,7 @@ import { import { customElement, property } from "lit/decorators"; import { formatDateTime } from "../common/datetime/format_date_time"; import { useAmPm } from "../common/datetime/use_am_pm"; +import { firstWeekdayIndex } from "../common/datetime/first_weekday"; import { computeRTLDirection } from "../common/util/compute_rtl"; import { HomeAssistant } from "../types"; import "./date-range-picker"; @@ -58,6 +59,7 @@ export class HaDateRangePicker extends LitElement { start-date=${this.startDate} end-date=${this.endDate} ?ranges=${this.ranges !== undefined} + first-day=${firstWeekdayIndex(this.hass.locale)} >
diff --git a/src/components/ha-dialog-date-picker.ts b/src/components/ha-dialog-date-picker.ts index 2cb229db7b..110b088928 100644 --- a/src/components/ha-dialog-date-picker.ts +++ b/src/components/ha-dialog-date-picker.ts @@ -40,6 +40,7 @@ export class HaDialogDatePicker extends LitElement { .max=${this._params.max} .locale=${this._params.locale} @datepicker-value-updated=${this._valueChanged} + .firstDayOfWeek=${this._params.firstWeekday} > today "", diff --git a/src/panels/calendar/ha-full-calendar.ts b/src/panels/calendar/ha-full-calendar.ts index 8b2b2ad4b5..50c393bce5 100644 --- a/src/panels/calendar/ha-full-calendar.ts +++ b/src/panels/calendar/ha-full-calendar.ts @@ -37,6 +37,7 @@ import type { HomeAssistant, ToggleButton, } from "../../types"; +import { firstWeekdayIndex } from "../../common/datetime/first_weekday"; declare global { interface HTMLElementTagNameMap { @@ -214,6 +215,7 @@ export class HAFullCalendar extends LitElement { const config: CalendarOptions = { ...defaultFullCalendarConfig, locale: this.hass.language, + firstDay: firstWeekdayIndex(this.hass.locale), initialView: this.initialView, eventTimeFormat: { hour: useAmPm(this.hass.locale) ? "numeric" : "2-digit", diff --git a/src/panels/config/automation/condition/types/ha-automation-condition-time.ts b/src/panels/config/automation/condition/types/ha-automation-condition-time.ts index e2b2301b21..ce08d1654d 100644 --- a/src/panels/config/automation/condition/types/ha-automation-condition-time.ts +++ b/src/panels/config/automation/condition/types/ha-automation-condition-time.ts @@ -1,15 +1,17 @@ import { html, LitElement } from "lit"; import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; +import { firstWeekdayIndex } from "../../../../../common/datetime/first_weekday"; import { fireEvent } from "../../../../../common/dom/fire_event"; -import type { TimeCondition } from "../../../../../data/automation"; -import type { HomeAssistant } from "../../../../../types"; -import type { ConditionElement } from "../ha-automation-condition-row"; import type { LocalizeFunc } from "../../../../../common/translations/localize"; import "../../../../../components/ha-form/ha-form"; import type { SchemaUnion } from "../../../../../components/ha-form/types"; +import type { TimeCondition } from "../../../../../data/automation"; +import { FrontendLocaleData } from "../../../../../data/translation"; +import type { HomeAssistant } from "../../../../../types"; +import type { ConditionElement } from "../ha-automation-condition-row"; -const DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const; +const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const; @customElement("ha-automation-condition-time") export class HaTimeCondition extends LitElement implements ConditionElement { @@ -30,10 +32,15 @@ export class HaTimeCondition extends LitElement implements ConditionElement { private _schema = memoizeOne( ( localize: LocalizeFunc, + locale: FrontendLocaleData, inputModeAfter?: boolean, inputModeBefore?: boolean - ) => - [ + ) => { + const dayIndex = firstWeekdayIndex(locale); + const sortedDays = DAYS.slice(dayIndex, DAYS.length).concat( + DAYS.slice(0, dayIndex) + ); + return [ { name: "mode_after", type: "select", @@ -87,7 +94,7 @@ export class HaTimeCondition extends LitElement implements ConditionElement { { type: "multi_select", name: "weekday", - options: DAYS.map( + options: sortedDays.map( (day) => [ day, @@ -97,7 +104,8 @@ export class HaTimeCondition extends LitElement implements ConditionElement { ] as const ), }, - ] as const + ] as const; + } ); protected render() { @@ -110,6 +118,7 @@ export class HaTimeCondition extends LitElement implements ConditionElement { const schema = this._schema( this.hass.localize, + this.hass.locale, inputModeAfter, inputModeBefore ); diff --git a/src/panels/config/helpers/forms/ha-schedule-form.ts b/src/panels/config/helpers/forms/ha-schedule-form.ts index 7aa682ae5c..2c238c1ef4 100644 --- a/src/panels/config/helpers/forms/ha-schedule-form.ts +++ b/src/panels/config/helpers/forms/ha-schedule-form.ts @@ -19,6 +19,7 @@ import { import { customElement, property, state } from "lit/decorators"; import { formatTime24h } from "../../../../common/datetime/format_time"; import { useAmPm } from "../../../../common/datetime/use_am_pm"; +import { firstWeekdayIndex } from "../../../../common/datetime/first_weekday"; import { fireEvent } from "../../../../common/dom/fire_event"; import "../../../../components/ha-icon-picker"; import "../../../../components/ha-textfield"; @@ -169,6 +170,7 @@ class HaScheduleForm extends LitElement { const config: CalendarOptions = { ...defaultFullCalendarConfig, locale: this.hass.language, + firstDay: firstWeekdayIndex(this.hass.locale), slotLabelFormat: { hour: "numeric", minute: undefined, diff --git a/src/panels/profile/ha-panel-profile.ts b/src/panels/profile/ha-panel-profile.ts index 7ecc99b68f..2d709dbc61 100644 --- a/src/panels/profile/ha-panel-profile.ts +++ b/src/panels/profile/ha-panel-profile.ts @@ -24,6 +24,7 @@ import "./ha-force-narrow-row"; import "./ha-long-lived-access-tokens-card"; import "./ha-mfa-modules-card"; import "./ha-pick-dashboard-row"; +import "./ha-pick-first-weekday-row"; import "./ha-pick-language-row"; import "./ha-pick-number-format-row"; import "./ha-pick-theme-row"; @@ -100,6 +101,10 @@ class HaPanelProfile extends LitElement { .narrow=${this.narrow} .hass=${this.hass} > + + + ${this.hass.localize("ui.panel.profile.first_weekday.header")} + + + ${this.hass.localize("ui.panel.profile.first_weekday.description")} + + + ${Object.values(FirstWeekday).map((day) => { + const value = this.hass.localize( + `ui.panel.profile.first_weekday.values.${day}` + ); + const twoLine = day === FirstWeekday.language; + return html` + + ${value} + ${twoLine + ? html` + ${this.hass.localize( + `ui.panel.profile.first_weekday.values.${firstWeekday( + this.hass.locale + )}` + )} + ` + : ""} + + `; + })} + + + `; + } + + private async _handleFormatSelection(ev) { + fireEvent(this, "hass-first-weekday-select", ev.target.value); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-pick-first-weekday-row": FirstWeekdayRow; + } +} diff --git a/src/state/connection-mixin.ts b/src/state/connection-mixin.ts index 5d896360c8..dab61ccbb1 100644 --- a/src/state/connection-mixin.ts +++ b/src/state/connection-mixin.ts @@ -15,7 +15,7 @@ import { subscribeFrontendUserData } from "../data/frontend"; import { forwardHaptic } from "../data/haptics"; import { DEFAULT_PANEL } from "../data/panel"; import { serviceCallWillDisconnect } from "../data/service"; -import { NumberFormat, TimeFormat } from "../data/translation"; +import { FirstWeekday, NumberFormat, TimeFormat } from "../data/translation"; import { subscribePanels } from "../data/ws-panels"; import { translationMetadata } from "../resources/translations-metadata"; import { Constructor, HomeAssistant, ServiceCallResponse } from "../types"; @@ -58,6 +58,7 @@ export const connectionMixin = >( language, number_format: NumberFormat.language, time_format: TimeFormat.language, + first_weekday: FirstWeekday.language, }, resources: null as any, localize: () => "", diff --git a/src/state/translations-mixin.ts b/src/state/translations-mixin.ts index b84e711548..b0848fcb4e 100644 --- a/src/state/translations-mixin.ts +++ b/src/state/translations-mixin.ts @@ -6,6 +6,7 @@ import { } from "../common/util/compute_rtl"; import { debounce } from "../common/util/debounce"; import { + FirstWeekday, getHassTranslations, getHassTranslationsPre109, NumberFormat, @@ -36,6 +37,9 @@ declare global { "hass-time-format-select": { time_format: TimeFormat; }; + "hass-first-weekday-select": { + first_weekday: FirstWeekday; + }; "translations-updated": undefined; } } @@ -76,6 +80,9 @@ export default >(superClass: T) => this.addEventListener("hass-time-format-select", (e) => { this._selectTimeFormat((e as CustomEvent).detail, true); }); + this.addEventListener("hass-first-weekday-select", (e) => { + this._selectFirstWeekday((e as CustomEvent).detail, true); + }); this._loadCoreTranslations(getLocalLanguage()); } @@ -114,6 +121,13 @@ export default >(superClass: T) => // We just got time_format from backend, no need to save back this._selectTimeFormat(locale.time_format, false); } + if ( + locale?.first_weekday && + this.hass!.locale.first_weekday !== locale.first_weekday + ) { + // We just got first_weekday from backend, no need to save back + this._selectFirstWeekday(locale.first_weekday, false); + } }); this.hass!.connection.subscribeEvents( @@ -161,6 +175,18 @@ export default >(superClass: T) => } } + private _selectFirstWeekday( + first_weekday: FirstWeekday, + saveToBackend: boolean + ) { + this._updateHass({ + locale: { ...this.hass!.locale, first_weekday: first_weekday }, + }); + if (saveToBackend) { + saveTranslationPreferences(this.hass!, this.hass!.locale); + } + } + private _selectLanguage(language: string, saveToBackend: boolean) { if (!this.hass) { // should not happen, do it to avoid use this.hass! diff --git a/src/translations/en.json b/src/translations/en.json index a993014576..374c847570 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1117,6 +1117,15 @@ "day": "{count} {count, plural,\n one {day}\n other {days}\n}", "week": "{count} {count, plural,\n one {week}\n other {weeks}\n}" }, + "weekdays": { + "monday": "Monday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "thursday": "Thursday", + "friday": "Friday", + "saturday": "Saturday", + "sunday": "Sunday" + }, "errors": { "config": { "no_type_provided": "No type provided.", @@ -4271,6 +4280,21 @@ "24": "24 hours" } }, + "first_weekday": { + "header": "First day of the week", + "dropdown_label": "First day of the week", + "description": "Choose the starting day for calendars.", + "values": { + "language": "Auto (use language setting)", + "monday": "[%key:ui::weekdays::monday%]", + "tuesday": "[%key:ui::weekdays::tuesday%]", + "wednesday": "[%key:ui::weekdays::wednesday%]", + "thursday": "[%key:ui::weekdays::thursday%]", + "friday": "[%key:ui::weekdays::friday%]", + "saturday": "[%key:ui::weekdays::saturday%]", + "sunday": "[%key:ui::weekdays::sunday%]" + } + }, "themes": { "header": "Theme", "error_no_theme": "No themes available.", diff --git a/src/util/common-translation.ts b/src/util/common-translation.ts index ff7793d256..aa83e23141 100644 --- a/src/util/common-translation.ts +++ b/src/util/common-translation.ts @@ -76,6 +76,7 @@ export async function getUserLocale( const language = result?.language; const number_format = result?.number_format; const time_format = result?.time_format; + const first_weekday = result?.first_weekday; if (language) { const availableLanguage = findAvailableLanguage(language); if (availableLanguage) { @@ -83,12 +84,14 @@ export async function getUserLocale( language: availableLanguage, number_format, time_format, + first_weekday, }; } } return { number_format, time_format, + first_weekday, }; } diff --git a/test/common/datetime/format_date.ts b/test/common/datetime/format_date.ts index f81a4d1a5d..de2293719e 100644 --- a/test/common/datetime/format_date.ts +++ b/test/common/datetime/format_date.ts @@ -1,7 +1,11 @@ import { assert } from "chai"; import { formatDate } from "../../../src/common/datetime/format_date"; -import { NumberFormat, TimeFormat } from "../../../src/data/translation"; +import { + NumberFormat, + TimeFormat, + FirstWeekday, +} from "../../../src/data/translation"; describe("formatDate", () => { const dateObj = new Date(2017, 10, 18, 11, 12, 13, 1400); @@ -12,6 +16,7 @@ describe("formatDate", () => { language: "en", number_format: NumberFormat.language, time_format: TimeFormat.language, + first_weekday: FirstWeekday.language, }), "November 18, 2017" ); diff --git a/test/common/datetime/format_date_time.ts b/test/common/datetime/format_date_time.ts index 25e0a26a9c..a7692cbe97 100644 --- a/test/common/datetime/format_date_time.ts +++ b/test/common/datetime/format_date_time.ts @@ -4,7 +4,11 @@ import { formatDateTime, formatDateTimeWithSeconds, } from "../../../src/common/datetime/format_date_time"; -import { NumberFormat, TimeFormat } from "../../../src/data/translation"; +import { + NumberFormat, + TimeFormat, + FirstWeekday, +} from "../../../src/data/translation"; describe("formatDateTime", () => { const dateObj = new Date(2017, 10, 18, 23, 12, 13, 400); @@ -15,6 +19,7 @@ describe("formatDateTime", () => { language: "en", number_format: NumberFormat.language, time_format: TimeFormat.am_pm, + first_weekday: FirstWeekday.language, }), "November 18, 2017 at 11:12 PM" ); @@ -23,6 +28,7 @@ describe("formatDateTime", () => { language: "en", number_format: NumberFormat.language, time_format: TimeFormat.twenty_four, + first_weekday: FirstWeekday.language, }), "November 18, 2017 at 23:12" ); @@ -38,6 +44,7 @@ describe("formatDateTimeWithSeconds", () => { language: "en", number_format: NumberFormat.language, time_format: TimeFormat.am_pm, + first_weekday: FirstWeekday.language, }), "November 18, 2017 at 11:12:13 PM" ); @@ -46,6 +53,7 @@ describe("formatDateTimeWithSeconds", () => { language: "en", number_format: NumberFormat.language, time_format: TimeFormat.twenty_four, + first_weekday: FirstWeekday.language, }), "November 18, 2017 at 23:12:13" ); diff --git a/test/common/datetime/format_time.ts b/test/common/datetime/format_time.ts index e8cc5f63f0..0fbaafa871 100644 --- a/test/common/datetime/format_time.ts +++ b/test/common/datetime/format_time.ts @@ -5,7 +5,11 @@ import { formatTimeWithSeconds, formatTimeWeekday, } from "../../../src/common/datetime/format_time"; -import { NumberFormat, TimeFormat } from "../../../src/data/translation"; +import { + NumberFormat, + TimeFormat, + FirstWeekday, +} from "../../../src/data/translation"; describe("formatTime", () => { const dateObj = new Date(2017, 10, 18, 23, 12, 13, 1400); @@ -16,6 +20,7 @@ describe("formatTime", () => { language: "en", number_format: NumberFormat.language, time_format: TimeFormat.am_pm, + first_weekday: FirstWeekday.language, }), "11:12 PM" ); @@ -24,6 +29,7 @@ describe("formatTime", () => { language: "en", number_format: NumberFormat.language, time_format: TimeFormat.twenty_four, + first_weekday: FirstWeekday.language, }), "23:12" ); @@ -39,6 +45,7 @@ describe("formatTimeWithSeconds", () => { language: "en", number_format: NumberFormat.language, time_format: TimeFormat.am_pm, + first_weekday: FirstWeekday.language, }), "11:12:13 PM" ); @@ -47,6 +54,7 @@ describe("formatTimeWithSeconds", () => { language: "en", number_format: NumberFormat.language, time_format: TimeFormat.twenty_four, + first_weekday: FirstWeekday.language, }), "23:12:13" ); @@ -62,6 +70,7 @@ describe("formatTimeWeekday", () => { language: "en", number_format: NumberFormat.language, time_format: TimeFormat.am_pm, + first_weekday: FirstWeekday.language, }), "Wednesday 11:12 PM" ); @@ -70,6 +79,7 @@ describe("formatTimeWeekday", () => { language: "en", number_format: NumberFormat.language, time_format: TimeFormat.twenty_four, + first_weekday: FirstWeekday.language, }), "Wednesday 23:12" ); diff --git a/test/common/datetime/relative_time.ts b/test/common/datetime/relative_time.ts index 2e020e1ddc..b91a0bef92 100644 --- a/test/common/datetime/relative_time.ts +++ b/test/common/datetime/relative_time.ts @@ -1,13 +1,18 @@ import { assert } from "chai"; import { relativeTime } from "../../../src/common/datetime/relative_time"; -import { NumberFormat, TimeFormat } from "../../../src/data/translation"; +import { + NumberFormat, + TimeFormat, + FirstWeekday, +} from "../../../src/data/translation"; describe("relativeTime", () => { const locale = { language: "en", number_format: NumberFormat.language, time_format: TimeFormat.language, + first_weekday: FirstWeekday.language, }; it("now", () => { diff --git a/test/common/entity/compute_state_display.ts b/test/common/entity/compute_state_display.ts index 4d9e2cca67..095be174f4 100644 --- a/test/common/entity/compute_state_display.ts +++ b/test/common/entity/compute_state_display.ts @@ -5,6 +5,7 @@ import { FrontendLocaleData, NumberFormat, TimeFormat, + FirstWeekday, } from "../../../src/data/translation"; let localeData: FrontendLocaleData; @@ -19,6 +20,7 @@ describe("computeStateDisplay", () => { language: "en", number_format: NumberFormat.comma_decimal, time_format: TimeFormat.am_pm, + first_weekday: FirstWeekday.language, }; }); diff --git a/test/common/string/format_number.ts b/test/common/string/format_number.ts index d9e6109cb5..f762f22305 100644 --- a/test/common/string/format_number.ts +++ b/test/common/string/format_number.ts @@ -5,6 +5,7 @@ import { FrontendLocaleData, NumberFormat, TimeFormat, + FirstWeekday, } from "../../../src/data/translation"; describe("formatNumber", () => { @@ -13,6 +14,7 @@ describe("formatNumber", () => { language: "en", number_format: NumberFormat.language, time_format: TimeFormat.language, + first_weekday: FirstWeekday.language, }; // Node only ships with English support for `Intl`, so we can not test for other number formats here. diff --git a/yarn.lock b/yarn.lock index 5b3051dcaf..316610220e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9145,6 +9145,7 @@ fsevents@^1.2.7: webpack-dev-server: ^4.3.0 webpack-manifest-plugin: ^4.0.2 webpackbar: ^5.0.0-3 + weekstart: ^1.1.0 workbox-build: ^6.4.2 workbox-cacheable-response: ^6.4.2 workbox-core: ^6.4.2 @@ -15781,6 +15782,13 @@ typescript@^4.4.3: languageName: node linkType: hard +"weekstart@npm:^1.1.0": + version: 1.1.0 + resolution: "weekstart@npm:1.1.0" + checksum: afce96e0b95809a30f00fa02b13a0927324d9f76b9c10ce6b3de9bbd5926615156f8a0526c63e2bd1cabdc8ec3da68b8df8d6608b6364ded11b5da300a8cfcb4 + languageName: node + linkType: hard + "whatwg-url@npm:^7.0.0": version: 7.1.0 resolution: "whatwg-url@npm:7.1.0"