, 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 @@
- {{ fmtFullDateTime(new Date(session.created), false) }}
+ {{ fmtFullDateTime(new Date(session.created)) }}
- {{ fmtFullDateTime(new Date(session.finished), false) }}
+ {{ fmtFullDateTime(new Date(session.finished)) }}
|
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();
|