UI: Add date format preference in user interface settings (#29321)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Michael Geers <michael@geers.tv>
This commit is contained in:
Alexandre JARDON 2026-08-21 17:18:51 +02:00 • committed by GitHub
parent 28bb5ba859
commit 37d8aecbb2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 195 additions and 18 deletions

View file

@ -59,6 +59,19 @@
equal-width
/>
</FormRow>
<FormRow id="settingsDateFormat" :label="$t('settings.dateFormat.label')">
<select
id="settingsDateFormat"
v-model="dateFormat"
class="form-select form-select-sm w-75"
>
<option value="">{{ $t("settings.dateFormat.auto") }}</option>
<option value="dmy">{{ $t("settings.dateFormat.dmy") }}</option>
<option value="mdy">{{ $t("settings.dateFormat.mdy") }}</option>
<option value="ymd">{{ $t("settings.dateFormat.ymd") }}</option>
</select>
<div class="form-text evcc-gray">{{ dateFormatExample }}</div>
</FormRow>
<FormRow v-if="loadpoints.length" :label="$t('settings.loadpoints.label')">
<LoadpointOrderSettings :loadpoints="loadpoints" />
</FormRow>
@ -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<UiLoadpoint[]>, 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] };

View file

@ -60,11 +60,11 @@
</th>
<td>
<template v-if="session.created">
{{ fmtFullDateTime(new Date(session.created), false) }}
{{ fmtFullDateTime(new Date(session.created)) }}
</template>
<br />
<template v-if="session.finished">
{{ fmtFullDateTime(new Date(session.finished), false) }}
{{ fmtFullDateTime(new Date(session.finished)) }}
</template>
</td>
</tr>

View file

@ -133,7 +133,7 @@
@click="showDetails(session.id)"
>
<td class="ps-0 tabular">
{{ fmtFullDateTime(new Date(session.created), true) }}
{{ fmtWeekdayDayTime(new Date(session.created)) }}
</td>
<td class="d-none d-md-table-cell">
{{ session.loadpoint }}

View file

@ -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);

View file

@ -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<CURRENCY, string> = {
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, {

View file

@ -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));

View file

@ -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;
}

View file

@ -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",

View file

@ -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();