diff --git a/AGENTS.md b/AGENTS.md index 8fa24ebe8..75476d4a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,7 +198,9 @@ This file provides guidance to AI coding agents when working with code in this r - **Examples**: - `page.getByRole("button", { name: "Add charger" })` - `page.getByLabel("Manufacturer").selectOption("Demo charger")` + - `page.getByRole("listitem", { name: "Draggable: First Loadpoint" })` (using aria-label) - `page.getByTestId("loadpoint")` (fallback only) +- never use `.locator()` or `class` and `id`-based selectors ### Test Patterns diff --git a/assets/css/app.css b/assets/css/app.css index a1874a44a..fb45ac972 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -296,6 +296,11 @@ a:hover { outline-width: var(--bs-focus-ring-width); } +.btn-link:disabled { + border-color: transparent !important; + opacity: 0.2; +} + .dark .btn-outline-secondary { --bs-btn-color: var(--bs-gray-bright); --bs-btn-border-color: var(--bs-gray-bright); diff --git a/assets/js/components/Config/GeneralConfig.vue b/assets/js/components/Config/GeneralConfig.vue index 00120e20e..f8863e343 100644 --- a/assets/js/components/Config/GeneralConfig.vue +++ b/assets/js/components/Config/GeneralConfig.vue @@ -19,7 +19,7 @@ test-id="generalconfig-telemetry" :label="$t('config.general.telemetry')" :text="$t(`config.general.${telemetryEnabled ? 'on' : 'off'}`)" - modal-id="globalSettingsModal" + modal-id="telemetryModal" /> + +

{{ $t("config.telemetry.description") }}

