diff --git a/assets/js/components/GlobalSettings/UserInterfaceSettings.vue b/assets/js/components/GlobalSettings/UserInterfaceSettings.vue index 24f07d0ac..bd0ef4cc8 100644 --- a/assets/js/components/GlobalSettings/UserInterfaceSettings.vue +++ b/assets/js/components/GlobalSettings/UserInterfaceSettings.vue @@ -59,6 +59,19 @@ equal-width /> + + +
{{ dateFormatExample }}
+
@@ -91,14 +104,24 @@ import { removeLocalePreference, } from "@/i18n.ts"; import { getThemePreference, setThemePreference } from "@/theme.ts"; -import { getUnits, setUnits, is12hFormat, set12hFormat } from "@/units"; +import { + getUnits, + setUnits, + is12hFormat, + set12hFormat, + getDateFormat, + setDateFormat, +} from "@/units"; import { isApp } from "@/utils/native"; import { defineComponent, type PropType } from "vue"; import { LENGTH_UNIT, THEME, TIME_FORMAT, type UiLoadpoint } from "@/types/evcc"; +import formatter from "@/mixins/formatter"; +import type { DateFormat } from "@/settings"; export default defineComponent({ name: "UserInterfaceSettings", components: { FormRow, SelectGroup, LoadpointOrderSettings }, + mixins: [formatter], props: { loadpoints: { type: Array as PropType, default: () => [] }, }, @@ -115,6 +138,17 @@ export default defineComponent({ }; }, computed: { + dateFormat: { + get(): DateFormat { + return getDateFormat(); + }, + set(value: DateFormat) { + setDateFormat(value); + }, + }, + dateFormatExample(): string { + return this.fmtFullDateTime(new Date(2015, 9, 21, 16, 29)); + }, languageOptions: () => { const locales = Object.entries(LOCALES).map(([key, value]) => { return { value: key, name: value[1] }; diff --git a/assets/js/components/Sessions/SessionDetailsModal.vue b/assets/js/components/Sessions/SessionDetailsModal.vue index c0322ef5b..c89d6d584 100644 --- a/assets/js/components/Sessions/SessionDetailsModal.vue +++ b/assets/js/components/Sessions/SessionDetailsModal.vue @@ -60,11 +60,11 @@
diff --git a/assets/js/components/Sessions/SessionTable.vue b/assets/js/components/Sessions/SessionTable.vue index 94cc36233..2885e88dd 100644 --- a/assets/js/components/Sessions/SessionTable.vue +++ b/assets/js/components/Sessions/SessionTable.vue @@ -133,7 +133,7 @@ @click="showDetails(session.id)" > - {{ fmtFullDateTime(new Date(session.created), true) }} + {{ fmtWeekdayDayTime(new Date(session.created)) }} {{ session.loadpoint }} diff --git a/assets/js/mixins/formatter.test.ts b/assets/js/mixins/formatter.test.ts index aa240b026..a5d6e0c4a 100644 --- a/assets/js/mixins/formatter.test.ts +++ b/assets/js/mixins/formatter.test.ts @@ -2,6 +2,7 @@ import { mount, config } from "@vue/test-utils"; import { describe, expect, test, vi } from "vite-plus/test"; import formatter, { POWER_UNIT } from "./formatter"; import * as units from "../units"; +import settings from "../settings"; import { defineComponent } from "vue"; import { CURRENCY } from "@/types/evcc"; @@ -275,7 +276,7 @@ describe("12h/24h time format", () => { test("12h format", () => { is12hSpy.mockReturnValue(true); expect(fmt.fmtHourMinute(testDate)).toBe("3:30 PM"); - expect(fmt.fmtFullDateTime(testDate, false)).toBe("So., 15. Jan., 3:30 PM"); + expect(fmt.fmtFullDateTime(testDate)).toBe("So., 15. Jan. 2023, 3:30 PM"); expect(fmt.fmtWeekdayTime(testDate)).toBe("So. 3:30 PM"); expect(fmt.fmtAbsoluteDate(testDate)).toBe("So 3:30 PM"); }); @@ -283,12 +284,29 @@ describe("12h/24h time format", () => { test("24h format", () => { is12hSpy.mockReturnValue(false); expect(fmt.fmtHourMinute(testDate)).toBe("15:30"); - expect(fmt.fmtFullDateTime(testDate, false)).toBe("So., 15. Jan., 15:30"); + expect(fmt.fmtFullDateTime(testDate)).toBe("So., 15. Jan. 2023, 15:30"); expect(fmt.fmtWeekdayTime(testDate)).toBe("So., 15:30"); expect(fmt.fmtAbsoluteDate(testDate)).toBe("So 15:30"); }); }); +describe("date format", () => { + test("should order day and month by preference", () => { + is12hSpy.mockReturnValue(false); + settings.dateFormat = "dmy"; + expect(fmt.fmtFullDateTime(testDate)).toBe("So. 15 Jan. 2023 15:30"); + settings.dateFormat = "mdy"; + expect(fmt.fmtFullDateTime(testDate)).toBe("So. Jan. 15, 2023 15:30"); + settings.dateFormat = "ymd"; + expect(fmt.fmtFullDateTime(testDate)).toBe("So. 2023-01-15 15:30"); + settings.dateFormat = ""; + }); + test("should format day and time", () => { + is12hSpy.mockReturnValue(false); + expect(fmt.fmtWeekdayDayTime(testDate)).toBe("So. 15, 15:30"); + }); +}); + describe("fmtTimeRange", () => { test("should format time ranges in 24h format", () => { is12hSpy.mockReturnValue(false); diff --git a/assets/js/mixins/formatter.ts b/assets/js/mixins/formatter.ts index 894bd065f..47c05d1b5 100644 --- a/assets/js/mixins/formatter.ts +++ b/assets/js/mixins/formatter.ts @@ -1,6 +1,55 @@ import { defineComponent } from "vue"; import { is12hFormat } from "@/units"; import { CURRENCY } from "../types/evcc"; +import settings from "@/settings"; +import type { DateFormat } from "@/settings"; + +// Extract a single part in date context, where names can differ from their +// standalone forms (German "So.", "Jan." vs "So", "Jan"). +function datePart( + date: Date, + locale: string | undefined, + type: Intl.DateTimeFormatPartTypes +): string { + return ( + new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric", month: "short" }) + .formatToParts(date) + .find((part) => part.type === type)?.value ?? "" + ); +} + +// Day+month(+year) in the user's date order, e.g. "17 Mai" or "Mai 17, 2025". +// Ordering and punctuation come from a reference locale (en-GB day-first, +// en-US month-first), month names stay translated. ymd is the full ISO date. +function formatDayMonth( + date: Date, + locale: string | undefined, + fmt: DateFormat, + year = false +): string { + if (fmt === "ymd") return isoDate(date); + const orderLocale = fmt === "mdy" ? "en-US" : "en-GB"; + const month = datePart(date, locale, "month"); + // some locales (e.g. Czech) use numeric months in date context + const name = /\p{L}/u.test(month) + ? month + : new Intl.DateTimeFormat(locale, { month: "short" }).format(date); + return new Intl.DateTimeFormat(orderLocale, { + month: "short", + day: "numeric", + year: year ? "numeric" : undefined, + }) + .formatToParts(date) + .map((part) => (part.type === "month" ? name : part.value)) + .join(""); +} + +// local-time ISO date, Intl cannot produce this reliably across locales +function isoDate(date: Date): string { + const mm = String(date.getMonth() + 1).padStart(2, "0"); + const dd = String(date.getDate()).padStart(2, "0"); + return `${date.getFullYear()}-${mm}-${dd}`; +} const CURRENCY_SYMBOLS: Record = { AUD: "$", @@ -62,6 +111,11 @@ export default defineComponent({ fmtDigits: 1, }; }, + computed: { + dateFormat(): DateFormat { + return settings.dateFormat || ""; + }, + }, methods: { energyPriceSubunit(currency: CURRENCY): string | undefined { if (currency === CURRENCY.CHF) { @@ -265,15 +319,33 @@ export default defineComponent({ hour12: is12hFormat(), }).format(date); }, - fmtFullDateTime(date: Date, short: boolean) { - return new Intl.DateTimeFormat(this.$i18n?.locale, { - weekday: short ? undefined : "short", - month: short ? "numeric" : "short", - day: "numeric", + fmtFullDateTime(date: Date) { + const locale = this.$i18n?.locale; + const fmt = this.dateFormat; + if (!fmt) { + // auto: single Intl call preserves locale-native separators (e.g. German "So., 15. Jan.,") + return new Intl.DateTimeFormat(locale, { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "numeric", + hour12: is12hFormat(), + }).format(date); + } + const time = new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "numeric", hour12: is12hFormat(), }).format(date); + const weekday = datePart(date, locale, "weekday"); + return `${weekday} ${formatDayMonth(date, locale, fmt, true)} ${time}`.trim(); + }, + // weekday + day of month + time, for lists within a known month + fmtWeekdayDayTime(date: Date) { + const weekday = datePart(date, this.$i18n?.locale, "weekday"); + return `${weekday} ${date.getDate()}, ${this.fmtHourMinute(date)}`; }, fmtWeekdayTime(date: Date) { return new Intl.DateTimeFormat(this.$i18n?.locale, { @@ -295,11 +367,17 @@ export default defineComponent({ }).format(date); }, fmtDayMonth(date: Date) { - return new Intl.DateTimeFormat(this.$i18n?.locale, { - weekday: "short", - day: "numeric", - month: "short", - }).format(date); + const locale = this.$i18n?.locale; + const fmt = this.dateFormat; + if (!fmt) { + return new Intl.DateTimeFormat(locale, { + weekday: "short", + day: "numeric", + month: "short", + }).format(date); + } + const weekday = datePart(date, locale, "weekday"); + return `${weekday} ${formatDayMonth(date, locale, fmt)}`.trim(); }, fmtDayMonthYear(date: Date) { return new Intl.DateTimeFormat(this.$i18n?.locale, { diff --git a/assets/js/settings.ts b/assets/js/settings.ts index 8b92a76ae..f099c5aa4 100644 --- a/assets/js/settings.ts +++ b/assets/js/settings.ts @@ -23,6 +23,7 @@ const BATTERY_UNIT = "battery_unit"; const SETTINGS_PRICE_ZOOM = "settings_price_zoom"; const SETTINGS_HIDE_FEEDIN = "settings_hide_feedin"; const LAST_BATTERY_SMART_COST_LIMIT = "last_battery_smart_cost_limit"; +const SETTINGS_DATE_FORMAT = "settings_date_format"; const LAST_TARGET_TIME = "last_target_time"; const LAST_SOC_GOAL = "last_soc_goal"; const LAST_ENERGY_GOAL = "last_energy_goal"; @@ -99,6 +100,8 @@ function saveJSON(key: string) { }; } +export type DateFormat = "" | "dmy" | "mdy" | "ymd"; + export interface LoadpointSettings { order?: number; visible?: boolean; @@ -112,6 +115,7 @@ export interface Settings { theme: THEME | null; unit: string; is12hFormat: boolean; + dateFormat: DateFormat; energyflowDetails: boolean; energyflowCo2: boolean; energyflowPv: boolean; @@ -141,6 +145,7 @@ const settings: Settings = reactive({ theme: read(SETTINGS_THEME), unit: read(SETTINGS_UNIT), is12hFormat: readBool(SETTINGS_12H_FORMAT), + dateFormat: read(SETTINGS_DATE_FORMAT) || "", energyflowDetails: readBool(SETTINGS_ENERGYFLOW_DETAILS), energyflowCo2: readBool(SETTINGS_ENERGYFLOW_CO2), energyflowPv: readBool(SETTINGS_ENERGYFLOW_PV), @@ -169,6 +174,7 @@ watch(() => settings.locale, save(SETTINGS_LOCALE)); watch(() => settings.theme, save(SETTINGS_THEME)); watch(() => settings.unit, save(SETTINGS_UNIT)); watch(() => settings.is12hFormat, saveBool(SETTINGS_12H_FORMAT)); +watch(() => settings.dateFormat, save(SETTINGS_DATE_FORMAT)); watch(() => settings.energyflowDetails, saveBool(SETTINGS_ENERGYFLOW_DETAILS)); watch(() => settings.energyflowCo2, saveBool(SETTINGS_ENERGYFLOW_CO2)); watch(() => settings.energyflowPv, saveBool(SETTINGS_ENERGYFLOW_PV)); diff --git a/assets/js/units.ts b/assets/js/units.ts index f57f236b0..ec51bbe2b 100644 --- a/assets/js/units.ts +++ b/assets/js/units.ts @@ -1,4 +1,5 @@ import settings from "./settings"; +import type { DateFormat } from "./settings"; import { LENGTH_UNIT } from "./types/evcc"; const MILES_FACTOR = 0.6213711922; @@ -34,3 +35,11 @@ export function is12hFormat() { export function set12hFormat(value: boolean) { settings.is12hFormat = value; } + +export function getDateFormat(): DateFormat { + return settings.dateFormat || ""; +} + +export function setDateFormat(value: DateFormat) { + settings.dateFormat = value; +} diff --git a/i18n/en.json b/i18n/en.json index 09ae539b0..4456e5c95 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1718,6 +1718,13 @@ "vehicle": "Vehicle" }, "settings": { + "dateFormat": { + "auto": "Auto", + "dmy": "DD/MM/YYYY", + "label": "Date format", + "mdy": "MM/DD/YYYY", + "ymd": "YYYY-MM-DD" + }, "deviceInfo": "Settings you make in this dialog only affect this device.", "fullscreen": { "enter": "Enter fullscreen", diff --git a/tests/sessions.spec.ts b/tests/sessions.spec.ts index 725c25aba..3d2880ebf 100644 --- a/tests/sessions.spec.ts +++ b/tests/sessions.spec.ts @@ -388,7 +388,7 @@ test.describe("session details", async () => { await expect(modal.getByLabel("Charging point")).toHaveValue("Garage"); await expect(modal.getByLabel("Vehicle")).toHaveValue("weißes Model 3"); await expect(modal.getByTestId("session-details-date")).toContainText( - ["Thu, May 4, 22:00", "Fri, May 5, 06:00"].join("") + ["Thu, May 4, 2023, 22:00", "Fri, May 5, 2023, 06:00"].join("") ); await expect(modal.getByTestId("session-details-energy")).toContainText("5.0 kWh"); await expect(modal.getByTestId("session-details-energy")).toContainText("1:00"); @@ -410,7 +410,7 @@ test.describe("session details", async () => { await expect(modal.getByLabel("Charging point")).toHaveValue("Carport"); await expect(modal.getByLabel("Vehicle")).toHaveValue("blauer e-Golf"); await expect(modal.getByTestId("session-details-date")).toContainText( - ["Wed, Mar 1, 07:00", "Tue, May 2, 12:00"].join("") + ["Wed, Mar 1, 2023, 07:00", "Tue, May 2, 2023, 12:00"].join("") ); await expect(modal.getByTestId("session-details-energy")).toContainText("10.0 kWh"); await expect(modal.getByTestId("session-details-energy")).toContainText("1:00"); @@ -422,6 +422,31 @@ test.describe("session details", async () => { await expect(modal.getByTestId("session-details-odometer")).toContainText("12,345 km"); }); + test("date format setting (session 1)", async ({ page }) => { + await page.goto("/#/sessions?year=2023&month=3"); + const modal = page.getByTestId("session-details"); + const date = modal.getByTestId("session-details-date"); + + // auto format + await page.getByTestId("sessions-entry").nth(0).click(); + await expectModalVisible(modal); + await expect(date).toContainText("Wed, Mar 1, 2023, 07:00"); + await modal.getByRole("button", { name: "Close" }).click(); + await expectModalHidden(modal); + + await openMoreMenu(page); + await page.getByRole("button", { name: "User Interface", exact: true }).click(); + const settings = page.getByTestId("global-settings-modal"); + await expectModalVisible(settings); + await settings.getByLabel("Date format").selectOption("ymd"); + await settings.getByRole("button", { name: "Close" }).click(); + await expectModalHidden(settings); + + await page.getByTestId("sessions-entry").nth(0).click(); + await expectModalVisible(modal); + await expect(date).toContainText("Wed 2023-03-01 07:00"); + }); + test("edit session (session 5)", async ({ page }) => { await page.goto("/#/sessions?year=2023&month=5"); await page.getByTestId("sessions-entry").nth(0).click();