evcc-io/assets/js/mixins/formatter.ts
Alexandre JARDON 37d8aecbb2
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>
2026-08-21 15:18:51 +00:00

614 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: "$",
BGN: "лв",
BRL: "R$",
CAD: "$",
CHF: "Fr.",
CNY: "¥",
CZK: "Kč",
EUR: "€",
GBP: "£",
HUF: "Ft",
ILS: "₪",
JPY: "¥",
NZD: "$",
NOK: "kr",
PLN: "zł",
RON: "lei",
USD: "$",
DKK: "kr",
SEK: "kr",
ZAR: "R",
TRY: "₺",
MYR: "RM",
THB: "฿",
};
// list of currencies where energy price should be displayed in subunits (factor 100)
const ENERGY_PRICE_IN_SUBUNIT: Partial<Record<CURRENCY, string>> = {
AUD: "c", // Australian cent
BGN: "st", // Bulgarian stotinka
BRL: "¢", // Brazilian centavo
CAD: "¢", // Canadian cent
EUR: "ct", // Euro cent
GBP: "p", // GB pence
ILS: "ag", // Israeli agora
NZD: "c", // New Zealand cent
NOK: "øre", // Norwegian øre
PLN: "gr", // Polish grosz
USD: "¢", // US cent
DKK: "øre", // Danish øre
SEK: "öre", // Swedish öre
ZAR: "c", // South African cent
TRY: "krş", // Türkiye kuruş
};
export enum POWER_UNIT {
W = "W",
KW = "kW",
MW = "MW",
AUTO = "",
}
export default defineComponent({
data() {
return {
POWER_UNIT,
fmtLimit: 100,
fmtDigits: 1,
};
},
computed: {
dateFormat(): DateFormat {
return settings.dateFormat || "";
},
},
methods: {
energyPriceSubunit(currency: CURRENCY): string | undefined {
if (currency === CURRENCY.CHF) {
return this.$i18n?.locale === "de" ? "Rp." : "ct.";
}
return ENERGY_PRICE_IN_SUBUNIT[currency];
},
round(num: number, precision: number) {
const base = 10 ** precision;
return (Math.round(num * base) / base).toFixed(precision);
},
getPowerUnit(watt: number): POWER_UNIT {
const abs = Math.abs(watt);
return abs >= 10_000_000 ? POWER_UNIT.MW : abs >= 1000 ? POWER_UNIT.KW : POWER_UNIT.W;
},
fmtW(watt = 0, format = POWER_UNIT.KW, withUnit = true, digits?: number) {
let unit = format;
let d = digits;
if (POWER_UNIT.AUTO === unit) {
unit = watt === 0 ? POWER_UNIT.KW : this.getPowerUnit(watt);
}
let value = watt;
if (POWER_UNIT.KW === unit) {
value = watt / 1000;
} else if (POWER_UNIT.MW === unit) {
value = watt / 1_000_000;
}
if (d === undefined) {
d =
POWER_UNIT.KW === unit || POWER_UNIT.MW === unit || (POWER_UNIT.W !== unit && 0 === watt)
? 1
: 0;
}
return `${new Intl.NumberFormat(this.$i18n?.locale, {
style: "decimal",
minimumFractionDigits: d,
maximumFractionDigits: d,
}).format(value)}${withUnit ? ` ${unit}` : ""}`;
},
fmtWh(watt: number, format = POWER_UNIT.KW, withUnit = true, digits?: number) {
return this.fmtW(watt, format, withUnit, digits) + (withUnit ? "h" : "");
},
fmtNumber(number: number, decimals: number | undefined, unit?: string) {
const style = unit ? "unit" : "decimal";
return new Intl.NumberFormat(this.$i18n?.locale, {
style,
unit,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(number);
},
fmtGrams(gramms: number, withUnit = true) {
let unit = "gram";
let value = gramms;
if (gramms >= 1000) {
unit = "kilogram";
value = gramms / 1000;
}
return new Intl.NumberFormat(this.$i18n?.locale, {
style: withUnit ? "unit" : "decimal",
unit,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
},
fmtCo2Short(gramms = 0) {
return `${this.fmtNumber(gramms, 0)} g`;
},
fmtCo2Medium(gramms = 0) {
return `${this.fmtNumber(gramms, 0)} g/kWh`;
},
fmtCo2Long(gramms = 0) {
return `${this.fmtNumber(gramms, 0)} gCO₂e/kWh`;
},
fmtNumberToLocale(val: number, pad = 0) {
return val.toLocaleString(this.$i18n?.locale).padStart(pad, "0");
},
fmtDurationToTime(date: Date) {
const diff = date.getTime() - new Date().getTime();
return this.fmtDuration(diff / 1000, true, "h");
},
fmtDurationNs(duration = 0, withUnit = true, minUnit = "s") {
return this.fmtDuration(duration / 1e9, withUnit, minUnit);
},
fmtDuration(duration = 0, withUnit = true, minUnit = "s") {
if (duration <= 0) {
return "—";
}
const roundedDuration = Math.round(duration);
const seconds = roundedDuration % 60;
const minutes = Math.floor(roundedDuration / 60) % 60;
const hours = Math.floor(roundedDuration / 3600);
let result = "";
let unit = "";
if (hours >= 1 || minUnit === "h") {
result = `${this.fmtNumberToLocale(hours)}:${this.fmtNumberToLocale(minutes, 2)}`;
unit = "h";
} else if (minutes >= 1 || minUnit === "m") {
result = `${this.fmtNumberToLocale(minutes)}:${this.fmtNumberToLocale(seconds, 2)}`;
unit = "m";
} else {
result = `${this.fmtNumberToLocale(seconds)}`;
unit = "s";
}
if (withUnit) {
result += `\u202F${unit}`;
}
return result;
},
fmtDurationLong(seconds: number, style: "short" | "long" = "long") {
// @ts-expect-error - Intl.DurationFormat is a new API not yet in TS types, see https://github.com/microsoft/TypeScript/issues/60608
if (!Intl.DurationFormat) {
// old browser fallback
return this.fmtDuration(seconds);
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
// @ts-expect-error - Intl.DurationFormat is a new API not yet in TS types, see https://github.com/microsoft/TypeScript/issues/60608
const formatter = new Intl.DurationFormat(this.$i18n?.locale, { style });
return formatter.format({ minutes, hours });
},
fmtDurationParts(parts: Record<string, number>) {
// @ts-expect-error - Intl.DurationFormat is a new API not yet in TS types
return new Intl.DurationFormat(this.$i18n?.locale, { style: "long" }).format(parts);
},
fmtDayString(date: Date) {
const YY = `${date.getFullYear()}`;
const MM = `${date.getMonth() + 1}`.padStart(2, "0");
const DD = `${date.getDate()}`.padStart(2, "0");
return `${YY}-${MM}-${DD}`;
},
fmtTimeString(date: Date) {
const HH = `${date.getHours()}`.padStart(2, "0");
const mm = `${date.getMinutes()}`.padStart(2, "0");
return `${HH}:${mm}`;
},
isToday(date: Date) {
const today = new Date();
return today.toDateString() === date.toDateString();
},
weekdayPrefix(date: Date) {
if (this.isToday(date)) {
return "";
}
return new Intl.DateTimeFormat(this.$i18n?.locale, {
weekday: "short",
}).format(date);
},
hourShort(date: Date) {
// keep only hour and AM/PM; drops locale noise like "Uhr" (de), "h" (fr) and leading zeros
return new Intl.DateTimeFormat(this.$i18n?.locale, {
hour: "numeric",
hour12: is12hFormat(),
})
.formatToParts(date)
.filter(({ type }) => type === "hour" || type === "dayPeriod")
.map(({ value }) => value.replace(/^0(?=\d)/, ""))
.join(" ");
},
weekdayShort(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {
weekday: "short",
}).format(date);
},
weekdayLong(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {
weekday: "long",
}).format(date);
},
fmtAbsoluteDate(date: Date) {
const weekday = this.weekdayPrefix(date);
const hour = new Intl.DateTimeFormat(this.$i18n?.locale, {
hour: "numeric",
minute: "numeric",
hour12: is12hFormat(),
}).format(date);
return `${weekday} ${hour}`.trim();
},
// "gestern"/"heute"/"morgen" within one day of now, null otherwise
relativeDayName(date: Date) {
const startOfDay = (d: Date) => new Date(d).setHours(0, 0, 0, 0);
const days = Math.round((startOfDay(date) - startOfDay(new Date())) / 86400000);
if (Math.abs(days) > 1) return null;
return new Intl.RelativeTimeFormat(this.$i18n?.locale, { numeric: "auto" }).format(
days,
"day"
);
},
// relative day plus time, e.g. "heute 16:30", "morgen 5:00", "Freitag 12:15"
fmtDayTime(date: Date) {
const time = this.fmtHourMinute(date);
const day = this.relativeDayName(date);
return `${day ?? this.weekdayLong(date)} ${time}`;
},
fmtHourMinute(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {
hour: "numeric",
minute: "numeric",
hour12: is12hFormat(),
}).format(date);
},
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, {
weekday: "short",
hour: "numeric",
minute: "numeric",
hour12: is12hFormat(),
}).format(date);
},
fmtMonthYear(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {
month: "long",
year: "numeric",
}).format(date);
},
fmtMonth(date: Date, short: boolean) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {
month: short ? "short" : "long",
}).format(date);
},
fmtDayMonth(date: 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, {
weekday: "short",
day: "numeric",
month: "short",
year: "numeric",
}).format(date);
},
fmtDayMonthShort(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {
day: "numeric",
month: "short",
}).format(date);
},
fmtMonthNarrow(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {
month: "narrow",
}).format(date);
},
// "HH:mm – HH:mm" honoring 12h/24h preference.
fmtTimeSlot(start: Date, durationMs: number) {
const end = new Date(start.getTime() + durationMs);
return `${this.fmtHourMinute(start)} – ${this.fmtHourMinute(end)}`;
},
fmtDurationUnit(value: number, unit = "second") {
return new Intl.NumberFormat(this.$i18n?.locale, {
style: "unit",
unit,
unitDisplay: "long",
})
.formatToParts(value)
.find((part) => part.type === "unit")?.value;
},
fmtMoney(amout = 0, currency = CURRENCY.EUR, decimals = true, withSymbol = false) {
const currencyDisplay = withSymbol ? "narrowSymbol" : "code";
const digits = decimals ? undefined : 0;
const result = new Intl.NumberFormat(this.$i18n?.locale, {
style: "currency",
currency,
currencyDisplay,
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(amout);
return withSymbol ? result : result.replace(currency, "").trim();
},
fmtCurrencySymbol(currency = CURRENCY.EUR) {
return CURRENCY_SYMBOLS[currency] || currency;
},
fmtCurrencyName(currency: CURRENCY) {
return (
new Intl.DisplayNames(this.$i18n?.locale, { type: "currency" }).of(currency) || currency
);
},
fmtPricePerKWh(amout = 0, currency = CURRENCY.EUR, short = false, withUnit = true) {
const factor = this.pricePerKWhDisplayFactor(currency);
const value = amout * factor;
const minimumFractionDigits = 1;
const maximumFractionDigits = this.energyPriceSubunit(currency) ? 1 : 3;
const price = new Intl.NumberFormat(this.$i18n?.locale, {
style: "decimal",
minimumFractionDigits,
maximumFractionDigits,
}).format(value);
if (withUnit) {
return `${price} ${this.pricePerKWhUnit(currency, short)}`;
}
return price;
},
timezone() {
return Intl?.DateTimeFormat?.().resolvedOptions?.().timeZone || "UTC";
},
pricePerKWhUnit(currency = CURRENCY.EUR, short = false) {
const unit = this.energyPriceSubunit(currency) || CURRENCY_SYMBOLS[currency] || currency;
return `${unit}${short ? "" : "/kWh"}`;
},
pricePerKWhDisplayFactor(currency = CURRENCY.EUR) {
return this.energyPriceSubunit(currency) ? 100 : 1;
},
fmtTimeAgo(elapsed: number) {
const units = {
day: 24 * 60 * 60 * 1000,
hour: 60 * 60 * 1000,
minute: 60 * 1000,
second: 1000,
};
// "Math.abs" accounts for both "past" & "future" scenarios
for (const u in units) {
const unitKey = u as keyof typeof units;
if (Math.abs(elapsed) > units[unitKey] || u == "second") {
try {
const rtf = new Intl.RelativeTimeFormat(this.$i18n?.locale, {
numeric: "auto",
});
return rtf.format(Math.round(elapsed / units[unitKey]), unitKey);
} catch (e) {
console.warn("fmtTimeAgo: Intl.RelativeTimeFormat not supported", e);
return `${elapsed} ${u}s ago`;
}
}
}
return "";
},
fmtSocOption(soc: number, rangePerSoc?: number, distanceUnit?: string, heating?: boolean) {
let result = heating ? this.fmtTemperature(soc) : `${this.fmtPercentage(soc)}`;
if (rangePerSoc && distanceUnit) {
const range = soc * rangePerSoc;
result += ` (${this.fmtNumber(range, 0)} ${distanceUnit})`;
}
return result;
},
fmtPercentage(value: number, digits = 0, forceSign = false) {
const sign = forceSign && value > 0 ? "+" : "";
return `${sign}${new Intl.NumberFormat(this.$i18n?.locale, {
style: "percent",
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(value / 100)}`;
},
hasLeadingPercentageSign() {
return ["tr", "ar"].includes(this.$i18n?.locale);
},
fmtTemperature(value: number) {
// TODO: handle fahrenheit
return this.fmtNumber(value, 1, "celsius");
},
fmtWeekdayByIndex(index: number, format: Intl.DateTimeFormatOptions["weekday"]) {
// June 7, 2021 is Monday (index 1), June 6 is Sunday (index 0)
const day = index === 0 ? 6 : 6 + index;
return new Intl.DateTimeFormat(this.$i18n?.locale, {
weekday: format,
}).format(new Date(2021, 5, day)); // local date avoids UTC timezone day shift
},
fmtMonthByIndex(index: number, format: Intl.DateTimeFormatOptions["month"]) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {
month: format,
}).format(new Date(2021, index, 1)); // local date avoids UTC timezone day shift
},
getWeekdaysList(
format: Intl.DateTimeFormatOptions["weekday"]
): { name: string; value: number }[] {
return Array.from({ length: 7 }, (_, i) => {
const value = (i + 1) % 7; // Mon=1, Tue=2, ..., Sat=6, Sun=0
return { name: this.fmtWeekdayByIndex(value, format), value };
});
},
getMonthsList(format: Intl.DateTimeFormatOptions["month"]): { name: string; value: number }[] {
return Array.from({ length: 12 }, (_, i) => ({
name: this.fmtMonthByIndex(i, format),
value: i,
}));
},
fmtConsecutiveRange(
selectedIndices: number[],
getNameFn: (transformedIndex: number) => string | undefined,
transformFn?: (index: number) => number
): string {
if (!selectedIndices || selectedIndices.length === 0) {
return "–";
}
// Transform indices if needed (e.g., Sunday 0 -> 7 for weekdays)
const workingIndices = transformFn ? selectedIndices.map(transformFn) : selectedIndices;
// Sort the indices
const sorted = [...workingIndices].sort((a, b) => a - b);
let label = "";
const max = Math.max(...sorted);
for (let i = 0; i < sorted.length; i++) {
const rangeStart = sorted[i];
if (rangeStart === undefined) continue;
label += getNameFn(rangeStart);
let rangeEnd = rangeStart;
let j = i;
// Find consecutive indices
while (j + 1 < sorted.length && sorted[j + 1] === rangeEnd + 1) {
rangeEnd++;
j++;
}
if (rangeEnd - rangeStart > 1) {
// more than 2 consecutive items selected
label += " – " + getNameFn(rangeEnd);
i = j;
} else if (rangeEnd > rangeStart) {
// 2 consecutive items selected
label += ", " + getNameFn(rangeEnd);
i = j;
}
const current = sorted[i];
if (current !== undefined && current < max) {
label += ", ";
}
}
return label;
},
fmtWeekdaysRange(selectedWeekdays: number[]): string {
const getName = (i: number) => this.fmtWeekdayByIndex(i % 7, "short");
const transform = (i: number) => i || 7;
return this.fmtConsecutiveRange(selectedWeekdays, getName, transform);
},
fmtMonthsRange(selectedMonths: number[]): string {
const getName = (i: number) => this.fmtMonthByIndex(i, "short");
return this.fmtConsecutiveRange(selectedMonths, getName);
},
// format a HH:MM to proper formatted time
fmtTimeStr(timeStr: string): string {
const [hour, minute] = timeStr.split(":").map((s) => parseInt(s, 10));
const date = new Date(2021, 0, 1);
date.setHours(hour!, minute!);
return new Intl.DateTimeFormat(this.$i18n?.locale, {
hour: "numeric",
minute: "2-digit",
hour12: is12hFormat(),
}).format(date);
},
// format a HH:MM-HH:MM to proper formatted range
fmtTimeRange(timeRange: string): string {
if (!timeRange) return "";
const parts = timeRange.split("-");
if (parts.length !== 2) return timeRange;
return `${this.fmtTimeStr(parts[0]!)} – ${this.fmtTimeStr(parts[1]!)}`;
},
},
});