diff --git a/assets/js/components/GlobalSettings/UserInterfaceSettings.vue b/assets/js/components/GlobalSettings/UserInterfaceSettings.vue index b7997c583a2..fc7e58051f3 100644 --- a/assets/js/components/GlobalSettings/UserInterfaceSettings.vue +++ b/assets/js/components/GlobalSettings/UserInterfaceSettings.vue @@ -59,6 +59,18 @@ equal-width /> + + + @@ -91,7 +103,14 @@ 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, type UiLoadpoint } from "@/types/evcc"; @@ -111,6 +130,7 @@ export default defineComponent({ language: getLocalePreference() || "", unit: getUnits(), timeFormat: is12hFormat() ? TIME_12H : TIME_24H, + dateFormat: getDateFormat(), fullscreenActive: false, THEMES: Object.values(THEME), UNITS: Object.values(LENGTH_UNIT), @@ -141,6 +161,9 @@ export default defineComponent({ timeFormat(value) { set12hFormat(value === TIME_12H); }, + dateFormat(value) { + setDateFormat(value); + }, theme(value) { setThemePreference(value); }, diff --git a/assets/js/mixins/formatter.ts b/assets/js/mixins/formatter.ts index 9489dd4929a..fef8ad1d242 100644 --- a/assets/js/mixins/formatter.ts +++ b/assets/js/mixins/formatter.ts @@ -1,6 +1,44 @@ import { defineComponent } from "vue"; import { is12hFormat } from "@/units"; import { CURRENCY } from "../types/evcc"; +import settings from "@/settings"; +import type { DateFormat } from "@/settings"; + +// Format the day+month portion of a date according to the user's date-order +// preference. Month names are always rendered in the given UI locale so they +// stay translated regardless of the ordering choice. +// dmy → "17 May" (or "17 Mai" in German) +// mdy → "May 17" +// ymd → "2025-05-17" +// "" → locale-native (existing behaviour, auto) +function formatDayMonth(date: Date, locale: string | undefined, fmt: DateFormat): string { + const monthName = new Intl.DateTimeFormat(locale, { month: "short" }).format(date); + const day = date.getDate(); + const year = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, "0"); + const dd = String(day).padStart(2, "0"); + if (fmt === "mdy") return `${monthName} ${day}`; + if (fmt === "ymd") return `${year}-${mm}-${dd}`; + if (fmt === "dmy") return `${day} ${monthName}`; + // auto: use locale-native ordering + return new Intl.DateTimeFormat(locale, { month: "short", day: "numeric" }).format(date); +} + +// Format the numeric date portion (no month names) with the right day/month +// ordering. Used in the compact "short" date format. +// dmy → "17/05" (via en-GB locale) +// mdy → "5/17" (via en-US locale) +// ymd → "2025-05-17" (explicit construction — Intl does not include year with only month+day) +// "" → locale-native +function formatNumericDate(date: Date, locale: string | undefined, fmt: DateFormat): string { + if (fmt === "ymd") { + const mm = String(date.getMonth() + 1).padStart(2, "0"); + const dd = String(date.getDate()).padStart(2, "0"); + return `${date.getFullYear()}-${mm}-${dd}`; + } + const orderLocale = fmt === "mdy" ? "en-US" : fmt === "dmy" ? "en-GB" : locale; + return new Intl.DateTimeFormat(orderLocale, { month: "numeric", day: "numeric" }).format(date); +} const CURRENCY_SYMBOLS: Record = { AUD: "$", @@ -56,6 +94,11 @@ export default defineComponent({ fmtDigits: 1, }; }, + computed: { + dateFormat(): DateFormat { + return settings.dateFormat || ""; + }, + }, methods: { energyPriceSubunit(currency: CURRENCY): string | undefined { if (currency === CURRENCY.CHF) { @@ -244,14 +287,29 @@ export default defineComponent({ }).format(date); }, fmtFullDateTime(date: Date, short: boolean) { - return new Intl.DateTimeFormat(this.$i18n?.locale, { - weekday: short ? undefined : "short", - month: short ? "numeric" : "short", - day: "numeric", + 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 ? undefined : "short", + month: short ? "numeric" : "short", + day: "numeric", + hour: "numeric", + minute: "numeric", + hour12: is12hFormat(), + }).format(date); + } + const time = new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "numeric", hour12: is12hFormat(), }).format(date); + if (short) { + return `${formatNumericDate(date, locale, fmt)} ${time}`.trim(); + } + const weekday = new Intl.DateTimeFormat(locale, { weekday: "short" }).format(date); + return `${weekday} ${formatDayMonth(date, locale, fmt)} ${time}`.trim(); }, fmtWeekdayTime(date: Date) { return new Intl.DateTimeFormat(this.$i18n?.locale, { @@ -273,11 +331,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 = new Intl.DateTimeFormat(locale, { weekday: "short" }).format(date); + return `${weekday} ${formatDayMonth(date, locale, fmt)}`.trim(); }, fmtDurationUnit(value: number, unit = "second") { return new Intl.NumberFormat(this.$i18n?.locale, { diff --git a/assets/js/settings.ts b/assets/js/settings.ts index 1657c0bc176..f42689a2559 100644 --- a/assets/js/settings.ts +++ b/assets/js/settings.ts @@ -23,6 +23,7 @@ const SETTINGS_SOLAR_ADJUSTED = "settings_solar_adjusted"; 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"; @@ -98,6 +99,8 @@ function saveJSON(key: string) { }; } +export type DateFormat = "" | "dmy" | "mdy" | "ymd"; + export interface LoadpointSettings { order?: number; visible?: boolean; @@ -111,6 +114,7 @@ export interface Settings { theme: THEME | null; unit: string; is12hFormat: boolean; + dateFormat: DateFormat; // "" = auto, "dmy" = DD/MM, "mdy" = MM/DD, "ymd" = YYYY-MM-DD energyflowDetails: boolean; energyflowCo2: boolean; energyflowPv: boolean; @@ -139,6 +143,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), @@ -166,6 +171,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 cf50379be06..ccd07c3a2de 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; @@ -30,3 +31,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 8d852e51cc2..933842f9cb4 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1465,6 +1465,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",