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 equal-width
/> />
</FormRow> </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')"> <FormRow v-if="loadpoints.length" :label="$t('settings.loadpoints.label')">
<LoadpointOrderSettings :loadpoints="loadpoints" /> <LoadpointOrderSettings :loadpoints="loadpoints" />
</FormRow> </FormRow>
@ -91,14 +104,24 @@ import {
removeLocalePreference, removeLocalePreference,
} from "@/i18n.ts"; } from "@/i18n.ts";
import { getThemePreference, setThemePreference } from "@/theme.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 { isApp } from "@/utils/native";
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import { LENGTH_UNIT, THEME, TIME_FORMAT, type UiLoadpoint } from "@/types/evcc"; import { LENGTH_UNIT, THEME, TIME_FORMAT, type UiLoadpoint } from "@/types/evcc";
import formatter from "@/mixins/formatter";
import type { DateFormat } from "@/settings";
export default defineComponent({ export default defineComponent({
name: "UserInterfaceSettings", name: "UserInterfaceSettings",
components: { FormRow, SelectGroup, LoadpointOrderSettings }, components: { FormRow, SelectGroup, LoadpointOrderSettings },
mixins: [formatter],
props: { props: {
loadpoints: { type: Array as PropType<UiLoadpoint[]>, default: () => [] }, loadpoints: { type: Array as PropType<UiLoadpoint[]>, default: () => [] },
}, },
@ -115,6 +138,17 @@ export default defineComponent({
}; };
}, },
computed: { computed: {
dateFormat: {
get(): DateFormat {
return getDateFormat();
},
set(value: DateFormat) {
setDateFormat(value);
},
},
dateFormatExample(): string {
return this.fmtFullDateTime(new Date(2015, 9, 21, 16, 29));
},
languageOptions: () => { languageOptions: () => {
const locales = Object.entries(LOCALES).map(([key, value]) => { const locales = Object.entries(LOCALES).map(([key, value]) => {
return { value: key, name: value[1] }; return { value: key, name: value[1] };

View file

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

View file

@ -133,7 +133,7 @@
@click="showDetails(session.id)" @click="showDetails(session.id)"
> >
<td class="ps-0 tabular"> <td class="ps-0 tabular">
{{ fmtFullDateTime(new Date(session.created), true) }} {{ fmtWeekdayDayTime(new Date(session.created)) }}
</td> </td>
<td class="d-none d-md-table-cell"> <td class="d-none d-md-table-cell">
{{ session.loadpoint }} {{ 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 { describe, expect, test, vi } from "vite-plus/test";
import formatter, { POWER_UNIT } from "./formatter"; import formatter, { POWER_UNIT } from "./formatter";
import * as units from "../units"; import * as units from "../units";
import settings from "../settings";
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import { CURRENCY } from "@/types/evcc"; import { CURRENCY } from "@/types/evcc";
@ -275,7 +276,7 @@ describe("12h/24h time format", () => {
test("12h format", () => { test("12h format", () => {
is12hSpy.mockReturnValue(true); is12hSpy.mockReturnValue(true);
expect(fmt.fmtHourMinute(testDate)).toBe("3:30 PM"); 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.fmtWeekdayTime(testDate)).toBe("So. 3:30 PM");
expect(fmt.fmtAbsoluteDate(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", () => { test("24h format", () => {
is12hSpy.mockReturnValue(false); is12hSpy.mockReturnValue(false);
expect(fmt.fmtHourMinute(testDate)).toBe("15:30"); 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.fmtWeekdayTime(testDate)).toBe("So., 15:30");
expect(fmt.fmtAbsoluteDate(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", () => { describe("fmtTimeRange", () => {
test("should format time ranges in 24h format", () => { test("should format time ranges in 24h format", () => {
is12hSpy.mockReturnValue(false); is12hSpy.mockReturnValue(false);

View file

@ -1,6 +1,55 @@
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import { is12hFormat } from "@/units"; import { is12hFormat } from "@/units";
import { CURRENCY } from "../types/evcc"; 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> = { const CURRENCY_SYMBOLS: Record<CURRENCY, string> = {
AUD: "$", AUD: "$",
@ -62,6 +111,11 @@ export default defineComponent({
fmtDigits: 1, fmtDigits: 1,
}; };
}, },
computed: {
dateFormat(): DateFormat {
return settings.dateFormat || "";
},
},
methods: { methods: {
energyPriceSubunit(currency: CURRENCY): string | undefined { energyPriceSubunit(currency: CURRENCY): string | undefined {
if (currency === CURRENCY.CHF) { if (currency === CURRENCY.CHF) {
@ -265,15 +319,33 @@ export default defineComponent({
hour12: is12hFormat(), hour12: is12hFormat(),
}).format(date); }).format(date);
}, },
fmtFullDateTime(date: Date, short: boolean) { fmtFullDateTime(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, { const locale = this.$i18n?.locale;
weekday: short ? undefined : "short", const fmt = this.dateFormat;
month: short ? "numeric" : "short", if (!fmt) {
day: "numeric", // 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", hour: "numeric",
minute: "numeric", minute: "numeric",
hour12: is12hFormat(), hour12: is12hFormat(),
}).format(date); }).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) { fmtWeekdayTime(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, { return new Intl.DateTimeFormat(this.$i18n?.locale, {
@ -295,11 +367,17 @@ export default defineComponent({
}).format(date); }).format(date);
}, },
fmtDayMonth(date: Date) { fmtDayMonth(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, { const locale = this.$i18n?.locale;
weekday: "short", const fmt = this.dateFormat;
day: "numeric", if (!fmt) {
month: "short", return new Intl.DateTimeFormat(locale, {
}).format(date); weekday: "short",
day: "numeric",
month: "short",
}).format(date);
}
const weekday = datePart(date, locale, "weekday");
return `${weekday} ${formatDayMonth(date, locale, fmt)}`.trim();
}, },
fmtDayMonthYear(date: Date) { fmtDayMonthYear(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, { 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_PRICE_ZOOM = "settings_price_zoom";
const SETTINGS_HIDE_FEEDIN = "settings_hide_feedin"; const SETTINGS_HIDE_FEEDIN = "settings_hide_feedin";
const LAST_BATTERY_SMART_COST_LIMIT = "last_battery_smart_cost_limit"; 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_TARGET_TIME = "last_target_time";
const LAST_SOC_GOAL = "last_soc_goal"; const LAST_SOC_GOAL = "last_soc_goal";
const LAST_ENERGY_GOAL = "last_energy_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 { export interface LoadpointSettings {
order?: number; order?: number;
visible?: boolean; visible?: boolean;
@ -112,6 +115,7 @@ export interface Settings {
theme: THEME | null; theme: THEME | null;
unit: string; unit: string;
is12hFormat: boolean; is12hFormat: boolean;
dateFormat: DateFormat;
energyflowDetails: boolean; energyflowDetails: boolean;
energyflowCo2: boolean; energyflowCo2: boolean;
energyflowPv: boolean; energyflowPv: boolean;
@ -141,6 +145,7 @@ const settings: Settings = reactive({
theme: read(SETTINGS_THEME), theme: read(SETTINGS_THEME),
unit: read(SETTINGS_UNIT), unit: read(SETTINGS_UNIT),
is12hFormat: readBool(SETTINGS_12H_FORMAT), is12hFormat: readBool(SETTINGS_12H_FORMAT),
dateFormat: read(SETTINGS_DATE_FORMAT) || "",
energyflowDetails: readBool(SETTINGS_ENERGYFLOW_DETAILS), energyflowDetails: readBool(SETTINGS_ENERGYFLOW_DETAILS),
energyflowCo2: readBool(SETTINGS_ENERGYFLOW_CO2), energyflowCo2: readBool(SETTINGS_ENERGYFLOW_CO2),
energyflowPv: readBool(SETTINGS_ENERGYFLOW_PV), energyflowPv: readBool(SETTINGS_ENERGYFLOW_PV),
@ -169,6 +174,7 @@ watch(() => settings.locale, save(SETTINGS_LOCALE));
watch(() => settings.theme, save(SETTINGS_THEME)); watch(() => settings.theme, save(SETTINGS_THEME));
watch(() => settings.unit, save(SETTINGS_UNIT)); watch(() => settings.unit, save(SETTINGS_UNIT));
watch(() => settings.is12hFormat, saveBool(SETTINGS_12H_FORMAT)); watch(() => settings.is12hFormat, saveBool(SETTINGS_12H_FORMAT));
watch(() => settings.dateFormat, save(SETTINGS_DATE_FORMAT));
watch(() => settings.energyflowDetails, saveBool(SETTINGS_ENERGYFLOW_DETAILS)); watch(() => settings.energyflowDetails, saveBool(SETTINGS_ENERGYFLOW_DETAILS));
watch(() => settings.energyflowCo2, saveBool(SETTINGS_ENERGYFLOW_CO2)); watch(() => settings.energyflowCo2, saveBool(SETTINGS_ENERGYFLOW_CO2));
watch(() => settings.energyflowPv, saveBool(SETTINGS_ENERGYFLOW_PV)); watch(() => settings.energyflowPv, saveBool(SETTINGS_ENERGYFLOW_PV));

View file

@ -1,4 +1,5 @@
import settings from "./settings"; import settings from "./settings";
import type { DateFormat } from "./settings";
import { LENGTH_UNIT } from "./types/evcc"; import { LENGTH_UNIT } from "./types/evcc";
const MILES_FACTOR = 0.6213711922; const MILES_FACTOR = 0.6213711922;
@ -34,3 +35,11 @@ export function is12hFormat() {
export function set12hFormat(value: boolean) { export function set12hFormat(value: boolean) {
settings.is12hFormat = value; 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" "vehicle": "Vehicle"
}, },
"settings": { "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.", "deviceInfo": "Settings you make in this dialog only affect this device.",
"fullscreen": { "fullscreen": {
"enter": "Enter 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("Charging point")).toHaveValue("Garage");
await expect(modal.getByLabel("Vehicle")).toHaveValue("weißes Model 3"); await expect(modal.getByLabel("Vehicle")).toHaveValue("weißes Model 3");
await expect(modal.getByTestId("session-details-date")).toContainText( 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("5.0 kWh");
await expect(modal.getByTestId("session-details-energy")).toContainText("1:00"); 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("Charging point")).toHaveValue("Carport");
await expect(modal.getByLabel("Vehicle")).toHaveValue("blauer e-Golf"); await expect(modal.getByLabel("Vehicle")).toHaveValue("blauer e-Golf");
await expect(modal.getByTestId("session-details-date")).toContainText( 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("10.0 kWh");
await expect(modal.getByTestId("session-details-energy")).toContainText("1:00"); 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"); 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 }) => { test("edit session (session 5)", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=5"); await page.goto("/#/sessions?year=2023&month=5");
await page.getByTestId("sessions-entry").nth(0).click(); await page.getByTestId("sessions-entry").nth(0).click();