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:
parent
28bb5ba859
commit
37d8aecbb2
9 changed files with 195 additions and 18 deletions
|
|
@ -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] };
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue