diff --git a/assets/css/app.css b/assets/css/app.css index 3d1f6e986..a139c00c9 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -47,6 +47,8 @@ --evcc-pv: var(--evcc-dark-green); --evcc-battery: var(--evcc-darker-green); --evcc-export: var(--evcc-yellow); + --evcc-price: #ff912fff; + --evcc-co2: #00916eff; --evcc-background: var(--bs-gray-bright); --evcc-box: var(--bs-white); --evcc-box-border: var(--bs-gray-brighter); @@ -165,6 +167,14 @@ h5 { color: var(--bs-primary) !important; } +.text-price { + color: var(--evcc-price) !important; +} + +.text-co2 { + color: var(--evcc-co2) !important; +} + a { color: var(--bs-primary); } diff --git a/assets/js/colors.js b/assets/js/colors.js index 2e536a66f..5bc721b51 100644 --- a/assets/js/colors.js +++ b/assets/js/colors.js @@ -15,9 +15,10 @@ const colors = reactive({ grid: null, co2PerKWh: null, pricePerKWh: null, - price: "#FF912FFF", - co2: "#00916EFF", + price: null, + co2: null, background: null, + light: null, selfPalette: ["#0FDE41FF", "#FFBD2FFF", "#FD6158FF", "#03C1EFFF", "#0F662DFF", "#FF922EFF"], palette: [ "#03C1EFFF", @@ -35,6 +36,18 @@ const colors = reactive({ ], }); +export const dimColor = (color) => { + return color.toLowerCase().replace(/ff$/, "20"); +}; + +export const lighterColor = (color) => { + return color.toLowerCase().replace(/ff$/, "aa"); +}; + +export const fullColor = (color) => { + return color.toLowerCase().replace(/20$/, "ff"); +}; + function updateCssColors() { const style = window.getComputedStyle(document.documentElement); colors.text = style.getPropertyValue("--evcc-default-text"); @@ -42,22 +55,19 @@ function updateCssColors() { colors.border = style.getPropertyValue("--bs-border-color-translucent"); colors.self = style.getPropertyValue("--evcc-self"); colors.grid = style.getPropertyValue("--evcc-grid"); + colors.price = style.getPropertyValue("--evcc-price"); + colors.co2 = style.getPropertyValue("--evcc-co2"); colors.background = style.getPropertyValue("--evcc-background"); colors.pricePerKWh = style.getPropertyValue("--bs-gray-medium"); colors.co2PerKWh = style.getPropertyValue("--bs-gray-medium"); + colors.light = style.getPropertyValue("--bs-gray-light"); } // update colors on theme change const darkModeMatcher = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)"); darkModeMatcher?.addEventListener("change", updateCssColors); +// initialize colors updateCssColors(); - -export const dimColor = (color) => { - return color.toLowerCase().replace(/ff$/, "20"); -}; - -export const fullColor = (color) => { - return color.toLowerCase().replace(/20$/, "ff"); -}; +window.requestAnimationFrame(updateCssColors); export default colors; diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue index 35316e4fb..c221f8b48 100644 --- a/assets/js/components/Energyflow/Energyflow.vue +++ b/assets/js/components/Energyflow/Energyflow.vue @@ -83,8 +83,15 @@ icon="sun" :power="pvProduction" :powerTooltip="pvTooltip" + :details="solarForecast" + :detailsFmt="forecastFmt" + :detailsTooltip="forecastTooltip(solarForecast)" + :detailsInactive="solarForecast === 0" + :detailsIcon="solarForecast !== undefined ? 'forecast' : undefined" + :detailsClickable="solarForecast !== undefined" :powerUnit="powerUnit" data-testid="energyflow-entry-production" + @details-clicked="openForecastModal" /> ({}) }, }, data: () => { return { detailsOpen: false, detailsCompleteHeight: null, ready: false }; @@ -381,6 +389,11 @@ export default { } return this.fmtPricePerKWh(this.batteryGridChargeLimit, this.currency, true); }, + solarForecast() { + const slots = this.forecast.solar || []; + if (slots.length === 0) return undefined; + return energyByDay(slots, 0); + }, }, watch: { pvConfigured() { @@ -418,6 +431,12 @@ export default { } return result; }, + forecastTooltip(value) { + if (value !== null) { + return [this.$t("main.energyflow.forecastTooltip")]; + } + return []; + }, detailsValue(price, co2) { if (this.co2Available) { return co2; @@ -430,6 +449,12 @@ export default { } return this.fmtPricePerKWh(value, this.currency, true); }, + forecastFmt(value) { + if (value === null) { + return ""; + } + return `${this.fmtWh(value, POWER_UNIT.KW)}`; + }, kw: function (watt) { return this.fmtW(watt, this.powerUnit); }, @@ -447,6 +472,10 @@ export default { ); modal.show(); }, + openForecastModal() { + const modal = Modal.getOrCreateInstance(document.getElementById("forecastModal")); + modal.show(); + }, dischargePower(power) { return Math.abs(Math.max(0, power)); }, diff --git a/assets/js/components/Energyflow/EnergyflowEntry.vue b/assets/js/components/Energyflow/EnergyflowEntry.vue index 5ec1410a1..040c1548f 100644 --- a/assets/js/components/Energyflow/EnergyflowEntry.vue +++ b/assets/js/components/Energyflow/EnergyflowEntry.vue @@ -13,12 +13,19 @@
+
@@ -41,10 +48,11 @@ import BatteryIcon from "./BatteryIcon.vue"; import formatter from "../../mixins/formatter"; import AnimatedNumber from "../AnimatedNumber.vue"; import VehicleIcon from "../VehicleIcon"; +import ForecastIcon from "../MaterialIcon/Forecast.vue"; export default { name: "EnergyflowEntry", - components: { BatteryIcon, AnimatedNumber, VehicleIcon }, + components: { BatteryIcon, AnimatedNumber, VehicleIcon, ForecastIcon }, mixins: [formatter], props: { name: { type: String }, @@ -54,9 +62,11 @@ export default { powerTooltip: { type: Array }, powerUnit: { type: String }, details: { type: Number }, + detailsIcon: { type: String }, detailsFmt: { type: Function }, detailsTooltip: { type: Array }, detailsClickable: { type: Boolean }, + detailsInactive: { type: Boolean }, }, emits: ["details-clicked"], data() { @@ -107,9 +117,6 @@ export default { ); }, updateDetailsTooltip() { - if (this.detailsClickable) { - return; - } this.detailsTooltipInstance = this.updateTooltip( this.detailsTooltipInstance, this.detailsTooltip, @@ -143,6 +150,8 @@ export default { if (this.detailsClickable) { this.$emit("details-clicked"); } + // hide tooltip, chrome needs a timeout + setTimeout(() => this.detailsTooltipInstance?.hide(), 10); }, }, }; diff --git a/assets/js/components/ForecastActiveSlot.vue b/assets/js/components/ForecastActiveSlot.vue new file mode 100644 index 000000000..f0c368ac1 --- /dev/null +++ b/assets/js/components/ForecastActiveSlot.vue @@ -0,0 +1,35 @@ + + + diff --git a/assets/js/components/ForecastChart.vue b/assets/js/components/ForecastChart.vue new file mode 100644 index 000000000..49bb507fa --- /dev/null +++ b/assets/js/components/ForecastChart.vue @@ -0,0 +1,417 @@ + + + + + diff --git a/assets/js/components/ForecastDetails.vue b/assets/js/components/ForecastDetails.vue new file mode 100644 index 000000000..bfe77e4da --- /dev/null +++ b/assets/js/components/ForecastDetails.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/assets/js/components/ForecastModal.vue b/assets/js/components/ForecastModal.vue new file mode 100644 index 000000000..8bca85ee2 --- /dev/null +++ b/assets/js/components/ForecastModal.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/assets/js/components/ForecastTypeSelect.vue b/assets/js/components/ForecastTypeSelect.vue new file mode 100644 index 000000000..19f506223 --- /dev/null +++ b/assets/js/components/ForecastTypeSelect.vue @@ -0,0 +1,57 @@ + + + diff --git a/assets/js/components/IconSelectItem.vue b/assets/js/components/IconSelectItem.vue index 0a95e12a7..6e9526e74 100644 --- a/assets/js/components/IconSelectItem.vue +++ b/assets/js/components/IconSelectItem.vue @@ -3,12 +3,16 @@ @@ -23,6 +27,7 @@ export default { active: Boolean, label: String, disabled: Boolean, + hideLabelOnMobile: Boolean, }, emits: ["click"], }; @@ -51,4 +56,10 @@ export default { width: auto; padding: 0 1rem; } +@media (max-width: 768px) { + .btn.hideLabelOnMobile { + width: 32px; + padding: 0; + } +} diff --git a/assets/js/components/MaterialIcon/Forecast.vue b/assets/js/components/MaterialIcon/Forecast.vue new file mode 100644 index 000000000..8870e8c97 --- /dev/null +++ b/assets/js/components/MaterialIcon/Forecast.vue @@ -0,0 +1,17 @@ + + + diff --git a/assets/js/components/Sessions/chartConfig.js b/assets/js/components/Sessions/chartConfig.js index a69cbc8ba..6bde1c0dc 100644 --- a/assets/js/components/Sessions/chartConfig.js +++ b/assets/js/components/Sessions/chartConfig.js @@ -40,6 +40,7 @@ export const commonOptions = { maintainAspectRatio: false, plugins: { legend: { display: false }, + datalabels: { display: false }, tooltip: { backgroundColor: "#000000cc", boxPadding: 5, diff --git a/assets/js/components/Site.vue b/assets/js/components/Site.vue index 160a25609..164365c84 100644 --- a/assets/js/components/Site.vue +++ b/assets/js/components/Site.vue @@ -131,6 +131,7 @@ export default { sponsor: { type: Object, default: () => ({}) }, smartCostType: String, fatal: Object, + forecast: Object, }, computed: { batteryConfigured: function () { diff --git a/assets/js/components/TopNavigation.vue b/assets/js/components/TopNavigation.vue index d17d33ecd..1678d205b 100644 --- a/assets/js/components/TopNavigation.vue +++ b/assets/js/components/TopNavigation.vue @@ -48,6 +48,16 @@ {{ $t("batterySettings.modalTitle") }} +
  • + +
  • import("./views/Energy.vue"), + props: true, + }, { path: "/log", component: () => import("./views/Log.vue"), diff --git a/assets/js/utils/forecast.test.ts b/assets/js/utils/forecast.test.ts new file mode 100644 index 000000000..4930712ab --- /dev/null +++ b/assets/js/utils/forecast.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test, vi } from "vitest"; +import { aggregateEnergy, type PriceSlot } from "./forecast"; + +const slots: PriceSlot[] = [ + { start: "2050-03-20T10:00:00+01:00", end: "2050-03-20T10:30:00+01:00", price: 2000 }, // 1kWh + { start: "2050-03-20T10:30:00+01:00", end: "2050-03-20T11:00:00+01:00", price: 4000 }, // 2kWh + { start: "2050-03-20T11:00:00+01:00", end: "2050-03-20T11:30:00+01:00", price: 3000 }, // 1.5kWh + { start: "2050-03-20T11:30:00+01:00", end: "2050-03-20T12:00:00+01:00", price: 2000 }, // 1kWh + { start: "2050-03-20T12:00:00+01:00", end: "2050-03-20T12:30:00+01:00", price: 0 }, // 0kWh + { start: "2050-03-20T12:30:00+01:00", end: "2050-03-20T13:00:00+01:00", price: 0 }, // 0kWh +]; + +describe("aggregateEnergy", () => { + test("aggregates energy from power values for 30min slots", () => { + const result = aggregateEnergy(slots); + expect(result).toBeCloseTo(5500); + }); + test("ignore slots in the past", () => { + // will ignore the first two slots + vi.setSystemTime("2050-03-20T11:00:00+01:00"); + const result = aggregateEnergy(slots, true); + expect(result).toBeCloseTo(2500); + }); + test("correctly split in-between slots", () => { + // start in the middle of the 4th slot + vi.setSystemTime("2050-03-20T11:45:00+01:00"); + const result = aggregateEnergy(slots, true); + expect(result).toBeCloseTo(500); + }); +}); diff --git a/assets/js/utils/forecast.ts b/assets/js/utils/forecast.ts new file mode 100644 index 000000000..2ded3dbb2 --- /dev/null +++ b/assets/js/utils/forecast.ts @@ -0,0 +1,70 @@ +export interface PriceSlot { + start: string; + end: string; + price: number; +} + +export enum ForecastType { + Solar = "solar", + Price = "price", + Co2 = "co2", +} + +export function aggregateEnergy(slots: PriceSlot[], ignorePast: boolean = false): number { + const now = new Date(); + return slots.reduce((acc: number, { start, end, price: power }: PriceSlot) => { + let startDate = new Date(start); + const endDate = new Date(end); + if (ignorePast) { + if (endDate < now) { + return acc; // ignore past slots + } + if (startDate < now) { + startDate = now; // count this slot from now on + } + } + const hours = (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60); // convert ms to hours + const energy = power * hours; // Wh + return acc + energy; + }, 0); +} + +// return the date in local YYYY-MM-DD format +function toDayString(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +// return only slots that are on a given date, ignores slots that are in the past +export function filterSlotsByDate(slots: PriceSlot[], dayString: string): PriceSlot[] { + const now = new Date(); + return slots.filter(({ start, end }) => { + const isPast = new Date(end) < now; + const dateMatches = toDayString(new Date(start)) === dayString; + return !isPast && dateMatches; + }); +} + +// return the energy for a given day (0 = today, 1 = tomorrow, etc.) +export function energyByDay(slots: PriceSlot[], day: number = 0): number { + const dayString = dayStringByOffset(day); + const daySlots = filterSlotsByDate(slots, dayString); + return aggregateEnergy(daySlots, true); +} + +export function dayStringByOffset(day: number): string { + const date = new Date(); + date.setDate(date.getDate() + day); + return toDayString(date); +} + +// return the highest slot for a given day (0 = today, 1 = tomorrow, etc.) +export function highestSlotIndexByDay(slots: PriceSlot[], day: number = 0): number { + const dayString = dayStringByOffset(day); + const daySlots = filterSlotsByDate(slots, dayString); + const sortedSlots = daySlots.sort((a, b) => b.price - a.price); + const highestSlot = sortedSlots[0] || {}; + return slots.findIndex((slot) => slot.start === highestSlot.start); +} diff --git a/assets/js/views/App.vue b/assets/js/views/App.vue index 2bed7068a..a9656f667 100644 --- a/assets/js/views/App.vue +++ b/assets/js/views/App.vue @@ -4,6 +4,7 @@ + @@ -15,6 +16,7 @@ import store from "../store"; import GlobalSettingsModal from "../components/GlobalSettingsModal.vue"; import BatterySettingsModal from "../components/BatterySettingsModal.vue"; +import ForecastModal from "../components/ForecastModal.vue"; import OfflineIndicator from "../components/OfflineIndicator.vue"; import PasswordModal from "../components/PasswordModal.vue"; import LoginModal from "../components/LoginModal.vue"; @@ -37,6 +39,7 @@ export default { GlobalSettingsModal, HelpModal, BatterySettingsModal, + ForecastModal, PasswordModal, LoginModal, OfflineIndicator, @@ -69,6 +72,9 @@ export default { offlineIndicatorProps() { return this.collectProps(OfflineIndicator, store.state); }, + forecastModalProps() { + return this.collectProps(ForecastModal, store.state); + }, }, watch: { version: function (now, prev) { diff --git a/assets/js/views/Energy.vue b/assets/js/views/Energy.vue new file mode 100644 index 000000000..74e99a156 --- /dev/null +++ b/assets/js/views/Energy.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/i18n/de.toml b/i18n/de.toml index 85355e2c2..4c57833cd 100644 --- a/i18n/de.toml +++ b/i18n/de.toml @@ -434,6 +434,31 @@ modalUpdateNow = "Jetzt installieren" modalUpdateStarted = "Starte die neue Version von evcc ..." modalUpdateStatusStart = "Installation gestartet:" +[forecast] +modalTitle = "Vorhersage" + +[forecast.co2] +average = "Durchschnitt" +lowestHour = "Sauberste Stunde" +range = "Bereich" + +[forecast.price] +average = "Durchschnitt" +lowestHour = "Günstigste Stunde" +range = "Bereich" + +[forecast.solar] +dayAfterTomorrow = "Übermorgen" +partly = "teilweise" +remaining = "verbleibend" +today = "Heute" +tomorrow = "Morgen" + +[forecast.type] +co2 = "CO₂" +price = "Preis" +solar = "Solar" + [header] about = "Über" blog = "Blog" @@ -524,6 +549,7 @@ batteryGridChargeActive = "Netzladen aktiv" batteryGridChargeLimit = "Netzladen wenn" batteryHold = "Batterie (gesperrt)" batteryTooltip = "{energy} von {total} ({soc})" +forecastTooltip = "Vorhersage: erwartete Sonnenenergie heute" gridImport = "Netzbezug" homePower = "Verbrauch" loadpoints = "Ladepunkt | Ladepunkt | {count} Ladepunkte" diff --git a/i18n/en.toml b/i18n/en.toml index c25e32799..052f24b37 100644 --- a/i18n/en.toml +++ b/i18n/en.toml @@ -433,6 +433,31 @@ modalUpdateNow = "Install now" modalUpdateStarted = "Starting the new version of evcc…" modalUpdateStatusStart = "Installation started:" +[forecast] +modalTitle = "Forecast" + +[forecast.co2] +average = "Average" +lowestHour = "Cleanest hour" +range = "Range" + +[forecast.price] +average = "Average" +lowestHour = "Cheapest hour" +range = "Range" + +[forecast.solar] +dayAfterTomorrow = "Day after tomorrow" +partly = "partly" +remaining = "remaining" +today = "Today" +tomorrow = "Tomorrow" + +[forecast.type] +co2 = "CO₂" +price = "Price" +solar = "Solar" + [header] about = "About" blog = "Blog" @@ -523,6 +548,7 @@ batteryGridChargeActive = "grid charging active" batteryGridChargeLimit = "grid charging when" batteryHold = "Battery (locked)" batteryTooltip = "{energy} of {total} ({soc})" +forecastTooltip = "forecast: remaining solar production today" gridImport = "Grid use" homePower = "Consumption" loadpoints = "Charger| Charger | {count} chargers" diff --git a/package-lock.json b/package-lock.json index 6bd4fadab..5f6b4eb05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,10 @@ "bootstrap": "^5.2.2", "canvas-confetti": "^1.4.0", "chart.js": "^4.4.4", + "chartjs-adapter-dayjs-4": "^1.0.4", + "chartjs-plugin-datalabels": "^2.2.0", "countup.js": "^2.3.2", + "dayjs": "^1.11.13", "eslint": "^9.13.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-import": "^2.25.3", @@ -4371,6 +4374,28 @@ "pnpm": ">=8" } }, + "node_modules/chartjs-adapter-dayjs-4": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chartjs-adapter-dayjs-4/-/chartjs-adapter-dayjs-4-1.0.4.tgz", + "integrity": "sha512-yy9BAYW4aNzPVrCWZetbILegTRb7HokhgospPoC3b5iZ5qdlqNmXts2KdSp6AqnjkPAp/YWyHDxLvIvwt5x81w==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "chart.js": ">=4.0.1", + "dayjs": "^1.9.7" + } + }, + "node_modules/chartjs-plugin-datalabels": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz", + "integrity": "sha512-14ZU30lH7n89oq+A4bWaJPnAG8a7ZTk7dKf48YAzMvJjQtjrgg5Dpk9f+LbjCF6bpx3RAGTeL13IXpKQYyRvlw==", + "license": "MIT", + "peerDependencies": { + "chart.js": ">=3.0.0" + } + }, "node_modules/check-error": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", @@ -4718,6 +4743,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", diff --git a/package.json b/package.json index c2ddf0d62..f3a325ab7 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,10 @@ "bootstrap": "^5.2.2", "canvas-confetti": "^1.4.0", "chart.js": "^4.4.4", + "chartjs-adapter-dayjs-4": "^1.0.4", + "chartjs-plugin-datalabels": "^2.2.0", "countup.js": "^2.3.2", + "dayjs": "^1.11.13", "eslint": "^9.13.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-import": "^2.25.3",