+ +
+ + + diff --git a/assets/js/components/Energyflow/Energyflow.stories.ts b/assets/js/components/Energyflow/Energyflow.stories.ts index 926327856..7ec46062d 100644 --- a/assets/js/components/Energyflow/Energyflow.stories.ts +++ b/assets/js/components/Energyflow/Energyflow.stories.ts @@ -7,7 +7,7 @@ export default { component: Energyflow, } as Meta; -const Template: StoryFn = (args) => ({ +const Template: StoryFn = (args: any) => ({ components: { Energyflow }, setup() { return { args }; @@ -22,39 +22,33 @@ GridAndPV.args = { pvPower: 7300, gridPower: -2300, homePower: 800, - loadpointsCompact: [ + loadpoints: [ { - power: 1000, icon: "car", charging: true, title: "Garage", chargePower: 1000, connected: true, - index: 0, vehicleName: "", vehicleSoc: 50, chargerFeatureHeating: false, }, { - power: 1000, icon: "bike", charging: true, title: "Garage", chargePower: 1000, connected: true, - index: 1, vehicleName: "", vehicleSoc: 50, chargerFeatureHeating: false, }, { - power: 2200, icon: "car", charging: true, title: "Garage", chargePower: 2200, connected: true, - index: 2, vehicleName: "", vehicleSoc: 50, chargerFeatureHeating: false, @@ -72,7 +66,7 @@ GridAndPV.args = { dayAfterTomorrow: { energy: 1000, complete: false }, }, }, -}; +} as any; export const BatteryAndGrid = Template.bind({}); BatteryAndGrid.args = { @@ -91,7 +85,7 @@ BatteryAndGrid.args = { { soc: 44.999, capacity: 13.3, power: 350, controllable: true }, { soc: 82.3331, capacity: 21, power: 450, controllable: false }, ], -}; +} as any; export const BatteryCharging = Template.bind({}); BatteryCharging.args = { @@ -101,15 +95,13 @@ BatteryCharging.args = { pvPower: 5000, gridPower: -1300, homePower: 800, - loadpointsCompact: [ + loadpoints: [ { - power: 1400, icon: "car", charging: true, title: "Garage", chargePower: 1400, connected: true, - index: 0, vehicleName: "", vehicleSoc: 50, chargerFeatureHeating: false, @@ -117,7 +109,7 @@ BatteryCharging.args = { ], batteryPower: -1500, batterySoc: 75, -}; +} as any; export const GridPVAndBattery = Template.bind({}); GridPVAndBattery.args = { @@ -129,7 +121,7 @@ GridPVAndBattery.args = { homePower: 3300, batteryPower: 1500, batterySoc: 30, -}; +} as any; export const BatteryThresholds = Template.bind({}); BatteryThresholds.args = { @@ -138,27 +130,23 @@ BatteryThresholds.args = { batteryConfigured: true, pvPower: 8700, gridPower: -500, - loadpointsCompact: [ + loadpoints: [ { - power: 5000, icon: "car", charging: true, title: "Garage", chargePower: 5000, connected: true, - index: 0, vehicleName: "", vehicleSoc: 50, chargerFeatureHeating: false, }, { - power: 2500, icon: "bus", charging: true, title: "Garage", chargePower: 2500, connected: true, - index: 1, vehicleName: "", vehicleSoc: 50, chargerFeatureHeating: false, @@ -166,7 +154,7 @@ BatteryThresholds.args = { ], batteryPower: -700, batterySoc: 95, -}; +} as any; export const PVThresholds = Template.bind({}); PVThresholds.args = { @@ -176,27 +164,23 @@ PVThresholds.args = { pvPower: 300, gridPower: 6500, homePower: 1000, - loadpointsCompact: [ + loadpoints: [ { - power: 5000, icon: "car", charging: true, title: "Garage", chargePower: 5000, connected: true, - index: 0, vehicleName: "", vehicleSoc: 50, chargerFeatureHeating: false, }, { - power: 1600, icon: "car", charging: true, title: "Garage", chargePower: 1600, connected: true, - index: 1, vehicleName: "", vehicleSoc: 50, chargerFeatureHeating: false, @@ -204,7 +188,7 @@ PVThresholds.args = { ], batteryPower: 800, batterySoc: 76, -}; +} as any; export const GridOnly = Template.bind({}); GridOnly.args = { @@ -214,51 +198,43 @@ GridOnly.args = { pvPower: 0, gridPower: 6500, homePower: 1000, - loadpointsCompact: [ + loadpoints: [ { - power: 5500, icon: "car", charging: true, title: "Garage", chargePower: 5500, connected: true, - index: 0, vehicleName: "", vehicleSoc: 50, chargerFeatureHeating: false, }, { - power: 0, icon: "car", charging: false, title: "Garage", chargePower: 0, connected: false, - index: 1, vehicleName: "", vehicleSoc: 50, chargerFeatureHeating: false, }, { - power: 0, icon: "car", charging: false, title: "Garage", chargePower: 0, connected: false, - index: 2, vehicleName: "", vehicleSoc: 50, chargerFeatureHeating: false, }, { - power: 0, icon: "car", charging: false, title: "Garage", chargePower: 0, connected: false, - index: 3, vehicleName: "", vehicleSoc: 50, chargerFeatureHeating: false, @@ -266,7 +242,7 @@ GridOnly.args = { ], batteryPower: 0, batterySoc: 0, -}; +} as any; export const LowPower = Template.bind({}); LowPower.args = { @@ -281,7 +257,7 @@ LowPower.args = { tariffGrid: 0.25, tariffFeedIn: 0.08, currency: CURRENCY.EUR, -}; +} as any; export const CO2 = Template.bind({}); CO2.args = { @@ -290,39 +266,33 @@ CO2.args = { pvPower: 7300, gridPower: -2300, homePower: 800, - loadpointsCompact: [ + loadpoints: [ { - power: 1000, icon: "car", charging: true, title: "Garage", chargePower: 1000, connected: true, - index: 0, vehicleName: "", vehicleSoc: 50, chargerFeatureHeating: false, }, { - power: 1000, icon: "car", charging: true, title: "Garage", chargePower: 1000, connected: true, - index: 1, vehicleName: "", vehicleSoc: 50, chargerFeatureHeating: false, }, { - power: 2200, icon: "car", charging: true, title: "Garage", chargePower: 2200, connected: true, - index: 2, vehicleName: "", vehicleSoc: 50, chargerFeatureHeating: false, @@ -334,7 +304,7 @@ CO2.args = { smartCostType: "co2", currency: CURRENCY.EUR, pv: [{ power: 5000 }, { power: 2300 }], -}; +} as any; export const UnknownInput = Template.bind({}); UnknownInput.args = { @@ -342,21 +312,19 @@ UnknownInput.args = { pvConfigured: true, pvPower: 2000, gridPower: -2000, - loadpointsCompact: [ + loadpoints: [ { - power: 1000, icon: "car", charging: true, title: "Garage", chargePower: 1000, connected: true, - index: 0, vehicleName: "", vehicleSoc: 50, chargerFeatureHeating: false, }, ], -}; +} as any; export const UnknownInputFill = Template.bind({}); UnknownInputFill.args = { @@ -366,8 +334,8 @@ UnknownInputFill.args = { pvPower: 500, gridPower: 0, batteryPower: -1000, - loadpointsCompact: [], -}; + loadpoints: [], +} as any; export const UnknownOutput = Template.bind({}); UnknownOutput.args = { @@ -375,21 +343,19 @@ UnknownOutput.args = { pvConfigured: true, pvPower: 3000, gridPower: -1000, - loadpointsCompact: [ + loadpoints: [ { - power: 1700, + chargePower: 1700, icon: "car", charging: true, title: "Garage", - chargePower: 1700, connected: true, - index: 0, vehicleName: "", vehicleSoc: 50, chargerFeatureHeating: false, }, ], -}; +} as any; export const UnknownOutputLessThan10Percent = Template.bind({}); UnknownOutputLessThan10Percent.args = { @@ -397,18 +363,16 @@ UnknownOutputLessThan10Percent.args = { pvConfigured: true, pvPower: 3000, gridPower: -1000, - loadpointsCompact: [ + loadpoints: [ { - power: 1800, + chargePower: 1800, icon: "car", charging: true, title: "Garage", - chargePower: 1800, connected: true, - index: 0, vehicleName: "", vehicleSoc: 50, chargerFeatureHeating: false, }, ], -}; +} as any; diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue index 8293fba73..b32228891 100644 --- a/assets/js/components/Energyflow/Energyflow.vue +++ b/assets/js/components/Energyflow/Energyflow.vue @@ -11,7 +11,7 @@ :gridImport="gridImport" :selfPv="selfPv" :selfBattery="selfBattery" - :loadpoints="loadpointsCompact" + :loadpoints="loadpoints" :pvExport="pvExport" :batteryCharge="batteryCharge" :batteryDischarge="batteryDischarge" @@ -194,14 +194,18 @@ @@ -289,7 +293,7 @@ import { type Battery, type CURRENCY, type Forecast, - type LoadpointCompact, + type UiLoadpoint, } from "@/types/evcc"; export default defineComponent({ @@ -307,7 +311,7 @@ export default defineComponent({ pvConfigured: Boolean, pv: { type: Array as PropType }, pvPower: { type: Number, default: 0 }, - loadpointsCompact: { type: Array as PropType, default: () => [] }, + loadpoints: { type: Array as PropType, default: () => [] }, batteryConfigured: { type: Boolean }, battery: { type: Array as PropType }, batteryPower: { type: Number, default: 0 }, @@ -365,7 +369,7 @@ export default defineComponent({ return Math.min(this.batteryDischarge, this.consumption - this.selfPv); }, activeLoadpoints() { - return this.loadpointsCompact.filter((lp) => lp.charging); + return this.loadpoints.filter((lp) => lp.charging); }, activeLoadpointsCount() { return this.activeLoadpoints.length; @@ -377,8 +381,8 @@ export default defineComponent({ return ["car"]; }, loadpointsPower() { - return this.loadpointsCompact.reduce((sum, lp) => { - return sum + (lp.power || 0); + return this.loadpoints.reduce((sum, lp) => { + return sum + (lp.chargePower || 0); }, 0); }, pvExport() { diff --git a/assets/js/components/Energyflow/Visualization.vue b/assets/js/components/Energyflow/Visualization.vue index 9f4fb4f89..74f05a46b 100644 --- a/assets/js/components/Energyflow/Visualization.vue +++ b/assets/js/components/Energyflow/Visualization.vue @@ -86,7 +86,7 @@ @@ -116,7 +116,7 @@ import QuestionIcon from "../MaterialIcon/Question.vue"; import "@h2d2/shopicons/es/regular/sun"; import "@h2d2/shopicons/es/regular/home"; import { defineComponent, type PropType } from "vue"; -import type { LoadpointCompact } from "@/types/evcc"; +import type { UiLoadpoint } from "@/types/evcc"; export default defineComponent({ name: "Visualization", @@ -127,7 +127,7 @@ export default defineComponent({ selfPv: { type: Number, default: 0 }, selfBattery: { type: Number, default: 0 }, pvExport: { type: Number, default: 0 }, - loadpoints: { type: Array as PropType, default: () => [] }, + loadpoints: { type: Array as PropType, default: () => [] }, batteryCharge: { type: Number, default: 0 }, batteryDischarge: { type: Number, default: 0 }, batteryHold: { type: Boolean, default: false }, diff --git a/assets/js/components/GlobalSettings/GlobalSettingsModal.vue b/assets/js/components/GlobalSettings/GlobalSettingsModal.vue index 03a2c5273..c8e7d70c6 100644 --- a/assets/js/components/GlobalSettings/GlobalSettingsModal.vue +++ b/assets/js/components/GlobalSettings/GlobalSettingsModal.vue @@ -4,7 +4,7 @@ :title="$t('settings.title')" data-testid="global-settings-modal" > - + @@ -12,14 +12,13 @@ import { defineComponent, type PropType } from "vue"; import GenericModal from "../Helper/GenericModal.vue"; import UserInterfaceSettings from "./UserInterfaceSettings.vue"; -import type { Sponsor } from "@/types/evcc"; +import type { UiLoadpoint } from "@/types/evcc"; export default defineComponent({ name: "GlobalSettingsModal", components: { GenericModal, UserInterfaceSettings }, props: { - sponsor: { type: Object as PropType, default: () => ({}) }, - telemetry: Boolean, + uiLoadpoints: { type: Array as PropType, default: () => [] }, }, }); diff --git a/assets/js/components/GlobalSettings/LoadpointOrderSettings.vue b/assets/js/components/GlobalSettings/LoadpointOrderSettings.vue new file mode 100644 index 000000000..89da8456d --- /dev/null +++ b/assets/js/components/GlobalSettings/LoadpointOrderSettings.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/assets/js/components/GlobalSettings/UserInterfaceSettings.vue b/assets/js/components/GlobalSettings/UserInterfaceSettings.vue index 098768e77..f04a021f5 100644 --- a/assets/js/components/GlobalSettings/UserInterfaceSettings.vue +++ b/assets/js/components/GlobalSettings/UserInterfaceSettings.vue @@ -59,12 +59,20 @@ equal-width /> - - + + + + + +
@@ -82,25 +90,16 @@
- - - - +
+ {{ $t("settings.deviceInfo") }} +
+ + diff --git a/assets/js/components/Helper/DragDropList.vue b/assets/js/components/Helper/DragDropList.vue new file mode 100644 index 000000000..4240d86a3 --- /dev/null +++ b/assets/js/components/Helper/DragDropList.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/assets/js/components/Loadpoints/Loadpoint.stories.ts b/assets/js/components/Loadpoints/Loadpoint.stories.ts index acfff028a..f953f1281 100644 --- a/assets/js/components/Loadpoints/Loadpoint.stories.ts +++ b/assets/js/components/Loadpoints/Loadpoint.stories.ts @@ -1,6 +1,12 @@ import Loadpoint from "./Loadpoint.vue"; import type { Meta, StoryFn } from "@storybook/vue3"; -import { SMART_COST_TYPE } from "@/types/evcc"; +import { + SMART_COST_TYPE, + CHARGE_MODE, + CHARGER_STATUS_REASON, + PHASE_ACTION, + PV_ACTION, +} from "@/types/evcc"; export default { title: "Loadpoints/Loadpoint", @@ -12,9 +18,9 @@ export default { // Based on actual API state structure from demo.evcc.io const baseState = { - id: 1, + id: "1", title: "Carport", - mode: "pv", + mode: CHARGE_MODE.PV, enabled: true, connected: true, socBasedCharging: true, @@ -50,9 +56,9 @@ const baseState = { planProjectedStart: undefined, planTime: undefined, priority: 0, - phaseAction: "inactive", + phaseAction: PHASE_ACTION.INACTIVE, phaseRemaining: 0, - pvAction: "inactive", + pvAction: PV_ACTION.INACTIVE, pvRemaining: 0, smartCostActive: false, smartCostNextStart: undefined, @@ -65,7 +71,7 @@ const baseState = { chargerFeatureIntegratedDevice: false, chargerIcon: "", chargerSinglePhase: false, - chargerStatusReason: "unknown", + chargerStatusReason: CHARGER_STATUS_REASON.UNKNOWN, connectedDuration: 0, // Global props that would typically come from parent vehicles: [ @@ -125,7 +131,7 @@ Idle.args = { connected: false, vehicleName: "", vehicles: [], - mode: "off", + mode: CHARGE_MODE.OFF, charging: false, chargePower: 0, offeredCurrent: 0, @@ -139,7 +145,7 @@ DisabledLongTitle.args = { title: "Charging point with a very very very long title!!!1!", remoteDisabled: "soft", remoteDisabledSource: "Sunny Home Manager", - mode: "now", + mode: CHARGE_MODE.NOW, enabled: false, charging: false, chargePower: 0, @@ -150,7 +156,7 @@ ChargerIconNoVehicle.args = { ...baseState, chargerIcon: "heater", title: "Heating device with long name", - mode: "now", + mode: CHARGE_MODE.NOW, chargerFeatureIntegratedDevice: true, vehicleName: "", vehicles: [], diff --git a/assets/js/components/Loadpoints/Loadpoint.vue b/assets/js/components/Loadpoints/Loadpoint.vue index 413aaf9ae..d9b44a97c 100644 --- a/assets/js/components/Loadpoints/Loadpoint.vue +++ b/assets/js/components/Loadpoints/Loadpoint.vue @@ -110,6 +110,9 @@ import { defineComponent, type PropType } from "vue"; import type { CHARGE_MODE, PHASES, + PHASE_ACTION, + PV_ACTION, + CHARGER_STATUS_REASON, Timeout, Vehicle, Forecast, @@ -130,12 +133,12 @@ export default defineComponent({ }, mixins: [formatter, collector], props: { - id: Number, + id: String, single: Boolean, // main title: String, - mode: String, + mode: String as PropType, effectiveLimitSoc: Number, limitEnergy: Number, remoteDisabled: String, @@ -147,16 +150,16 @@ export default defineComponent({ // session sessionEnergy: Number, - sessionCo2PerKWh: Number, - sessionPricePerKWh: Number, - sessionPrice: Number, + sessionCo2PerKWh: Number as PropType, + sessionPricePerKWh: Number as PropType, + sessionPrice: Number as PropType, sessionSolarPercentage: Number, // charger - chargerStatusReason: String, + chargerStatusReason: String as PropType, chargerFeatureIntegratedDevice: Boolean, chargerFeatureHeating: Boolean, - chargerIcon: String, + chargerIcon: String as PropType, // vehicle connected: Boolean, @@ -170,20 +173,20 @@ export default defineComponent({ vehicleLimitSoc: Number, vehicles: Array as PropType, planActive: Boolean, - planProjectedStart: String, - planProjectedEnd: String, + planProjectedStart: String as PropType, + planProjectedEnd: String as PropType, planOverrun: { type: Number, default: 0 }, planEnergy: Number, planPrecondition: Number, - planTime: String, - effectivePlanTime: String, + planTime: String as PropType, + effectivePlanTime: String as PropType, effectivePlanSoc: Number, vehicleProviderLoggedIn: Boolean, vehicleProviderLoginPath: String, vehicleProviderLogoutPath: String, // details - vehicleClimaterActive: Boolean, + vehicleClimaterActive: Boolean as PropType, vehicleWelcomeActive: Boolean, chargePower: { type: Number, default: 0 }, chargedEnergy: { type: Number, default: 0 }, @@ -200,19 +203,19 @@ export default defineComponent({ connectedDuration: Number, chargeCurrents: Array, chargeRemainingEnergy: Number, - phaseAction: String, + phaseAction: String as PropType, phaseRemaining: { type: Number, default: 0 }, pvRemaining: { type: Number, default: 0 }, - pvAction: String, - smartCostLimit: { type: Number, default: null }, + pvAction: String as PropType, + smartCostLimit: { type: Number as PropType, default: null }, smartCostType: String as PropType, smartCostAvailable: Boolean, smartCostActive: Boolean, - smartCostNextStart: String, - smartFeedInPriorityLimit: { type: Number, default: null }, + smartCostNextStart: String as PropType, + smartFeedInPriorityLimit: { type: Number as PropType, default: null }, smartFeedInPriorityAvailable: Boolean, smartFeedInPriorityActive: Boolean, - smartFeedInPriorityNextStart: String, + smartFeedInPriorityNextStart: String as PropType, tariffGrid: Number, tariffFeedIn: Number, tariffCo2: Number, diff --git a/assets/js/components/Loadpoints/Loadpoints.stories.ts b/assets/js/components/Loadpoints/Loadpoints.stories.ts index 3359f6d78..d303d680b 100644 --- a/assets/js/components/Loadpoints/Loadpoints.stories.ts +++ b/assets/js/components/Loadpoints/Loadpoints.stories.ts @@ -3,7 +3,7 @@ import type { Meta, StoryFn } from "@storybook/vue3"; import { SMART_COST_TYPE } from "@/types/evcc"; // Create LoadpointCompact structure for the Loadpoints component -const createLoadpoint = (opts = {}) => { +const createLoadpoint = (opts: any = {}) => { const base = { icon: "car", title: "Carport", @@ -18,7 +18,9 @@ const createLoadpoint = (opts = {}) => { vehicleSoc: 0, chargerFeatureHeating: false, }; - return { ...base, ...opts }; + const result = { ...base, ...opts }; + (result as any).id = result.index + 1; + return result; }; export default { diff --git a/assets/js/components/Loadpoints/Loadpoints.vue b/assets/js/components/Loadpoints/Loadpoints.vue index 059f69f46..8009e6ec1 100644 --- a/assets/js/components/Loadpoints/Loadpoints.vue +++ b/assets/js/components/Loadpoints/Loadpoints.vue @@ -8,13 +8,12 @@ :class="`carousel--${loadpoints.length}`" >
@@ -90,7 +90,6 @@ import type { Battery, CURRENCY, Forecast, - LoadpointCompact, Notification, Circuit, SMART_COST_TYPE, @@ -98,6 +97,7 @@ import type { FatalError, EvOpt, } from "@/types/evcc"; +import store from "@/store"; import type { Grid } from "./types"; export default defineComponent({ @@ -113,8 +113,7 @@ export default defineComponent({ }, mixins: [formatter, collector], props: { - loadpoints: { type: Array as PropType, default: () => [] }, - selectedLoadpointIndex: Number, + selectedLoadpointId: String, notifications: { type: Array as PropType, default: () => [] }, offline: Boolean, @@ -165,6 +164,12 @@ export default defineComponent({ evopt: { type: Object as PropType }, }, computed: { + loadpoints() { + return store.uiLoadpoints.value; + }, + orderedVisibleLoadpoints() { + return this.loadpoints.filter((lp) => lp.visible); + }, batteryConfigured() { return this.battery?.length > 0; }, @@ -178,22 +183,7 @@ export default defineComponent({ return this.collectProps(Energyflow); }, loadpointTitles() { - return this.loadpoints.map((lp) => lp.title); - }, - loadpointsCompact() { - return this.loadpoints.map((lp, index) => { - const vehicleIcon = this.vehicles?.[lp.vehicleName]?.icon; - const icon = lp.chargerIcon || vehicleIcon || "car"; - const title = - this.vehicleTitle(lp.vehicleName) || - lp.title || - this.$t("main.loadpoint.fallbackName"); - const charging = lp.charging; - const soc = lp.vehicleSoc; - const power = lp.chargePower || 0; - const heating = lp.chargerFeatureHeating; - return { icon, title, charging, power, soc, heating, index }; - }); + return this.orderedVisibleLoadpoints.map((lp) => lp.displayTitle); }, vehicleList() { const vehicles = this.vehicles || {}; @@ -235,11 +225,8 @@ export default defineComponent({ }, }, methods: { - selectedLoadpointChanged(index: number) { - this.$router.push({ query: { lp: index + 1 } }); - }, - vehicleTitle(vehicleName: string) { - return this.vehicles?.[vehicleName]?.title; + selectedLoadpointChanged(id: string | undefined) { + this.$router.push({ query: { lp: id } }); }, }, }); diff --git a/assets/js/router.ts b/assets/js/router.ts index c40925a1b..08ce832ab 100644 --- a/assets/js/router.ts +++ b/assets/js/router.ts @@ -48,7 +48,7 @@ export default function setupRouter(i18n: VueI18nInstance) { props: (route) => { const { lp } = route.query; return { - selectedLoadpointIndex: lp ? parseInt(lp as string, 10) - 1 : undefined, + selectedLoadpointId: lp as string | undefined, }; }, }, @@ -106,8 +106,11 @@ export default function setupRouter(i18n: VueI18nInstance) { await ensureCurrentLocaleMessages(i18n); return true; }); - router.afterEach(() => { - hideAllModals(); + router.afterEach((to, from) => { + // Only hide modals when the actual route path changes, not query parameters + if (to.path !== from.path) { + hideAllModals(); + } }); return router; } diff --git a/assets/js/settings.ts b/assets/js/settings.ts index 22b30eb82..4a3864ef3 100644 --- a/assets/js/settings.ts +++ b/assets/js/settings.ts @@ -11,13 +11,14 @@ const SETTINGS_ENERGYFLOW_DETAILS = "settings_energyflow_details"; const SETTINGS_ENERGYFLOW_PV = "settings_energyflow_pv"; const SETTINGS_ENERGYFLOW_BATTERY = "settings_energyflow_battery"; const SETTINGS_ENERGYFLOW_LOADPOINTS = "settings_energyflow_loadpoints"; -const SESSION_INFO = "session_info"; +const LOADPOINTS = "loadpoints"; const SESSION_COLUMNS = "session_columns"; const SAVINGS_PERIOD = "savings_period"; const SAVINGS_REGION = "savings_region"; const SESSIONS_GROUP = "sessions_group"; const SESSIONS_TYPE = "sessions_type"; const SETTINGS_SOLAR_ADJUSTED = "settings_solar_adjusted"; + function read(key: string) { return window.localStorage[key]; } @@ -57,6 +58,32 @@ function saveArray(key: string) { }; } +function readJSON(key: string) { + const value = read(key); + try { + return value ? JSON.parse(value) : {}; + } catch (e) { + console.error("unable to parse JSON from localStorage", { key, value, e }); + return {}; + } +} + +function saveJSON(key: string) { + return (value: object) => { + try { + save(key)(JSON.stringify(value)); + } catch (e) { + console.error("unable to stringify JSON for localStorage", { key, value, e }); + } + }; +} + +export interface LoadpointSettings { + order?: number; + visible?: boolean; + info?: SessionInfoKey; +} + export interface Settings { locale: keyof typeof LOCALES | null; theme: THEME | null; @@ -67,13 +94,13 @@ export interface Settings { energyflowPv: boolean; energyflowBattery: boolean; energyflowLoadpoints: boolean; - sessionInfo: SessionInfoKey[]; sessionColumns: string[]; savingsPeriod: string; savingsRegion: string; sessionsGroup: string; sessionsType: string; solarAdjusted: boolean; + loadpoints: Record; } const settings: Settings = reactive({ @@ -86,13 +113,13 @@ const settings: Settings = reactive({ energyflowPv: readBool(SETTINGS_ENERGYFLOW_PV), energyflowBattery: readBool(SETTINGS_ENERGYFLOW_BATTERY), energyflowLoadpoints: readBool(SETTINGS_ENERGYFLOW_LOADPOINTS), - sessionInfo: readArray(SESSION_INFO) as SessionInfoKey[], sessionColumns: readArray(SESSION_COLUMNS), savingsPeriod: read(SAVINGS_PERIOD), savingsRegion: read(SAVINGS_REGION), sessionsGroup: read(SESSIONS_GROUP), sessionsType: read(SESSIONS_TYPE), solarAdjusted: readBool(SETTINGS_SOLAR_ADJUSTED), + loadpoints: readJSON(LOADPOINTS), }); watch(() => settings.locale, save(SETTINGS_LOCALE)); @@ -104,11 +131,33 @@ watch(() => settings.energyflowDetails, saveBool(SETTINGS_ENERGYFLOW_DETAILS)); watch(() => settings.energyflowPv, saveBool(SETTINGS_ENERGYFLOW_PV)); watch(() => settings.energyflowBattery, saveBool(SETTINGS_ENERGYFLOW_BATTERY)); watch(() => settings.energyflowLoadpoints, saveBool(SETTINGS_ENERGYFLOW_LOADPOINTS)); -watch(() => settings.sessionInfo, saveArray(SESSION_INFO)); watch(() => settings.sessionColumns as string[], saveArray(SESSION_COLUMNS)); watch(() => settings.savingsPeriod, save(SAVINGS_PERIOD)); watch(() => settings.savingsRegion, save(SAVINGS_REGION)); watch(() => settings.sessionsGroup, save(SESSIONS_GROUP)); watch(() => settings.sessionsType, save(SESSIONS_TYPE)); watch(() => settings.solarAdjusted, saveBool(SETTINGS_SOLAR_ADJUSTED)); +watch(() => settings.loadpoints, saveJSON(LOADPOINTS), { deep: true }); + export default settings; + +// MIGRATIONS + +// Convert old comma-separated session_info to new loadpoints structure +// TODO: remove in later release +const SESSION_INFO = "session_info"; +const oldSessionInfo = read(SESSION_INFO); +if (oldSessionInfo && Object.keys(settings.loadpoints).length === 0) { + const sessionInfoArray = oldSessionInfo.split(","); + sessionInfoArray.forEach((info: string, index: number) => { + if (info.trim()) { + const loadpointId = `${index + 1}`; + if (!settings.loadpoints[loadpointId]) { + settings.loadpoints[loadpointId] = {}; + } + settings.loadpoints[loadpointId].info = info.trim() as SessionInfoKey; + } + }); + // Remove the old session_info key + delete window.localStorage[SESSION_INFO]; +} diff --git a/assets/js/store.ts b/assets/js/store.ts index 73b8bfe14..d82d12cb6 100644 --- a/assets/js/store.ts +++ b/assets/js/store.ts @@ -1,5 +1,8 @@ import { reactive } from "vue"; import type { State } from "./types/evcc"; +import { convertToUiLoadpoints } from "./uiLoadpoints"; +import { useDebouncedComputed } from "./utils/useDebouncedComputed"; +import settings from "./settings"; function setProperty(obj: object, props: string[], value: any) { const prop = props.shift(); @@ -32,12 +35,27 @@ const initialState: State = { const state = reactive(initialState); -const store = { +// create derived loadpoints array with ui specific fields (defaults, browser settings, ...); debounce for better performance +const uiLoadpoints = useDebouncedComputed( + () => convertToUiLoadpoints(state.loadpoints, state.vehicles), + () => [state.loadpoints, state.vehicles, settings.loadpoints], + 50 +); + +export interface Store { + state: State; // raw state from websocket + uiLoadpoints: typeof uiLoadpoints; + offline(value: boolean): void; + update(msg: any): void; + reset(): void; +} + +const store: Store = { state, + uiLoadpoints, offline(value: boolean) { state.offline = value; }, - // @ts-expect-error no-explicit-any update(msg) { Object.keys(msg).forEach(function (k) { if (k === "log") { diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 5a57e7d3a..6f9aa75ae 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -52,7 +52,7 @@ export interface FatalError { export interface State { offline: boolean; startup?: boolean; - loadpoints: []; + loadpoints: Loadpoint[]; forecast?: Forecast; currency?: CURRENCY; fatal?: FatalError[]; @@ -177,20 +177,87 @@ export enum LENGTH_UNIT { MILES = "mi", } -export interface LoadpointCompact { - icon: string; - title: string; - charging: boolean; - soc?: number; - power: number; - heating?: boolean; +export interface Loadpoint { + batteryBoost: boolean; + chargeCurrents?: number[]; + chargeDuration: number; chargePower: number; - connected: boolean; - index: number; - vehicleName: string; - chargerIcon?: string; - vehicleSoc: number; + chargeRemainingDuration?: number; + chargeRemainingEnergy?: number; + chargeTotalImport?: number; + chargeVoltages?: number[]; + chargedEnergy: number; chargerFeatureHeating: boolean; + chargerFeatureIntegratedDevice: boolean; + chargerIcon: string | null; + chargerPhases1p3p: boolean; + chargerSinglePhase: boolean; + chargerStatusReason: CHARGER_STATUS_REASON | null; + charging: boolean; + connected: boolean; + connectedDuration: number; + disableDelay: number; + disableThreshold: number; + effectiveLimitSoc: number; + effectiveMaxCurrent: number; + effectiveMinCurrent: number; + effectivePlanId: number; + effectivePlanSoc: number; + effectivePlanTime: string | null; + effectivePriority: number; + enableDelay: number; + enableThreshold: number; + enabled: boolean; + limitEnergy: number; + limitSoc: number; + maxCurrent: number; + minCurrent: number; + mode: CHARGE_MODE; + offeredCurrent: number; + phaseAction: PHASE_ACTION; + phaseRemaining: number; + phasesActive: number; + phasesConfigured: number; + planActive: boolean; + planEnergy: number; + planOverrun: number; + planPrecondition: number; + planProjectedEnd: string | null; + planProjectedStart: string | null; + planTime: string | null; + priority: number; + pvAction: PV_ACTION; + pvRemaining: number; + sessionCo2PerKWh: number | null; + sessionEnergy: number; + sessionPrice: number | null; + sessionPricePerKWh: number | null; + sessionSolarPercentage: number; + smartCostActive: boolean; + smartCostLimit: number | null; + smartCostNextStart: string | null; + smartFeedInPriorityActive: boolean; + smartFeedInPriorityLimit: number | null; + smartFeedInPriorityNextStart: string | null; + title: string; + vehicleClimaterActive: boolean | null; + vehicleDetectionActive: boolean; + vehicleLimitSoc: number; + vehicleName: string; + vehicleOdometer: number; + vehicleRange: number; + vehicleSoc: number; + vehicleTitle: string; + vehicleWelcomeActive: boolean; +} + +export interface UiLoadpoint extends Loadpoint { + // Derived/computed fields for UI display + id: string; + displayTitle: string; + icon: string; + order: number | null; + visible: boolean; } export enum THEME { @@ -238,6 +305,24 @@ export enum PHASES { THREE_PHASES = 3, } +export enum PHASE_ACTION { + INACTIVE = "inactive", + SCALE_1P = "scale1p", + SCALE_3P = "scale3p", +} + +export enum PV_ACTION { + INACTIVE = "inactive", + ENABLE = "enable", + DISABLE = "disable", +} + +export enum CHARGER_STATUS_REASON { + UNKNOWN = "unknown", + WAITING_FOR_AUTHORIZATION = "waitingforauthorization", + DISCONNECT_REQUIRED = "disconnectrequired", +} + export enum LOADPOINT_TYPE { CHARGING = "charging", HEATING = "heating", diff --git a/assets/js/uiLoadpoints.ts b/assets/js/uiLoadpoints.ts new file mode 100644 index 000000000..d0cf2db27 --- /dev/null +++ b/assets/js/uiLoadpoints.ts @@ -0,0 +1,77 @@ +import settings from "./settings"; +import type { UiLoadpoint, SessionInfoKey, Loadpoint, Vehicle } from "./types/evcc"; + +const get = (id: string) => { + if (!settings.loadpoints[id]) { + settings.loadpoints[id] = {}; + } + return settings.loadpoints[id]; +}; + +export const convertToUiLoadpoints = ( + loadpoints: Loadpoint[], + vehicles: Record +): UiLoadpoint[] => { + if (loadpoints.length === 0) return []; + + const mappedLoadpoints = loadpoints.map((lp, originalIndex) => { + const vehicle = vehicles[lp.vehicleName]; + const id = `${originalIndex + 1}`; + + return { + ...lp, + id, + displayTitle: vehicle?.title || lp.title || "Charging point", + icon: lp.chargerIcon || vehicle?.icon || "car", + order: getLoadpointOrder(id), + visible: isLoadpointVisible(id), + sessionInfo: getLoadpointSessionInfo(id), + }; + }); + + // Sort by order (loadpoints with no order go to the end) + return mappedLoadpoints.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity)); +}; + +export const getLoadpointOrder = (id: string): number | null => { + return get(id).order ?? null; +}; + +export const setLoadpointOrder = (orderedIds: string[]) => { + // Update order for all loadpoints in the ordered list + orderedIds.forEach((id, index) => { + get(id).order = index; + }); +}; + +export const isLoadpointVisible = (id: string): boolean => { + return get(id).visible ?? true; // Default to visible +}; + +export const setLoadpointVisibility = (id: string, visible: boolean) => { + get(id).visible = visible; +}; + +export const getLoadpointSessionInfo = (id: string): SessionInfoKey | undefined => { + return get(id).info; +}; + +export const setLoadpointSessionInfo = (id: string, value: SessionInfoKey) => { + get(id).info = value; +}; + +export const resetLoadpointsOrder = () => { + Object.keys(settings.loadpoints).forEach((id) => { + if (settings.loadpoints[id]) { + settings.loadpoints[id].order = undefined; + } + }); +}; + +export const resetLoadpointsVisible = () => { + Object.keys(settings.loadpoints).forEach((id) => { + if (settings.loadpoints[id]) { + settings.loadpoints[id].visible = undefined; + } + }); +}; diff --git a/assets/js/utils/debounce.ts b/assets/js/utils/debounce.ts new file mode 100644 index 000000000..b0f744178 --- /dev/null +++ b/assets/js/utils/debounce.ts @@ -0,0 +1,9 @@ +import type { Timeout } from "@/types/evcc"; + +export function debounce any>(fn: T, delay: number): T { + let timer: Timeout; + return ((...args: any[]) => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => fn(...args), delay); + }) as T; +} diff --git a/assets/js/utils/useDebouncedComputed.ts b/assets/js/utils/useDebouncedComputed.ts new file mode 100644 index 000000000..61884be07 --- /dev/null +++ b/assets/js/utils/useDebouncedComputed.ts @@ -0,0 +1,17 @@ +import { ref, watch, type Ref } from "vue"; +import { debounce } from "./debounce"; + +export function useDebouncedComputed( + getter: () => T, + deps: () => any, + delay: number = 100 +): Ref { + const result = ref(); + const debouncedUpdate = debounce(() => { + result.value = getter(); + }, delay); + + watch(deps, debouncedUpdate, { immediate: true, deep: true }); + + return result as Ref; +} diff --git a/assets/js/views/App.vue b/assets/js/views/App.vue index 49854ab46..db614c1c8 100644 --- a/assets/js/views/App.vue +++ b/assets/js/views/App.vue @@ -67,20 +67,24 @@ export default defineComponent({ batteryModalAvailabe() { return store.state.battery?.length; }, + state() { + const { state, uiLoadpoints } = store; + return { ...state, uiLoadpoints: uiLoadpoints.value }; + }, globalSettingsProps() { - return this.collectProps(GlobalSettingsModal, store.state); + return this.collectProps(GlobalSettingsModal, this.state); }, batterySettingsProps() { - return this.collectProps(BatterySettingsModal, store.state); + return this.collectProps(BatterySettingsModal, this.state); }, offlineIndicatorProps() { - return this.collectProps(OfflineIndicator, store.state); + return this.collectProps(OfflineIndicator, this.state); }, forecastModalProps() { - return this.collectProps(ForecastModal, store.state); + return this.collectProps(ForecastModal, this.state); }, loginModalProps() { - return this.collectProps(LoginModal, store.state); + return this.collectProps(LoginModal, this.state); }, }, watch: { diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index 093f55893..b3a3b625b 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -390,6 +390,7 @@ + =21.1.0" } }, + "node_modules/@formkit/drag-and-drop": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@formkit/drag-and-drop/-/drag-and-drop-0.5.3.tgz", + "integrity": "sha512-VGQsKYc350OOMbz7wYt69YqDbn49x7RJ32eqiJieNke54nGZYmx18XEqTw0lBiJmH3RaiH6V45gB1u+tvI/TNw==", + "license": "MIT" + }, "node_modules/@guolao/vue-monaco-editor": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@guolao/vue-monaco-editor/-/vue-monaco-editor-1.5.5.tgz", diff --git a/package.json b/package.json index 5de66e09a..d985b4e12 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "type": "module", "main": "index.js", "dependencies": { + "@formkit/drag-and-drop": "^0.5.3", "@guolao/vue-monaco-editor": "^1.5.5", "@h2d2/shopicons": "^1.9.0", "@popperjs/core": "^2.11.8", diff --git a/tests/loadpoint-sort.evcc.yaml b/tests/loadpoint-sort.evcc.yaml new file mode 100644 index 000000000..68638bcbd --- /dev/null +++ b/tests/loadpoint-sort.evcc.yaml @@ -0,0 +1,30 @@ +site: + title: Loadpoint Sort Test + +meters: + - name: grid + type: template + template: demo-meter + power: 200 + +loadpoints: + - title: First Loadpoint + charger: charger_1 + mode: now + - title: Second Loadpoint + charger: charger_2 + mode: now + - title: Third Loadpoint + charger: charger_3 + mode: now + +chargers: + - name: charger_1 + type: template + template: demo-charger + - name: charger_2 + type: template + template: demo-charger + - name: charger_3 + type: template + template: demo-charger diff --git a/tests/loadpoint-sort.spec.ts b/tests/loadpoint-sort.spec.ts new file mode 100644 index 000000000..2edd3b082 --- /dev/null +++ b/tests/loadpoint-sort.spec.ts @@ -0,0 +1,112 @@ +import { test, expect, type Page, type Locator } from "@playwright/test"; +import { start, stop, baseUrl } from "./evcc"; +import { expectModalVisible, expectModalHidden, openTopNavigation, dragElement } from "./utils"; + +const CONFIG_LOADPOINT_SORT = "loadpoint-sort.evcc.yaml"; + +test.use({ baseURL: baseUrl() }); +test.describe.configure({ mode: "parallel" }); + +test.beforeAll(async () => { + await start(CONFIG_LOADPOINT_SORT); +}); + +test.afterAll(async () => { + await stop(); +}); + +test.beforeEach(async ({ page }) => { + await page.goto("/"); +}); + +async function openModal(page: Page) { + await openTopNavigation(page); + await page.getByTestId("topnavigation-settings").click(); + const modal = page.getByTestId("global-settings-modal"); + await expectModalVisible(modal); + return modal; +} + +async function closeModal(modal: Locator) { + await modal.getByRole("button", { name: "Close" }).click(); + await expectModalHidden(modal); +} + +test.describe("loadpoint ordering and hiding", async () => { + test("initial loadpoint order", async ({ page }) => { + await expect(page.getByTestId("loadpoint")).toHaveCount(3); + + const loadpoints = page.getByTestId("loadpoint"); + await expect(loadpoints.nth(0)).toContainText("First Loadpoint"); + await expect(loadpoints.nth(1)).toContainText("Second Loadpoint"); + await expect(loadpoints.nth(2)).toContainText("Third Loadpoint"); + }); + + test("hide a loadpoint", async ({ page }) => { + const modal = await openModal(page); + + await modal.getByRole("switch", { name: "Hide Second Loadpoint" }).click(); + + await closeModal(modal); + + await expect(page.getByTestId("loadpoint")).toHaveCount(2); + await expect(page.getByRole("heading", { name: "Second Loadpoint" })).not.toBeVisible(); + }); + + test("reorder loadpoints", async ({ page }) => { + const modal = await openModal(page); + + const firstLoadpointDragItem = modal.getByRole("listitem", { + name: "Draggable: First Loadpoint", + }); + const thirdLoadpointDragItem = modal.getByRole("listitem", { + name: "Draggable: Third Loadpoint", + }); + + await dragElement(page, firstLoadpointDragItem, thirdLoadpointDragItem); + + await closeModal(modal); + + await expect(page.getByTestId("loadpoint")).toHaveCount(3); + const reorderedLoadpoints = page.getByTestId("loadpoint"); + await expect(reorderedLoadpoints.nth(0)).toContainText("Second Loadpoint"); + await expect(reorderedLoadpoints.nth(1)).toContainText("Third Loadpoint"); + await expect(reorderedLoadpoints.nth(2)).toContainText("First Loadpoint"); + }); + + test("reset to initial state", async ({ page }) => { + const modal = await openModal(page); + + await modal.getByRole("switch", { name: "Hide Second Loadpoint" }).click(); + + const firstLoadpointDragItem = modal.getByRole("listitem", { + name: "Draggable: First Loadpoint", + }); + const thirdLoadpointDragItem = modal.getByRole("listitem", { + name: "Draggable: Third Loadpoint", + }); + + await dragElement(page, firstLoadpointDragItem, thirdLoadpointDragItem); + + await modal.getByRole("button", { name: "Reset" }).click(); + + await closeModal(modal); + + await expect(page.getByTestId("loadpoint")).toHaveCount(3); + const loadpoints = page.getByTestId("loadpoint"); + await expect(loadpoints.nth(0)).toContainText("First Loadpoint"); + await expect(loadpoints.nth(1)).toContainText("Second Loadpoint"); + await expect(loadpoints.nth(2)).toContainText("Third Loadpoint"); + }); + + test("persist changes after page reload", async ({ page }) => { + const modal = await openModal(page); + + await modal.getByRole("switch", { name: "Hide Second Loadpoint" }).click(); + await closeModal(modal); + + await page.reload(); + await expect(page.getByTestId("loadpoint")).toHaveCount(2); + await expect(page.getByRole("heading", { name: "Second Loadpoint" })).not.toBeVisible(); + }); +}); diff --git a/tests/utils.ts b/tests/utils.ts index d0c1fc43e..8d9b9be65 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -123,3 +123,26 @@ export async function newLoadpoint( .click(); await lpModal.getByLabel("Title").fill(title); } + +export async function dragElement( + page: Page, + sourceElement: Locator, + targetElement: Locator +): Promise { + // Get bounding boxes to calculate actual positions + const sourceBox = await sourceElement.boundingBox(); + const targetBox = await targetElement.boundingBox(); + + if (sourceBox && targetBox) { + // Move from center of source item to center of target item + const startX = sourceBox.x + sourceBox.width / 2; + const startY = sourceBox.y + sourceBox.height / 2; + const endX = targetBox.x + targetBox.width / 2; + const endY = targetBox.y + targetBox.height / 2; + + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.mouse.move(endX, endY, { steps: 10 }); + await page.mouse.up(); + } +}