diff --git a/assets/css/app.css b/assets/css/app.css index cc748436c..15171e613 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -21,7 +21,7 @@ --evcc-darkest-green: #076f20ff; --evcc-darkest-green-rgb: 7, 111, 32; --evcc-yellow: #faf000; - --evcc-dark-yellow: #bbb400; + --evcc-dark-yellow: #f6bb0f; --evcc-orange: #ff9000; --evcc-orange-rgb: 255, 144, 0; --evcc-red: #fc440f; @@ -39,6 +39,7 @@ --evcc-pv: var(--evcc-dark-green); --evcc-battery: var(--evcc-darker-green); --evcc-export: var(--evcc-yellow); + --evcc-export-contrast: var(--evcc-dark-yellow); --evcc-price: #ff912fff; --evcc-co2: #00916eff; --evcc-background: var(--bs-gray-bright); @@ -100,6 +101,8 @@ :root.dark { --evcc-grid: var(--bs-gray-medium); + --evcc-export: var(--evcc-yellow); + --evcc-export-contrast: var(--evcc-yellow); --evcc-background: var(--bs-gray-deep); --evcc-box: var(--bs-gray-dark); --evcc-box-border: var(--bs-gray-darker); @@ -186,6 +189,10 @@ h5 { color: var(--evcc-co2) !important; } +.text-export { + color: var(--evcc-export-contrast) !important; +} + a { color: var(--bs-primary); } diff --git a/assets/js/colors.ts b/assets/js/colors.ts index f47242ec5..a98765010 100644 --- a/assets/js/colors.ts +++ b/assets/js/colors.ts @@ -17,6 +17,7 @@ const colors: { pricePerKWh: string | null; price: string | null; co2: string | null; + export: string | null; background: string | null; light: string | null; selfPalette: string[]; @@ -31,6 +32,7 @@ const colors: { pricePerKWh: null, price: null, co2: null, + export: null, background: null, light: null, selfPalette: ["#0FDE41FF", "#FFBD2FFF", "#FD6158FF", "#03C1EFFF", "#0F662DFF", "#FF922EFF"], @@ -75,6 +77,7 @@ export function updateCssColors() { colors.grid = style.getPropertyValue("--evcc-grid"); colors.price = style.getPropertyValue("--evcc-price"); colors.co2 = style.getPropertyValue("--evcc-co2"); + colors.export = style.getPropertyValue("--evcc-export-contrast"); colors.background = style.getPropertyValue("--evcc-background"); colors.pricePerKWh = style.getPropertyValue("--bs-gray-medium"); colors.co2PerKWh = style.getPropertyValue("--bs-gray-medium"); diff --git a/assets/js/components/Forecast/Co2Chart.vue b/assets/js/components/Forecast/Co2Chart.vue index ea688deea..5a3d15d3d 100644 --- a/assets/js/components/Forecast/Co2Chart.vue +++ b/assets/js/components/Forecast/Co2Chart.vue @@ -95,7 +95,7 @@ export default defineComponent({ { type: "line", data: this.slots.map((s) => [s.start, s.value]), - smooth: 0.05, + smooth: true, symbol: "circle", symbolSize: 6, showSymbol: false, diff --git a/assets/js/components/Forecast/Co2Details.vue b/assets/js/components/Forecast/Co2Details.vue new file mode 100644 index 000000000..3358f0b5d --- /dev/null +++ b/assets/js/components/Forecast/Co2Details.vue @@ -0,0 +1,57 @@ + + + + + {{ $t("forecast.co2.range") }} + + {{ range }} + + + + + {{ $t("forecast.co2.average") }} + + {{ average }} + + + + + + diff --git a/assets/js/components/Forecast/GridDetails.vue b/assets/js/components/Forecast/GridDetails.vue new file mode 100644 index 000000000..70d5cecb0 --- /dev/null +++ b/assets/js/components/Forecast/GridDetails.vue @@ -0,0 +1,107 @@ + + + + + {{ $t("main.energyflow.gridImport") }} + + + {{ gridSummary!.avg }} + {{ + gridSummary!.range + }} + + + + + + {{ $t("main.energyflow.pvExport") }} + + {{ feedinSummary!.avg }} + {{ + feedinSummary!.range + }} + + {{ + showFeedin ? $t("forecast.hideLine") : $t("forecast.showLine") + }} + + + + + + + {{ $t("forecast.price.range") }} + + {{ gridSummary.range || gridSummary.avg }} + + + + + {{ $t("forecast.price.average") }} + + {{ gridSummary.avg }} + + + + + + diff --git a/assets/js/components/Forecast/PriceChart.vue b/assets/js/components/Forecast/PriceChart.vue index 00298fa81..6a57b9019 100644 --- a/assets/js/components/Forecast/PriceChart.vue +++ b/assets/js/components/Forecast/PriceChart.vue @@ -7,6 +7,7 @@ diff --git a/assets/js/components/Forecast/SolarChart.vue b/assets/js/components/Forecast/SolarChart.vue index 528279bef..ba97ee967 100644 --- a/assets/js/components/Forecast/SolarChart.vue +++ b/assets/js/components/Forecast/SolarChart.vue @@ -103,7 +103,7 @@ export default defineComponent({ { type: "line", data, - smooth: 0.05, + smooth: true, symbol: "circle", symbolSize: 6, showSymbol: false, diff --git a/assets/js/components/Forecast/SolarDetails.vue b/assets/js/components/Forecast/SolarDetails.vue new file mode 100644 index 000000000..32497c190 --- /dev/null +++ b/assets/js/components/Forecast/SolarDetails.vue @@ -0,0 +1,79 @@ + + + + + {{ day.label }} + + + {{ day.energy }} + {{ day.note }} + + + + + + + diff --git a/assets/js/components/Forecast/chartStyles.css b/assets/js/components/Forecast/chartStyles.css index 5b9f3e23e..10f4a53a4 100644 --- a/assets/js/components/Forecast/chartStyles.css +++ b/assets/js/components/Forecast/chartStyles.css @@ -1,6 +1,7 @@ .forecast-chart-scroll { overflow-x: auto; padding-bottom: 4px; + margin-bottom: 0.5rem; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; diff --git a/assets/js/components/Forecast/echarts.ts b/assets/js/components/Forecast/echarts.ts index d56556b54..9fde86ba1 100644 --- a/assets/js/components/Forecast/echarts.ts +++ b/assets/js/components/Forecast/echarts.ts @@ -53,7 +53,7 @@ export function markPointLabel( animation: true, clip: true, symbol: "rect", - symbolSize: 1, + symbolSize: 0, label: { show: true, position: "top", @@ -63,7 +63,7 @@ export function markPointLabel( color: colors.background, backgroundColor: color, borderRadius: 4, - padding: [5, 10], + padding: [5, 15], offset: [0, -2], formatter: (p: { data: { value: string } }) => p.data.value, }, @@ -111,7 +111,7 @@ export function tooltipStyle( } export function forecastGrid() { - return { top: 36, right: 16, bottom: 4, left: 34, borderWidth: 0 }; + return { top: 36, right: 16, bottom: 16, left: 24, borderWidth: 0 }; } export function forecastXAxes(startDate: Date, endDate: Date, weekdayShort: (d: Date) => string) { @@ -167,13 +167,13 @@ export function forecastYAxis(overrides: Record = {}) { axisLine: { show: false }, axisTick: { show: false }, splitLine: { - showMinLine: false, + showMinLine: true, showMaxLine: false, lineStyle: { color: colors.border || "" }, }, axisLabel: { fontSize: 10, - opacity: 0.5, + opacity: 0.75, ...(axisLabel as Record), }, ...rest, diff --git a/assets/js/mixins/formatter.ts b/assets/js/mixins/formatter.ts index 8562f5972..9489dd492 100644 --- a/assets/js/mixins/formatter.ts +++ b/assets/js/mixins/formatter.ts @@ -221,6 +221,11 @@ export default defineComponent({ 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, { diff --git a/assets/js/settings.ts b/assets/js/settings.ts index fcd200779..9ea7fcff8 100644 --- a/assets/js/settings.ts +++ b/assets/js/settings.ts @@ -21,6 +21,7 @@ const SESSIONS_GROUP = "sessions_group"; const SESSIONS_TYPE = "sessions_type"; 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 LAST_TARGET_TIME = "last_target_time"; const LAST_SOC_GOAL = "last_soc_goal"; @@ -124,6 +125,7 @@ export interface Settings { sessionsType: string; solarAdjusted: boolean; priceZoom: boolean; + hideFeedin: boolean; loadpoints: Record; lastBatterySmartCostLimit: number | undefined; lastTargetTime: string | null; @@ -151,6 +153,7 @@ const settings: Settings = reactive({ sessionsType: read(SESSIONS_TYPE), solarAdjusted: readBool(SETTINGS_SOLAR_ADJUSTED), priceZoom: readBool(SETTINGS_PRICE_ZOOM), + hideFeedin: readBool(SETTINGS_HIDE_FEEDIN), loadpoints: readJSON(LOADPOINTS), lastBatterySmartCostLimit: readNumber(LAST_BATTERY_SMART_COST_LIMIT), lastTargetTime: read(LAST_TARGET_TIME), @@ -177,6 +180,7 @@ watch(() => settings.sessionsGroup, save(SESSIONS_GROUP)); watch(() => settings.sessionsType, save(SESSIONS_TYPE)); watch(() => settings.solarAdjusted, saveBool(SETTINGS_SOLAR_ADJUSTED)); watch(() => settings.priceZoom, saveBool(SETTINGS_PRICE_ZOOM)); +watch(() => settings.hideFeedin, saveBool(SETTINGS_HIDE_FEEDIN)); watch(() => settings.loadpoints, saveJSON(LOADPOINTS), { deep: true }); watch(() => settings.lastBatterySmartCostLimit, saveNumber(LAST_BATTERY_SMART_COST_LIMIT)); watch(() => settings.lastTargetTime, save(LAST_TARGET_TIME)); diff --git a/assets/js/utils/forecast.test.ts b/assets/js/utils/forecast.test.ts index 7a1ba0265..f6f1b6b74 100644 --- a/assets/js/utils/forecast.test.ts +++ b/assets/js/utils/forecast.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { findLowestSumSlotIndex } from "./forecast"; +import { findLowestSumSlotIndex, isStaticTariff } from "./forecast"; describe("findLowestSumSlotIndex", () => { test("finds lowest sum with span of 4", () => { @@ -55,3 +55,31 @@ describe("findLowestSumSlotIndex", () => { expect(findLowestSumSlotIndex(slots, 2)).toBe(0); }); }); + +describe("isStaticTariff", () => { + test("returns false for undefined", () => { + expect(isStaticTariff(undefined)).toBe(false); + }); + + test("returns false for empty array", () => { + expect(isStaticTariff([])).toBe(false); + }); + + test("returns true when all values are identical", () => { + const slots = [ + { start: "2025-01-01T00:00:00Z", end: "2025-01-01T00:15:00Z", value: 0.25 }, + { start: "2025-01-01T00:15:00Z", end: "2025-01-01T00:30:00Z", value: 0.25 }, + { start: "2025-01-01T00:30:00Z", end: "2025-01-01T00:45:00Z", value: 0.25 }, + ]; + expect(isStaticTariff(slots)).toBe(true); + }); + + test("returns false when values differ", () => { + const slots = [ + { start: "2025-01-01T00:00:00Z", end: "2025-01-01T00:15:00Z", value: 0.25 }, + { start: "2025-01-01T00:15:00Z", end: "2025-01-01T00:30:00Z", value: 0.3 }, + { start: "2025-01-01T00:30:00Z", end: "2025-01-01T00:45:00Z", value: 0.25 }, + ]; + expect(isStaticTariff(slots)).toBe(false); + }); +}); diff --git a/assets/js/utils/forecast.ts b/assets/js/utils/forecast.ts index 66314158e..e18719493 100644 --- a/assets/js/utils/forecast.ts +++ b/assets/js/utils/forecast.ts @@ -1,4 +1,4 @@ -import type { TimeseriesEntry, SolarDetails } from "../components/Forecast/types"; +import type { ForecastSlot, TimeseriesEntry, SolarDetails } from "../components/Forecast/types"; import deepCopy from "./deepClone"; export enum ForecastType { @@ -65,6 +65,12 @@ export function adjustedSolar(solar?: SolarDetails): SolarDetails | undefined { return result; } +export function isStaticTariff(slots?: ForecastSlot[]): boolean { + if (!Array.isArray(slots) || slots.length === 0) return false; + const firstValue = slots[0].value; + return slots.every((slot) => slot.value === firstValue); +} + export interface SlotWithValue { start: string | Date; value: number; diff --git a/assets/js/views/Forecast.vue b/assets/js/views/Forecast.vue index 2410839d4..c1cd0b682 100644 --- a/assets/js/views/Forecast.vue +++ b/assets/js/views/Forecast.vue @@ -41,19 +41,10 @@ - + - - - {{ - $t("forecast.type.solar") - }} - {{ - solarSubtitle - }} - + + {{ $t("forecast.type.solar") }} - {{ solarAdjustText }} + {{ solarAdjustTextShort }} + {{ solarAdjustTextMedium }} - + + + + - - - - {{ - $t("forecast.type.price") - }} - {{ - priceSubtitle - }} - + + + {{ $t("forecast.type.price") }} - + + + - - - {{ - $t("forecast.type.co2") - }} - {{ - co2Subtitle - }} - - + + {{ $t("forecast.type.co2") }} + + + + @@ -149,17 +143,18 @@ import { defineComponent } from "vue"; import Header from "../components/Top/Header.vue"; import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue"; import SolarChart from "../components/Forecast/SolarChart.vue"; +import SolarDetails from "../components/Forecast/SolarDetails.vue"; import PriceChart from "../components/Forecast/PriceChart.vue"; +import GridDetails from "../components/Forecast/GridDetails.vue"; import Co2Chart from "../components/Forecast/Co2Chart.vue"; -import formatter, { POWER_UNIT } from "@/mixins/formatter"; +import Co2Details from "../components/Forecast/Co2Details.vue"; +import formatter from "@/mixins/formatter"; import settings from "@/settings"; import store from "../store"; -import { adjustedSolar, ForecastType } from "@/utils/forecast"; -import type { ForecastSlot } from "../components/Forecast/types"; +import { adjustedSolar, ForecastType, isStaticTariff } from "@/utils/forecast"; const MIN_HOURS = 76; const MAX_HOURS = 96; -const SLOTS_PER_HOUR = 4; export default defineComponent({ name: "Forecast", @@ -167,8 +162,11 @@ export default defineComponent({ TopHeader: Header, DynamicPriceIcon, SolarChart, + SolarDetails, PriceChart, + GridDetails, Co2Chart, + Co2Details, }, mixins: [formatter], data() { @@ -233,6 +231,17 @@ export default defineComponent({ priceZoom() { return settings.priceZoom; }, + showFeedin() { + return !settings.hideFeedin; + }, + isGridStatic(): boolean { + if (!this.forecast.grid) return false; + if (!isStaticTariff(this.forecast.grid)) return false; + if (this.forecast.feedin?.length) { + return isStaticTariff(this.forecast.feedin); + } + return true; + }, showSolarAdjust() { return !!this.forecast.solar && this.experimental; }, @@ -241,48 +250,16 @@ export default defineComponent({ ? adjustedSolar(this.forecast.solar) : this.forecast.solar; }, - solarAdjustText() { - const text = this.$t("forecast.solarAdjustShort"); + solarAdjustPercent(): string { const scale = this.forecast.solar?.scale || 1; const percentDiff = scale * 100 - 100; - return `${text} (${this.fmtPercentage(percentDiff, 0, true)})`; + return this.fmtPercentage(percentDiff, 0, true); }, - solarSubtitle(): string { - const s = this.solar; - if (!s) return ""; - const today = s.today?.energy ? this.fmtWh(s.today.energy, POWER_UNIT.AUTO) : ""; - const tomorrow = s.tomorrow?.energy - ? this.fmtWh(s.tomorrow.energy, POWER_UNIT.AUTO) - : ""; - if (!today && !tomorrow) return ""; - const parts = []; - if (today) parts.push(`${today} ${this.$t("forecast.solar.remaining")}`); - if (tomorrow) parts.push(`${tomorrow} ${this.$t("forecast.solar.tomorrow")}`); - return parts.join(" ・ "); + solarAdjustTextShort(): string { + return `${this.$t("forecast.solarAdjustShort")} (${this.solarAdjustPercent})`; }, - priceSubtitle(): string { - const slots = this.upcomingSlots(this.forecast.grid); - if (slots.length === 0) return ""; - const values = slots.map((s) => s.value); - const min = Math.min(...values); - const max = Math.max(...values); - const avg = values.reduce((a, b) => a + b, 0) / values.length; - const fmtMin = this.fmtPricePerKWh(min, this.currency, false, false); - const fmtMax = this.fmtPricePerKWh(max, this.currency, false, true); - const fmtAvg = this.fmtPricePerKWh(avg, this.currency, false, true); - return `⌀ ${fmtAvg} ・ ${fmtMin} – ${fmtMax}`; - }, - co2Subtitle(): string { - const slots = this.upcomingSlots(this.forecast.co2); - if (slots.length === 0) return ""; - const values = slots.map((s) => s.value); - const min = Math.min(...values); - const max = Math.max(...values); - const avg = values.reduce((a, b) => a + b, 0) / values.length; - const fmtMin = this.fmtNumber(min, 0); - const fmtMax = this.fmtCo2Medium(max); - const fmtAvg = this.fmtCo2Medium(avg); - return `⌀ ${fmtAvg} ・ ${fmtMin} – ${fmtMax}`; + solarAdjustTextMedium(): string { + return `${this.$t("forecast.solarAdjustMedium")} (${this.solarAdjustPercent})`; }, }, methods: { @@ -292,6 +269,9 @@ export default defineComponent({ togglePriceZoom() { settings.priceZoom = !settings.priceZoom; }, + toggleFeedin() { + settings.hideFeedin = !settings.hideFeedin; + }, onChartScroll(val: number) { if (this.isScrolling) return; this.isScrolling = true; @@ -300,13 +280,6 @@ export default defineComponent({ this.isScrolling = false; }); }, - upcomingSlots(slots?: ForecastSlot[]): ForecastSlot[] { - if (!Array.isArray(slots)) return []; - const now = new Date(); - return slots - .filter((slot) => new Date(slot.end) > now) - .slice(0, MAX_HOURS * SLOTS_PER_HOUR); - }, }, }); @@ -321,4 +294,10 @@ export default defineComponent({ border-radius: 2rem; max-width: 480px; } +@media (max-width: 575.98px) { + .chart-edge { + margin-left: -1.5rem; + margin-right: -1.5rem; + } +} diff --git a/i18n/en.json b/i18n/en.json index e4e669582..deed0f3f9 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -796,8 +796,8 @@ "titleChoice": "What Do You Want To Add?", "type": { "co2": "CO₂ Intensity", - "feedIn": "Feed-In Price", - "grid": "Grid Consumption Price", + "feedIn": "Grid Export Price", + "grid": "Grid Import Price", "planner": "Planner", "solar": "Solar" }, @@ -972,6 +972,7 @@ "setup": "Set up tariffs and forecasts", "solar": "See expected solar production for today and the coming days. Will also be used for automatic charging optimization in the future." }, + "hideLine": "hide line", "modalTitle": "Forecast", "price": { "average": "Average", @@ -980,6 +981,7 @@ "range": "Range" }, "priceZoom": "zoom in", + "showLine": "show line", "solar": { "dayAfterTomorrow": "Day after tomorrow", "partly": "partly", @@ -988,7 +990,8 @@ "tomorrow": "Tomorrow" }, "solarAdjust": "Adjust solar forecast based on real production data{percent}.", - "solarAdjustShort": "real data adjust", + "solarAdjustMedium": "real data adjust", + "solarAdjustShort": "adjust", "type": { "co2": "CO₂ Emissions", "price": "Grid Price", @@ -1153,7 +1156,7 @@ "batteryTooltip": "{energy} of {total} ({soc})", "forecast": "Forecast: ", "forecastTooltip": "forecast: remaining solar production today", - "gridImport": "Grid use", + "gridImport": "Grid import", "homePower": "Consumption", "loadpoints": "Charger| Charger | {count} chargers", "loadpointsLimit": "{limit} limit", diff --git a/tests/config-grid.spec.ts b/tests/config-grid.spec.ts index 924cbcc73..6ce902306 100644 --- a/tests/config-grid.spec.ts +++ b/tests/config-grid.spec.ts @@ -62,7 +62,7 @@ test.describe("grid meter", async () => { // check in main ui await page.goto("/"); await page.getByTestId("visualization").click(); - await expect(page.getByTestId("energyflow")).toContainText(["Grid use", "5.0 kW"].join("")); + await expect(page.getByTestId("energyflow")).toContainText(["Grid import", "5.0 kW"].join("")); // delete #1 await page.goto("/#/config");