Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions assets/js/components/ChargingPlans/ChargingPlan.vue
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,9 @@ export default defineComponent({
this.updateTargetTimeLabel();
},
},
timezone(): void {
this.updateTargetTimeLabel();
},
},
mounted(): void {
this.updateTargetTimeLabel();
Expand Down
15 changes: 12 additions & 3 deletions assets/js/components/ChargingPlans/PlanStaticSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@
</template>

<script lang="ts">
import { distanceUnit } from "@/units";
import { distanceUnit, parseLocalTimeInTz } from "@/units";

import formatter from "@/mixins/formatter";
import { energyOptions } from "@/utils/energyOptions.ts";
Expand Down Expand Up @@ -183,7 +183,13 @@ export default defineComponent({
},
computed: {
selectedDate() {
return new Date(`${this.selectedDay}T${this.selectedTime || "00:00"}`);
const dateTimeStr = `${this.selectedDay}T${this.selectedTime || "00:00"}`;
const tz = this.timezone;
const browserTz = Intl?.DateTimeFormat?.().resolvedOptions?.().timeZone || "UTC";
if (tz === browserTz) {
return new Date(dateTimeStr);
}
return parseLocalTimeInTz(dateTimeStr, tz);
},
socOptions() {
// a list of entries from 5 to 100 with a step of 5
Expand Down Expand Up @@ -298,13 +304,16 @@ export default defineComponent({
this.$t("main.targetCharge.today"),
this.$t("main.targetCharge.tomorrow"),
];
const tz = this.timezone;
for (let i = 0; i < 7; i++) {
const dayNumber = date.toLocaleDateString(this.$i18n?.locale, {
day: "numeric",
month: "short",
timeZone: tz,
});
const dayName =
labels[i] || date.toLocaleDateString(this.$i18n?.locale, { weekday: "short" });
labels[i] ||
date.toLocaleDateString(this.$i18n?.locale, { weekday: "short", timeZone: tz });
options.push({
value: this.fmtDayString(date),
name: `${dayNumber} (${dayName})`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export default defineComponent({
time: DEFAULT_TARGET_TIME,
soc: DEFAULT_TARGET_SOC,
active: false,
tz: this.timezone(),
tz: this.timezone,
};

// update the plan without storing non-applied changes from other plans
Expand Down
37 changes: 36 additions & 1 deletion assets/js/components/GlobalSettings/UserInterfaceSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,23 @@
equal-width
/>
</FormRow>
<FormRow id="settingsTimezone" :label="$t('settings.timezone.label')">
<select
id="settingsTimezone"
v-model="timezone"
class="form-select form-select-sm w-75"
>
<option value="">
{{ browserTimezone }} ({{ $t("settings.timezone.browser") }})
</option>
<option value="server">
{{ serverTimezone }} ({{ $t("settings.timezone.server") }})
</option>
<optgroup :label="$t('settings.timezone.custom')">
<option v-for="tz in allTimezones" :key="tz" :value="tz">{{ tz }}</option>
</optgroup>
</select>
</FormRow>
<FormRow v-if="loadpoints.length" :label="$t('settings.loadpoints.label')">
<LoadpointOrderSettings :loadpoints="loadpoints" />
</FormRow>
Expand Down Expand Up @@ -91,7 +108,8 @@ import {
removeLocalePreference,
} from "@/i18n.ts";
import { getThemePreference, setThemePreference } from "@/theme.ts";
import { getUnits, setUnits, is12hFormat, set12hFormat } from "@/units";
import { getUnits, setUnits, is12hFormat, set12hFormat, getTimezone, setTimezone } from "@/units";
import store from "@/store";
import { isApp } from "@/utils/native";
import { defineComponent, type PropType } from "vue";
import { LENGTH_UNIT, THEME, type UiLoadpoint } from "@/types/evcc";
Expand All @@ -111,13 +129,27 @@ export default defineComponent({
language: getLocalePreference() || "",
unit: getUnits(),
timeFormat: is12hFormat() ? TIME_12H : TIME_24H,
timezone: getTimezone(),
fullscreenActive: false,
THEMES: Object.values(THEME),
UNITS: Object.values(LENGTH_UNIT),
TIME_FORMATS: [TIME_24H, TIME_12H],
};
},
computed: {
browserTimezone(): string {
return Intl?.DateTimeFormat?.().resolvedOptions?.().timeZone || "UTC";
},
serverTimezone(): string {
return store.state.timezone || "UTC";
},
allTimezones(): string[] {
try {
return Intl.supportedValuesOf("timeZone");
} catch {
return [];
}
},
languageOptions: () => {
const locales = Object.entries(LOCALES).map(([key, value]) => {
return { value: key, name: value[1] };
Expand All @@ -141,6 +173,9 @@ export default defineComponent({
timeFormat(value) {
set12hFormat(value === TIME_12H);
},
timezone(value: string) {
setTimezone(value);
},
theme(value) {
setThemePreference(value);
},
Expand Down
141 changes: 85 additions & 56 deletions assets/js/mixins/formatter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
import { defineComponent } from "vue";
import { is12hFormat } from "@/units";
import { is12hFormat, resolveTimezone } from "@/units";
import { CURRENCY } from "../types/evcc";
import store from "@/store";
import settings from "@/settings";

// Returns a getter function for a specific part type from formatToParts output.
function formatPartsInTz(date: Date, timeZone: string, options: Intl.DateTimeFormatOptions) {
const parts = new Intl.DateTimeFormat("en-CA", { timeZone, ...options }).formatToParts(date);
return (type: string, fallback = "00") => parts.find((p) => p.type === type)?.value ?? fallback;
}

// Formats a date with the given locale, timezone, and options.
function formatInTz(
locale: string | undefined,
timeZone: string,
options: Intl.DateTimeFormatOptions,
date: Date
) {
return new Intl.DateTimeFormat(locale, { ...options, timeZone }).format(date);
}

const CURRENCY_SYMBOLS: Record<CURRENCY, string> = {
AUD: "$",
Expand Down Expand Up @@ -56,6 +74,11 @@ export default defineComponent({
fmtDigits: 1,
};
},
computed: {
timezone(): string {
return resolveTimezone(settings.timezone, store.state.timezone);
},
},
methods: {
energyPriceSubunit(currency: CURRENCY): string | undefined {
if (currency === CURRENCY.CHF) {
Expand Down Expand Up @@ -185,81 +208,90 @@ export default defineComponent({
return new Intl.DurationFormat(this.$i18n?.locale, { style: "long" }).format(parts);
},
fmtDayString(date: Date) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting shared Intl-based timezone formatting into small helpers and a reusable timezone resolver to reduce duplication and keep the mixin focused on display logic.

You can keep the timezone-aware behavior but trim a lot of duplication by extracting two small helpers and moving timezone resolution out of the mixin.

1. Factor out repeated Intl.DateTimeFormat + formatToParts logic

fmtDayString, fmtTimeString, isToday, and the German branch of hourShort all repeat the same pattern. A tiny helper will centralize that:

// in the same file, above `export default` or in a small util
function formatPartsInTz(
  date: Date,
  timeZone: string,
  options: Intl.DateTimeFormatOptions
) {
  const parts = new Intl.DateTimeFormat("en-CA", { timeZone, ...options }).formatToParts(date);
  return (type: string, fallback = "00") =>
    parts.find((p) => p.type === type)?.value ?? fallback;
}

Usage examples:

fmtDayString(date: Date) {
  const get = formatPartsInTz(date, this.timezone, {
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
  });
  return `${get("year")}-${get("month")}-${get("day")}`;
},

fmtTimeString(date: Date) {
  const get = formatPartsInTz(date, this.timezone, {
    hour: "2-digit",
    minute: "2-digit",
    hour12: false,
  });
  const HH = get("hour").replace("24", "00");
  const mm = get("minute");
  return `${HH}:${mm}`;
},

hourShort(date: Date) {
  const locale = this.$i18n?.locale;
  const tz = this.timezone;

  if (locale === "de") {
    const get = formatPartsInTz(date, tz, {
      hour: "numeric",
      hour12: false,
    });
    return Number(get("hour", "0"));
  }

  return new Intl.DateTimeFormat(locale, {
    hour: "numeric",
    hour12: is12hFormat(),
    timeZone: tz,
  }).format(date);
},

isToday can also reuse Intl.DateTimeFormat construction via a helper (see next section).

2. Factor out Intl.DateTimeFormat creation for the current timezone

You recreate new Intl.DateTimeFormat(this.$i18n?.locale, { …, timeZone: this.timezone }) in many methods. A small helper reduces noise and makes intent clearer:

function formatInTz(
  locale: string | undefined,
  timeZone: string,
  options: Intl.DateTimeFormatOptions,
  date: Date
) {
  return new Intl.DateTimeFormat(locale, { ...options, timeZone }).format(date);
}

Then:

isToday(date: Date) {
  const fmt = (d: Date) =>
    formatInTz("en-CA", this.timezone, {
      year: "numeric",
      month: "2-digit",
      day: "2-digit",
    }, d);

  return fmt(date) === fmt(new Date());
},

weekdayPrefix(date: Date) {
  if (this.isToday(date)) return "";
  return formatInTz(this.$i18n?.locale, this.timezone, { weekday: "short" }, date);
},

weekdayShort(date: Date) {
  return formatInTz(this.$i18n?.locale, this.timezone, { weekday: "short" }, date);
},

weekdayLong(date: Date) {
  return formatInTz(this.$i18n?.locale, this.timezone, { weekday: "long" }, date);
},

fmtAbsoluteDate(date: Date) {
  const weekday = this.weekdayPrefix(date);
  const hour = formatInTz(this.$i18n?.locale, this.timezone, {
    hour: "numeric",
    minute: "numeric",
    hour12: is12hFormat(),
  }, date);
  return `${weekday} ${hour}`.trim();
},

fmtHourMinute(date: Date) {
  return formatInTz(this.$i18n?.locale, this.timezone, {
    hour: "numeric",
    minute: "numeric",
    hour12: is12hFormat(),
  }, date);
},

fmtFullDateTime(date: Date, short: boolean) {
  return formatInTz(this.$i18n?.locale, this.timezone, {
    weekday: short ? undefined : "short",
    month: short ? "numeric" : "short",
    day: "numeric",
    hour: "numeric",
    minute: "numeric",
    hour12: is12hFormat(),
  }, date);
},

fmtWeekdayTime(date: Date) {
  return formatInTz(this.$i18n?.locale, this.timezone, {
    weekday: "short",
    hour: "numeric",
    minute: "numeric",
    hour12: is12hFormat(),
  }, date);
},

This keeps all behavior but makes each formatter read as “what do I want to show” rather than “how do I wire up Intl and timeZone again”.

3. Move timezone resolution out of the mixin

The timezone computed now mixes UI formatting with global settings and store state. You can keep behavior identical but move the logic into a reusable utility, which the mixin calls:

// e.g. in @/units or @/settings
export function resolveTimezone(userTz: string | undefined, storeTz: string | undefined): string {
  const pref = userTz || "";
  const browserTz = Intl?.DateTimeFormat?.().resolvedOptions?.().timeZone || "UTC";

  if (!pref) return browserTz;
  if (pref === "server") return storeTz || browserTz;
  return pref;
}

Then in this mixin:

import { resolveTimezone } from "@/settings"; // or "@/units"

computed: {
  timezone(): string {
    return resolveTimezone(settings.timezone, store.state.timezone);
  },
},

That keeps this file focused on formatting, isolates the policy of “which timezone wins”, and makes it trivial to reuse/test elsewhere.

const YY = `${date.getFullYear()}`;
const MM = `${date.getMonth() + 1}`.padStart(2, "0");
const DD = `${date.getDate()}`.padStart(2, "0");
return `${YY}-${MM}-${DD}`;
const get = formatPartsInTz(date, this.timezone, {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
return `${get("year")}-${get("month")}-${get("day")}`;
},
fmtTimeString(date: Date) {
const HH = `${date.getHours()}`.padStart(2, "0");
const mm = `${date.getMinutes()}`.padStart(2, "0");
return `${HH}:${mm}`;
const get = formatPartsInTz(date, this.timezone, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
return `${get("hour").replace("24", "00")}:${get("minute")}`;
},
isToday(date: Date) {
const today = new Date();
return today.toDateString() === date.toDateString();
const fmt = (d: Date) =>
formatInTz(
"en-CA",
this.timezone,
{ year: "numeric", month: "2-digit", day: "2-digit" },
d
);
return fmt(date) === fmt(new Date());
},
weekdayPrefix(date: Date) {
if (this.isToday(date)) {
return "";
}
return new Intl.DateTimeFormat(this.$i18n?.locale, {
weekday: "short",
}).format(date);
if (this.isToday(date)) return "";
return formatInTz(this.$i18n?.locale, this.timezone, { weekday: "short" }, date);
},
hourShort(date: Date) {
const locale = this.$i18n?.locale;
// special: use shorter german format
if (locale === "de") return date.getHours();
return new Intl.DateTimeFormat(locale, {
hour: "numeric",
hour12: is12hFormat(),
}).format(date);
const tz = this.timezone;
// special: use shorter german format (numeric hour without AM/PM)
if (locale === "de") {
return Number(formatPartsInTz(date, tz, { hour: "numeric", hour12: false })("hour", "0"));
}
return formatInTz(locale, tz, { hour: "numeric", hour12: is12hFormat() }, date);
},
weekdayShort(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {
weekday: "short",
}).format(date);
return formatInTz(this.$i18n?.locale, this.timezone, { weekday: "short" }, date);
},
weekdayLong(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {
weekday: "long",
}).format(date);
return formatInTz(this.$i18n?.locale, this.timezone, { weekday: "long" }, 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);

const hour = formatInTz(
this.$i18n?.locale,
this.timezone,
{ hour: "numeric", minute: "numeric", hour12: is12hFormat() },
date
);
return `${weekday} ${hour}`.trim();
},
fmtHourMinute(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {
hour: "numeric",
minute: "numeric",
hour12: is12hFormat(),
}).format(date);
return formatInTz(
this.$i18n?.locale,
this.timezone,
{ hour: "numeric", minute: "numeric", hour12: is12hFormat() },
date
);
},
fmtFullDateTime(date: Date, short: boolean) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {
weekday: short ? undefined : "short",
month: short ? "numeric" : "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
hour12: is12hFormat(),
}).format(date);
return formatInTz(
this.$i18n?.locale,
this.timezone,
{
weekday: short ? undefined : "short",
month: short ? "numeric" : "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
hour12: is12hFormat(),
},
date
);
},
fmtWeekdayTime(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {
weekday: "short",
hour: "numeric",
minute: "numeric",
hour12: is12hFormat(),
}).format(date);
return formatInTz(
this.$i18n?.locale,
this.timezone,
{ weekday: "short", hour: "numeric", minute: "numeric", hour12: is12hFormat() },
date
);
},
fmtMonthYear(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {
Expand Down Expand Up @@ -324,9 +356,6 @@ export default defineComponent({
}
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"}`;
Expand Down
4 changes: 4 additions & 0 deletions assets/js/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_TIMEZONE = "settings_timezone";
const LAST_TARGET_TIME = "last_target_time";
const LAST_SOC_GOAL = "last_soc_goal";
const LAST_ENERGY_GOAL = "last_energy_goal";
Expand Down Expand Up @@ -111,6 +112,7 @@ export interface Settings {
theme: THEME | null;
unit: string;
is12hFormat: boolean;
timezone: string; // "" = browser, "server" = server IANA, or explicit IANA name
energyflowDetails: boolean;
energyflowCo2: boolean;
energyflowPv: boolean;
Expand Down Expand Up @@ -139,6 +141,7 @@ const settings: Settings = reactive({
theme: read(SETTINGS_THEME),
unit: read(SETTINGS_UNIT),
is12hFormat: readBool(SETTINGS_12H_FORMAT),
timezone: read(SETTINGS_TIMEZONE) || "",
energyflowDetails: readBool(SETTINGS_ENERGYFLOW_DETAILS),
energyflowCo2: readBool(SETTINGS_ENERGYFLOW_CO2),
energyflowPv: readBool(SETTINGS_ENERGYFLOW_PV),
Expand Down Expand Up @@ -166,6 +169,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.timezone, save(SETTINGS_TIMEZONE));
watch(() => settings.energyflowDetails, saveBool(SETTINGS_ENERGYFLOW_DETAILS));
watch(() => settings.energyflowCo2, saveBool(SETTINGS_ENERGYFLOW_CO2));
watch(() => settings.energyflowPv, saveBool(SETTINGS_ENERGYFLOW_PV));
Expand Down
Loading
Loading