chore: Typescript: Rewrite views (#21714)

Co-authored-by: Michael Geers <michael@geers.tv>
This commit is contained in:
Maschga 2025-08-05 19:56:39 +02:00 • committed by GitHub
parent f593848c09
commit 4c73cbe197
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 459 additions and 241 deletions

View file

@ -90,7 +90,7 @@
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { defineComponent, type PropType } from "vue";
import FormRow from "./FormRow.vue";
import PropertyEntry from "./PropertyEntry.vue";
import PropertyCollapsible from "./PropertyCollapsible.vue";
@ -102,9 +102,9 @@ import SponsorTokenRequired from "./DeviceModal/SponsorTokenRequired.vue";
import TemplateSelector, { customTemplateOption } from "./DeviceModal/TemplateSelector.vue";
import YamlEntry from "./DeviceModal/YamlEntry.vue";
import { initialTestState, performTest } from "./utils/test";
import { ConfigType } from "@/types/evcc";
import {
handleError,
ConfigType,
type DeviceValues,
type Template,
type Product,
@ -121,7 +121,7 @@ import switchsocketHeaterYaml from "./defaultYaml/switchsocketHeater.yaml?raw";
import switchsocketChargerYaml from "./defaultYaml/switchsocketCharger.yaml?raw";
import sgreadyYaml from "./defaultYaml/sgready.yaml?raw";
import sgreadyBoostYaml from "./defaultYaml/sgreadyBoost.yaml?raw";
import { LOADPOINT_TYPE } from "@/types/evcc";
import { LOADPOINT_TYPE, type LoadpointType } from "@/types/evcc";
const initialValues = { type: ConfigType.Template };
const device = createDeviceUtils("charger");
@ -165,7 +165,7 @@ export default defineComponent({
props: {
id: Number,
name: String,
loadpointType: { type: String as () => LOADPOINT_TYPE | null, default: null },
loadpointType: { type: String as PropType<LoadpointType>, default: null },
fade: String,
isSponsor: Boolean,
},

View file

@ -37,7 +37,7 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import FormRow from "../FormRow.vue";
import type { DeviceType } from "@/types/evcc";
import { type DeviceType } from "@/types/evcc";
export interface TemplateOption {
name: string;

View file

@ -14,8 +14,7 @@
import { defineComponent } from "vue";
import YamlEditorContainer from "../YamlEditorContainer.vue";
import { docsPrefix } from "@/i18n";
type DeviceType = "vehicle" | "charger" | "meter";
import { type DeviceType } from "@/types/evcc";
export default defineComponent({
name: "YamlEntry",

View file

@ -1,4 +1,5 @@
import type { DeviceType } from "@/types/evcc";
import { ConfigType } from "@/types/evcc";
import api from "@/api";
export type Product = {
@ -54,15 +55,6 @@ export function handleError(e: any, msg: string) {
export const timeout = 15000;
export enum ConfigType {
Template = "template",
Custom = "custom",
Heatpump = "heatpump",
SwitchSocket = "switchsocket",
SgReady = "sgready",
SgReadyBoost = "sgready-boost",
}
export function applyDefaultsFromTemplate(template: Template | null, values: DeviceValues) {
const params = template?.Params || [];
params

View file

@ -557,6 +557,7 @@
</template>
<script lang="ts">
import type { PropType } from "vue";
import FormRow from "./FormRow.vue";
import PropertyField from "./PropertyField.vue";
import SelectGroup from "../Helper/SelectGroup.vue";
@ -567,8 +568,16 @@ import deepEqual from "@/utils/deepEqual";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import EditIcon from "../MaterialIcon/Edit.vue";
import NewDeviceButton from "./NewDeviceButton.vue";
import { type ConfigType, handleError, customChargerName } from "./DeviceModal";
import { LOADPOINT_TYPE } from "@/types/evcc";
import { handleError, customChargerName } from "./DeviceModal";
import {
LOADPOINT_TYPE,
type DeviceType,
type LoadpointType,
type ConfigCharger,
type ConfigMeter,
type ConfigCircuit,
type ConfigLoadpoint,
} from "@/types/evcc";
const nsPerMin = 60 * 1e9;
@ -592,33 +601,13 @@ const defaultValues = {
charger: "",
circuit: "",
meter: "",
};
} as ConfigLoadpoint;
const defaultThresholds = {
enable: { delay: 1 * nsPerMin, threshold: 0 },
disable: { delay: 3 * nsPerMin, threshold: 0 },
};
type LoadpointValues = typeof defaultValues;
type Charger = {
name: string;
deviceProduct: string;
type: ConfigType;
config: { template: string };
};
type Meter = {
name: string;
deviceProduct: string;
config: { template: string };
};
type Circuit = {
name: string;
config: { title: string };
};
export default {
name: "LoadpointModal",
components: { FormRow, PropertyField, GenericModal, SelectGroup, EditIcon, NewDeviceButton },
@ -629,19 +618,22 @@ export default {
vehicleOptions: { type: Array, default: () => [] },
loadpointCount: { type: Number, default: 0 },
fade: String,
chargers: { type: Array as () => Charger[], default: () => [] },
chargers: { type: Array as PropType<ConfigCharger[]>, default: () => [] },
chargerValues: { type: Object, default: () => {} },
meters: { type: Array as () => Meter[], default: () => [] },
circuits: { type: Array as () => Circuit[], default: () => [] },
hasDeviceError: { type: Function, default: () => false },
meters: { type: Array as PropType<ConfigMeter[]>, default: () => [] },
circuits: { type: Array as PropType<ConfigCircuit[]>, default: () => [] },
hasDeviceError: {
type: Function as PropType<(type: DeviceType, name: string) => boolean>,
default: () => false,
},
},
emits: ["updated", "openMeterModal", "openChargerModal", "opened"],
data() {
return {
isModalVisible: false,
saving: false,
selectedType: null as LOADPOINT_TYPE | null,
values: deepClone(defaultValues) as LoadpointValues,
selectedType: null as LoadpointType | null,
values: deepClone(defaultValues) as ConfigLoadpoint,
chargerPower: "11kw",
solarMode: "default",
tab: "solar",
@ -742,10 +734,10 @@ export default {
...this.vehicleOptions,
];
},
typeChoices(): LOADPOINT_TYPE[] {
typeChoices(): LoadpointType[] {
return Object.values(LOADPOINT_TYPE);
},
loadpointType(): LOADPOINT_TYPE | null {
loadpointType(): LoadpointType | null {
return this.selectedType ?? this.chargerType;
},
addChargerLabel() {
@ -894,7 +886,7 @@ export default {
return;
}
},
selectType(type: LOADPOINT_TYPE) {
selectType(type: LoadpointType) {
this.selectedType = type;
},
},

View file

@ -137,9 +137,9 @@ import TemplateSelector, { customTemplateOption } from "./DeviceModal/TemplateSe
import YamlEntry from "./DeviceModal/YamlEntry.vue";
import { ICONS } from "../VehicleIcon/VehicleIcon.vue";
import { initialTestState, performTest } from "./utils/test";
import { ConfigType, type SelectedMeterType } from "@/types/evcc";
import {
handleError,
ConfigType,
type DeviceValues,
type Template,
type Product,
@ -197,7 +197,7 @@ export default defineComponent({
id: Number,
name: String,
type: {
type: String as () => "grid" | "pv" | "battery" | undefined,
type: String as () => SelectedMeterType | undefined,
default: undefined,
},
typeChoices: {

View file

@ -185,9 +185,9 @@ import TemplateSelector, { customTemplateOption } from "./DeviceModal/TemplateSe
import DeviceModalActions from "./DeviceModal/Actions.vue";
import YamlEntry from "./DeviceModal/YamlEntry.vue";
import { initialTestState, performTest } from "./utils/test";
import { ConfigType } from "@/types/evcc";
import {
handleError,
ConfigType,
type DeviceValues,
type Template,
type Product,

View file

@ -30,6 +30,8 @@ Fatal.args = {
error: `cannot create charger 'wallbox_cphl': cannot create charger type 'template': cannot create
charger type 'hardybarth-ecbl: Post http://192.168.2.219/api/v1/chargecontrols/1/mode: context deadline exceeded (Client.Timeout
exceeded while awaiting headers)`,
class: "",
device: "",
},
],
};

View file

@ -136,7 +136,7 @@ export default defineComponent({
auth: { type: Object as PropType<Auth>, default: () => ({ vehicles: {} }) },
currency: { type: String as PropType<CURRENCY>, required: true },
currency: { type: String as PropType<CURRENCY> },
statistics: Object,
tariffFeedIn: Number,
tariffGrid: Number,

View file

@ -26,9 +26,8 @@ function setProperty(obj: object, props: string[], value: any) {
const initialState: State = {
offline: false,
// ensure array type
loadpoints: [],
vehicles: [],
vehicles: {},
};
const state = reactive(initialState);

View file

@ -26,6 +26,21 @@ export interface Auth {
export type VehicleLogins = Record<string, { authenticated: boolean; uri: string }>;
export interface MqttConfig {
broker: string;
topic: string;
}
export interface InfluxConfig {
url: string;
database: any;
org: any;
}
export interface HemsConfig {
type: any;
}
export interface FatalError {
error: string;
class?: string;
@ -40,7 +55,109 @@ export interface State {
currency?: CURRENCY;
fatal?: FatalError[];
auth?: Auth;
vehicles: Vehicle[];
version?: string;
battery?: Battery[];
tariffGrid?: number;
tariffFeedIn?: number;
tariffCo2?: number;
tariffSolar?: number;
mqtt?: MqttConfig;
influx?: InfluxConfig;
hems?: HemsConfig;
sponsor?: Sponsor;
eebus?: any;
modbusproxy?: [];
messaging?: any;
interval?: number;
circuits?: Record<string, Circuit>;
siteTitle?: string;
vehicles: Record<string, Vehicle>;
authDisabled?: boolean;
}
export interface Config {
template?: string;
title?: string;
icon?: string;
[key: string]: number | string | undefined;
}
export interface Circuit {
name: string;
maxPower: number;
power?: number;
maxCurrent: number;
current?: number;
config?: Config;
}
export interface Entity {
name: string;
type: string;
id: number;
config: Config;
}
export enum ConfigType {
Template = "template",
Custom = "custom",
Heatpump = "heatpump",
SwitchSocket = "switchsocket",
SgReady = "sgready",
SgReadyBoost = "sgready-boost",
}
export type ConfigVehicle = Entity;
// Configuration-specific types for device setup/configuration contexts
export interface ConfigCharger extends Omit<Entity, "type"> {
deviceProduct: string;
type: ConfigType;
}
export interface ConfigMeter extends Entity {
deviceProduct: string;
deviceTitle?: string;
deviceIcon?: string;
}
export type ConfigCircuit = Entity;
export interface LoadpointThreshold {
delay: number;
threshold: number;
}
export interface ConfigLoadpoint {
id?: number;
name?: string;
charger: string;
meter: string;
vehicle: string;
title: string;
defaultMode: string;
priority: number;
phasesConfigured: number;
minCurrent: number;
maxCurrent: number;
smartCostLimit: number | null;
planEnergy?: number;
planTime?: string;
planPrecondition?: number;
limitEnergy?: number;
limitSoc?: number;
circuit?: string;
thresholds: {
enable: LoadpointThreshold;
disable: LoadpointThreshold;
};
soc: {
poll: {
mode: string;
interval: number;
};
estimate: boolean;
};
}
export enum SMART_COST_TYPE {
@ -120,6 +237,8 @@ export enum LOADPOINT_TYPE {
HEATING = "heating",
}
export type LoadpointType = ValueOf<typeof LOADPOINT_TYPE>;
export type SessionInfoKey =
| "remaining"
| "finished"
@ -203,10 +322,20 @@ export interface SelectOption<T> {
disabled?: boolean;
}
export type DeviceType = "charger" | "meter" | "vehicle";
export type DeviceType = "charger" | "meter" | "vehicle" | "loadpoint";
export type SelectedMeterType = "grid" | "pv" | "battery" | "charge" | "aux" | "ext";
// see https://stackoverflow.com/a/54178819
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
export interface SiteConfig {
grid: string;
pv: string[];
battery: string[];
title: string;
aux: string[] | null;
ext: string[] | null;
}
export type ValueOf<T> = T[keyof T];

View file

@ -12,7 +12,7 @@
</div>
</template>
<script>
<script lang="ts">
import store from "../store";
import GlobalSettingsModal from "../components/GlobalSettings/GlobalSettingsModal.vue";
import BatterySettingsModal from "../components/Battery/BatterySettingsModal.vue";
@ -22,18 +22,19 @@ import PasswordModal from "../components/Auth/PasswordModal.vue";
import LoginModal from "../components/Auth/LoginModal.vue";
import HelpModal from "../components/HelpModal.vue";
import collector from "../mixins/collector";
import { defineComponent } from "vue";
// assume offline if not data received for 5 minutes
let lastDataReceived = new Date();
const maxDataAge = 60 * 1000 * 5;
setInterval(() => {
if (new Date() - lastDataReceived > maxDataAge) {
if (new Date().getTime() - lastDataReceived.getTime() > maxDataAge) {
console.log("no data received, assume we are offline");
window.app.setOffline();
}
}, 1000);
export default {
export default defineComponent({
name: "App",
components: {
GlobalSettingsModal,
@ -50,7 +51,11 @@ export default {
offline: Boolean,
},
data: () => {
return { reconnectTimeout: null, ws: null, authNotConfigured: false };
return {
reconnectTimeout: null as number | null,
ws: null as WebSocket | null,
authNotConfigured: false,
};
},
head() {
return { title: "...", titleTemplate: "%s | evcc" };
@ -98,20 +103,25 @@ export default {
},
unmounted() {
this.disconnect();
window.clearTimeout(this.reconnectTimeout);
this.clearReconnectTimeout();
document.removeEventListener("visibilitychange", this.pageVisibilityChanged, false);
},
methods: {
clearReconnectTimeout() {
if (this.reconnectTimeout) {
window.clearTimeout(this.reconnectTimeout);
}
},
pageVisibilityChanged() {
if (document.hidden) {
window.clearTimeout(this.reconnectTimeout);
this.clearReconnectTimeout();
this.disconnect();
} else {
this.connect();
}
},
reconnect() {
window.clearTimeout(this.reconnectTimeout);
this.clearReconnectTimeout();
this.reconnectTimeout = window.setTimeout(() => {
this.disconnect();
this.connect();
@ -155,7 +165,7 @@ export default {
this.ws = new WebSocket(uri);
this.ws.onerror = () => {
console.log({ message: "Websocket error. Trying to reconnect." });
this.ws.close();
this.ws?.close();
};
this.ws.onopen = () => {
console.log("websocket connected");
@ -174,8 +184,9 @@ export default {
store.update(msg);
lastDataReceived = new Date();
} catch (error) {
const e = error as Error;
window.app.raise({
message: `Failed to parse web socket data: ${error.message} [${evt.data}]`,
message: `Failed to parse web socket data: ${e.message} [${evt.data}]`,
});
}
};
@ -184,7 +195,7 @@ export default {
window.location.reload();
},
},
};
});
</script>
<style scoped>
.app {

View file

@ -84,7 +84,7 @@
:editable="!!gridMeter.id"
:error="hasDeviceError('meter', gridMeter.name)"
data-testid="grid"
@edit="editMeter(gridMeter.id, 'grid')"
@edit="editMeter('grid', gridMeter.id)"
>
<template #icon>
<shopicon-regular-powersupply></shopicon-regular-powersupply>
@ -135,7 +135,7 @@
:editable="!!meter.id"
:error="hasDeviceError('meter', meter.name)"
data-testid="pv"
@edit="editMeter(meter.id, 'pv')"
@edit="editMeter('pv', meter.id)"
>
<template #icon>
<shopicon-regular-sun></shopicon-regular-sun>
@ -156,7 +156,7 @@
:editable="!!meter.id"
:error="hasDeviceError('meter', meter.name)"
data-testid="battery"
@edit="editMeter(meter.id, 'battery')"
@edit="editMeter('battery', meter.id)"
>
<template #icon>
<shopicon-regular-batterythreequarters></shopicon-regular-batterythreequarters>
@ -185,7 +185,7 @@
:editable="!!meter.id"
:error="hasDeviceError('meter', meter.name)"
data-testid="aux"
@edit="editMeter(meter.id, 'aux')"
@edit="editMeter('aux', meter.id)"
>
<template #icon>
<VehicleIcon :name="meter.deviceIcon || 'smartconsumer'" />
@ -330,7 +330,7 @@
:vehicleOptions="vehicleOptions"
:loadpointCount="loadpoints.length"
:chargers="chargers"
:chargerValues="deviceValues.charger"
:chargerValues="deviceValues['charger']"
:meters="meters"
:circuits="circuits"
:fade="loadpointSubModalOpen ? 'left' : ''"
@ -381,7 +381,7 @@
</div>
</template>
<script>
<script lang="ts">
import "@h2d2/shopicons/es/regular/sun";
import "@h2d2/shopicons/es/regular/batterythreequarters";
import "@h2d2/shopicons/es/regular/powersupply";
@ -421,12 +421,27 @@ import TariffsModal from "../components/Config/TariffsModal.vue";
import Header from "../components/Top/Header.vue";
import VehicleIcon from "../components/VehicleIcon";
import VehicleModal from "../components/Config/VehicleModal.vue";
import { defineComponent } from "vue";
import type {
ConfigCharger,
ConfigVehicle,
ConfigCircuit,
ConfigLoadpoint,
ConfigMeter,
LoadpointType,
Timeout,
SelectedMeterType,
SiteConfig,
DeviceType,
} from "@/types/evcc";
type DeviceValuesMap = Record<DeviceType, Record<string, any>>;
import BackupRestoreModal from "@/components/Config/BackupRestoreModal.vue";
import WelcomeBanner from "../components/Config/WelcomeBanner.vue";
import ExperimentalBanner from "../components/Config/ExperimentalBanner.vue";
import PasswordModal from "../components/Auth/PasswordModal.vue";
export default {
export default defineComponent({
name: "Config",
components: {
NewDeviceButton,
@ -470,22 +485,34 @@ export default {
},
data() {
return {
vehicles: [],
meters: [],
loadpoints: [],
chargers: [],
circuits: [],
selectedVehicleId: undefined,
selectedMeterId: undefined,
selectedMeterType: undefined,
selectedMeterTypeChoices: [],
selectedChargerId: undefined,
selectedLoadpointId: undefined,
selectedLoadpointType: undefined,
vehicles: [] as ConfigVehicle[],
meters: [] as ConfigMeter[],
loadpoints: [] as ConfigLoadpoint[],
chargers: [] as ConfigCharger[],
circuits: [] as ConfigCircuit[],
selectedVehicleId: undefined as number | undefined,
selectedMeterId: undefined as number | undefined,
selectedMeterType: undefined as SelectedMeterType | undefined,
selectedMeterTypeChoices: [] as string[],
selectedChargerId: undefined as number | undefined,
selectedLoadpointId: undefined as number | undefined,
selectedLoadpointType: undefined as LoadpointType | undefined,
loadpointSubModalOpen: false,
site: { grid: "", pv: [], battery: [], title: "" },
deviceValueTimeout: undefined,
deviceValues: {},
site: {
grid: "",
pv: [] as string[],
battery: [] as string[],
title: "",
aux: null as string[] | null,
ext: null as string[] | null,
} as SiteConfig,
deviceValueTimeout: null as Timeout,
deviceValues: {
meter: {},
vehicle: {},
charger: {},
loadpoint: {},
} as DeviceValuesMap,
isComponentMounted: true,
isPageVisible: true,
};
@ -536,7 +563,13 @@ export default {
) {
return null;
}
const tags = {};
const tags = {
currency: {},
gridPrice: {},
feedinPrice: {},
co2: {},
solarForecast: {},
};
if (currency) {
tags.currency = { value: currency };
}
@ -565,7 +598,7 @@ export default {
influxTags() {
const { url, database, org } = store.state?.influx || {};
if (!url) return { configured: { value: false } };
const result = { url: { value: url } };
const result = { url: { value: url }, bucket: {}, org: {} };
if (database) result.bucket = { value: database };
if (org) result.org = { value: org };
return result;
@ -574,7 +607,7 @@ export default {
return this.vehicles.map((v) => ({ key: v.name, name: v.config?.title || v.name }));
},
hemsTags() {
const result = { configured: { value: false } };
const result = { configured: { value: false }, hemsType: {} };
const { type } = store.state?.hems || {};
if (type) {
result.configured.value = true;
@ -621,14 +654,16 @@ export default {
unmounted() {
this.isComponentMounted = false;
document.removeEventListener("visibilitychange", this.handleVisibilityChange);
clearTimeout(this.deviceValueTimeout);
if (this.deviceValueTimeout) {
clearTimeout(this.deviceValueTimeout);
}
},
methods: {
handleVisibilityChange() {
this.isPageVisible = document.visibilityState === "visible";
if (this.isPageVisible) {
this.updateValues();
} else {
} else if (this.deviceValueTimeout) {
clearTimeout(this.deviceValueTimeout);
}
},
@ -666,7 +701,7 @@ export default {
},
async loadSite() {
const response = await api.get("/config/site", {
validateStatus: (status) => status < 500,
validateStatus: (status: number) => status < 500,
});
if (response.status === 200) {
this.site = response.data;
@ -676,37 +711,43 @@ export default {
const response = await api.get("/config/loadpoints");
this.loadpoints = response.data || [];
},
getMetersByNames(names) {
getMetersByNames(names: string[] | null) {
if (!names || !this.meters) {
return [];
}
return this.meters.filter((m) => names.includes(m.name));
},
getMeterById(id) {
getMeterById(id?: number) {
if (!id || !this.meters) {
return undefined;
}
return this.meters.find((m) => m.id === id);
},
getChargerById(id) {
getChargerById(id?: number) {
if (!id || !this.chargers) {
return undefined;
}
return this.chargers.find((c) => c.id === id);
},
vehicleModal() {
return Modal.getOrCreateInstance(document.getElementById("vehicleModal"));
return Modal.getOrCreateInstance(
document.getElementById("vehicleModal") as HTMLElement
);
},
meterModal() {
return Modal.getOrCreateInstance(document.getElementById("meterModal"));
return Modal.getOrCreateInstance(document.getElementById("meterModal") as HTMLElement);
},
loadpointModal() {
return Modal.getOrCreateInstance(document.getElementById("loadpointModal"));
return Modal.getOrCreateInstance(
document.getElementById("loadpointModal") as HTMLElement
);
},
chargerModal() {
return Modal.getOrCreateInstance(document.getElementById("chargerModal"));
return Modal.getOrCreateInstance(
document.getElementById("chargerModal") as HTMLElement
);
},
editLoadpointCharger(name, loadpointType) {
editLoadpointCharger(name: string, loadpointType?: LoadpointType) {
this.loadpointSubModalOpen = true;
const charger = this.chargers.find((c) => c.name === name);
if (charger && charger.id === undefined) {
@ -718,7 +759,7 @@ export default {
this.loadpointModal().hide();
this.$nextTick(() => this.editCharger(charger?.id, loadpointType));
},
editLoadpointMeter(name) {
editLoadpointMeter(name: string) {
this.loadpointSubModalOpen = true;
const meter = this.meters.find((m) => m.name === name);
if (meter && meter.id === undefined) {
@ -726,14 +767,14 @@ export default {
return;
}
this.loadpointModal().hide();
this.$nextTick(() => this.editMeter(meter?.id, "charge"));
this.$nextTick(() => this.editMeter("charge", meter?.id));
},
editMeter(id, type) {
this.selectedMeterId = id;
editMeter(type: SelectedMeterType, id?: number) {
this.selectedMeterType = type;
this.selectedMeterId = id;
this.$nextTick(() => this.meterModal().show());
},
newMeter(type) {
newMeter(type: SelectedMeterType) {
this.selectedMeterId = undefined;
this.selectedMeterType = type;
this.$nextTick(() => this.meterModal().show());
@ -750,7 +791,7 @@ export default {
this.selectedMeterTypeChoices = ["aux", "ext"];
this.$nextTick(() => this.meterModal().show());
},
editCharger(id, loadpointType) {
editCharger(id?: number, loadpointType?: LoadpointType) {
this.selectedChargerId = id;
this.selectedLoadpointType = loadpointType;
this.$nextTick(() => this.chargerModal().show());
@ -765,13 +806,16 @@ export default {
await this.loadDirty();
this.updateValues();
},
editLoadpoint(id) {
editLoadpoint(id?: number) {
if (!id) alert("missing loadpoint id");
this.selectedLoadpointId = id;
this.$nextTick(() => this.loadpointModal().show());
},
newLoadpoint() {
this.selectedLoadpointId = undefined;
this.$refs.loadpointModal.reset();
(
this.$refs["loadpointModal"] as InstanceType<typeof LoadpointModal> | undefined
)?.reset();
this.$nextTick(() => this.loadpointModal().show());
},
async loadpointChanged() {
@ -779,7 +823,7 @@ export default {
await this.loadLoadpoints();
this.loadDirty();
},
editVehicle(id) {
editVehicle(id: number) {
this.selectedVehicleId = id;
this.$nextTick(() => this.vehicleModal().show());
},
@ -799,28 +843,46 @@ export default {
yamlChanged() {
this.loadDirty();
},
meterAdded(type, name) {
meterAdded(type: SelectedMeterType, name: string) {
if (type === "charge") {
// update loadpoint
this.$refs.loadpointModal?.setMeter(name);
(
this.$refs["loadpointModal"] as InstanceType<typeof LoadpointModal> | undefined
)?.setMeter(name);
} else if (type === "grid") {
// update site grid
this.site.grid = name;
this.saveSite(type);
} else {
// update site pv, battery, aux
if (!this.site[type]) {
this.site[type] = [];
// update site pv, battery, aux, ext with type-safe approach
switch (type) {
case "pv":
if (!this.site.pv) this.site.pv = [];
this.site.pv.push(name);
break;
case "battery":
if (!this.site.battery) this.site.battery = [];
this.site.battery.push(name);
break;
case "aux":
if (!this.site.aux) this.site.aux = [];
this.site.aux.push(name);
break;
case "ext":
if (!this.site.ext) this.site.ext = [];
this.site.ext.push(name);
break;
}
this.site[type].push(name);
this.saveSite(type);
}
this.meterChanged();
},
meterRemoved(type) {
meterRemoved(type: SelectedMeterType) {
if (type === "charge") {
// update loadpoint
this.$refs.loadpointModal?.setMeter("");
(
this.$refs["loadpointModal"] as InstanceType<typeof LoadpointModal> | undefined
)?.setMeter("");
} else {
// update site grid, pv, battery, aux, ext
this.loadSite();
@ -828,12 +890,16 @@ export default {
}
this.meterChanged();
},
async chargerAdded(name) {
async chargerAdded(name: string) {
await this.chargerChanged();
this.$refs.loadpointModal?.setCharger(name);
(
this.$refs["loadpointModal"] as InstanceType<typeof LoadpointModal> | undefined
)?.setCharger(name);
},
chargerRemoved() {
this.$refs.loadpointModal?.setCharger("");
(
this.$refs["loadpointModal"] as InstanceType<typeof LoadpointModal> | undefined
)?.setCharger("");
this.chargerChanged();
},
meterModalClosed() {
@ -846,7 +912,7 @@ export default {
// reopen loadpoint modal
this.loadpointModal().show();
},
async saveSite(key) {
async saveSite(key: keyof SiteConfig) {
const body = key ? { [key]: this.site[key] } : this.site;
await api.put("/config/site", body);
await this.loadSite();
@ -859,28 +925,29 @@ export default {
async restart() {
await performRestart();
},
async updateDeviceValue(type, name) {
async updateDeviceValue(type: DeviceType, name: string) {
try {
const response = await api.get(`/config/devices/${type}/${name}/status`);
if (!this.deviceValues[type]) this.deviceValues[type] = {};
this.deviceValues[type][name] = response.data;
} catch (error) {
console.error("Error fetching device values for", type, name, error);
return null;
}
},
async updateValues() {
clearTimeout(this.deviceValueTimeout);
if (this.deviceValueTimeout) {
clearTimeout(this.deviceValueTimeout);
}
if (!this.offline) {
const devices = {
meter: this.meters,
vehicle: this.vehicles,
charger: this.chargers,
};
} as Record<DeviceType, any[]>;
for (const type in devices) {
for (const device of devices[type]) {
for (const device of devices[type as DeviceType]) {
if (this.isComponentMounted && this.isPageVisible) {
await this.updateDeviceValue(type, device.name);
await this.updateDeviceValue(type as DeviceType, device.name);
}
}
}
@ -891,16 +958,16 @@ export default {
this.deviceValueTimeout = setTimeout(this.updateValues, interval);
}
},
deviceTags(type, id) {
return this.deviceValues[type]?.[id] || {};
deviceTags(type: DeviceType, id: string) {
return this.deviceValues[type][id] || {};
},
loadpointTags(loadpoint) {
loadpointTags(loadpoint: ConfigLoadpoint) {
const { charger, meter } = loadpoint;
const chargerTags = charger ? this.deviceTags("charger", charger) : {};
const meterTags = meter ? this.deviceTags("meter", meter) : {};
return { ...chargerTags, ...meterTags };
},
openModal(id) {
openModal(id: string) {
const $el = document.getElementById(id);
if ($el) {
Modal.getOrCreateInstance($el).show();
@ -908,41 +975,43 @@ export default {
console.error(`modal ${id} not found`);
}
},
circuitTags(circuit) {
circuitTags(circuit: ConfigCircuit) {
const circuits = store.state?.circuits || {};
const data = circuits[circuit.name] || {};
const result = {};
const result: Record<string, object> = {};
const p = data.power || 0;
if (data.maxPower) {
result.powerRange = {
value: [data.power || 0, data.maxPower],
warning: data.power >= data.maxPower,
result["powerRange"] = {
value: [p, data.maxPower],
warning: data.power && data.power >= data.maxPower,
};
} else {
result.power = { value: data.power || 0, muted: true };
result["power"] = { value: p, muted: true };
}
if (data.maxCurrent) {
result.currentRange = {
result["currentRange"] = {
value: [data.current || 0, data.maxCurrent],
warning: data.current >= data.maxCurrent,
warning: data.current && data.current >= data.maxCurrent,
};
}
return result;
},
hasDeviceError(type, name) {
hasDeviceError(type: DeviceType, name?: string) {
if (!name) return false;
const fatals = store.state?.fatal || [];
return fatals.some((fatal) => fatal.class === type && fatal.device === name);
},
hasClassError(className) {
hasClassError(className: string) {
const fatals = store.state?.fatal || [];
return fatals.some((fatal) => fatal.class === className);
},
chargerIcon(chargerName) {
chargerIcon(chargerName: string) {
const charger = this.chargers.find((c) => c.name === chargerName);
return charger?.config?.icon || this.deviceValues?.charger?.[chargerName]?.icon?.value;
return charger?.config?.icon || this.deviceValues["charger"][chargerName]?.icon?.value;
},
},
};
});
</script>
<style scoped>
.config-list {

View file

@ -106,7 +106,7 @@
</div>
</template>
<script>
<script lang="ts">
import "@h2d2/shopicons/es/regular/download";
import Header from "../components/Top/Header.vue";
import Play from "../components/MaterialIcon/Play.vue";
@ -114,6 +114,8 @@ import Record from "../components/MaterialIcon/Record.vue";
import MultiSelect from "../components/Helper/MultiSelect.vue";
import api from "../api";
import store from "../store";
import { defineComponent, type PropType } from "vue";
import type { Timeout } from "@/types/evcc";
const LEVELS = ["fatal", "error", "warn", "info", "debug", "trace"];
const DEFAULT_LEVEL = "debug";
@ -121,7 +123,7 @@ const DEFAULT_COUNT = 1000;
const levelMatcher = new RegExp(`\\[.*?\\] (${LEVELS.map((l) => l.toUpperCase()).join("|")})`);
export default {
export default defineComponent({
name: "Log",
components: {
TopHeader: Header,
@ -130,15 +132,15 @@ export default {
MultiSelect,
},
props: {
areas: { type: Array, default: () => [] },
areas: { type: Array as PropType<string[]>, default: () => [] },
level: { type: String, default: DEFAULT_LEVEL },
},
data() {
return {
lines: [],
availableAreas: [],
lines: [] as string[],
availableAreas: [] as string[],
search: "",
timeout: null,
timeout: null as Timeout,
levels: LEVELS,
busy: false,
};
@ -216,7 +218,7 @@ export default {
this.stopInterval();
},
methods: {
async updateLogs(showAll) {
async updateLogs(showAll: boolean = false) {
// prevent concurrent requests
if (this.busy) return;
@ -263,20 +265,20 @@ export default {
console.error(e);
}
},
onScroll(e) {
onScroll(e: Event) {
const t = e.target as HTMLElement;
// disable follow when not at the bottom
if (
this.autoFollow &&
e.target.scrollTop + e.target.clientHeight < e.target.scrollHeight
) {
if (this.autoFollow && t && t.scrollTop + t.clientHeight < t.scrollHeight) {
this.stopInterval();
}
},
scrollToTop() {
this.$refs.log.scrollTop = 0;
const log = this.$refs["log"] as HTMLElement;
log.scrollTop = 0;
},
scrollToBottom() {
this.$refs.log.scrollTop = this.$refs.log.scrollHeight;
const log = this.$refs["log"] as HTMLElement;
log.scrollTop = log.scrollHeight;
},
toggleAutoFollow() {
if (this.autoFollow) {
@ -286,29 +288,30 @@ export default {
this.startInterval();
}
},
updateQuery({ level, areas }) {
let newLevel = level || this.level;
let newAreas = areas || this.areas;
updateQuery({ level: l, areas: a }: { level?: string; areas?: string[] }) {
const newLevel = l || this.level;
const newAreas = a || this.areas;
// reset to default level
if (newLevel === DEFAULT_LEVEL) newLevel = undefined;
newAreas = newAreas.length ? newAreas.join(",") : undefined;
this.$router.push({
query: { level: newLevel, areas: newAreas },
});
const level = newLevel === DEFAULT_LEVEL ? undefined : newLevel;
const areas = newAreas.length ? newAreas.join(",") : undefined;
this.$router.push({ query: { level, areas } });
},
changeLevel(event) {
this.updateQuery({ level: event.target.value });
changeLevel(event: Event) {
this.updateQuery({ level: (event.target as HTMLSelectElement).value });
},
changeAreas(areas) {
changeAreas(areas: string[]) {
this.updateQuery({ areas });
},
onCopy(event) {
const selection = window.getSelection().toString();
event.clipboardData.setData("text/plain", "```\n" + selection + "\n```");
onCopy(e: Event) {
const selection = window.getSelection()?.toString();
const event = e as ClipboardEvent;
event.clipboardData?.setData("text/plain", "```\n" + selection + "\n```");
event.preventDefault();
},
},
};
});
</script>
<style scoped>
.logs {

View file

@ -7,15 +7,17 @@
/>
</template>
<script>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import Site from "../components/Site/Site.vue";
import store from "../store";
import type { Notification } from "@/types/evcc";
export default {
export default defineComponent({
name: "Main",
components: { Site },
props: {
notifications: Array,
notifications: Array as PropType<Notification[]>,
selectedLoadpointIndex: Number,
},
data() {
@ -29,5 +31,5 @@ export default {
// no custom title
return { title: "evcc", titleTemplate: null };
},
};
});
</script>

View file

@ -112,7 +112,7 @@
v-else
:sessions="currentSessions"
:color-mappings="colorMappings"
:group-by="selectedGroup"
:group-by="selectedGroupWithoutNone"
/>
</div>
<AvgCostGroupedChart
@ -120,7 +120,7 @@
:sessions="currentTypeSessions"
:color-mappings="colorMappings"
:suggested-max-price="suggestedMaxAvgCost"
:group-by="selectedGroup"
:group-by="selectedGroupWithoutNone"
:cost-type="activeType"
:currency="currency"
/>
@ -131,13 +131,13 @@
v-if="activeType === types.SOLAR"
:sessions="currentSessions"
:color-mappings="colorMappings"
:group-by="selectedGroup"
:group-by="selectedGroupWithoutNone"
/>
<CostGroupedChart
v-else
:sessions="currentTypeSessions"
:color-mappings="colorMappings"
:group-by="selectedGroup"
:group-by="selectedGroupWithoutNone"
:cost-type="activeType"
:currency="currency"
/>
@ -176,7 +176,7 @@
</div>
</template>
<script>
<script lang="ts">
import Modal from "bootstrap/js/dist/modal";
import "@h2d2/shopicons/es/regular/cablecharge";
import "@h2d2/shopicons/es/regular/car3";
@ -205,9 +205,11 @@ import PeriodSelector from "../components/Sessions/PeriodSelector.vue";
import DateNavigator from "../components/Sessions/DateNavigator.vue";
import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue";
import TotalIcon from "../components/MaterialIcon/Total.vue";
import { TYPES, GROUPS, PERIODS } from "../components/Sessions/types";
import { TYPES, GROUPS, PERIODS, type Session } from "../components/Sessions/types";
import { defineComponent, type PropType } from "vue";
import { CURRENCY } from "@/types/evcc";
export default {
export default defineComponent({
name: "Sessions",
components: {
SessionDetailsModal,
@ -230,20 +232,20 @@ export default {
},
mixins: [formatter],
props: {
notifications: Array,
notifications: Array as PropType<Notification[]>,
month: { type: Number, default: () => new Date().getMonth() + 1 },
year: { type: Number, default: () => new Date().getFullYear() },
period: { type: String, default: PERIODS.MONTH },
period: { type: String as PropType<PERIODS>, default: PERIODS.MONTH },
loadpointFilter: { type: String, default: "" },
vehicleFilter: { type: String, default: "" },
offline: Boolean,
},
data() {
return {
sessions: [],
selectedType: settings.sessionsType || TYPES.SOLAR,
selectedGroup: settings.sessionsGroup || GROUPS.NONE,
selectedSessionId: undefined,
sessions: [] as Session[],
selectedType: (settings.sessionsType || TYPES.SOLAR) as TYPES,
selectedGroup: (settings.sessionsGroup || GROUPS.NONE) as GROUPS,
selectedSessionId: undefined as number | undefined,
periods: PERIODS,
types: TYPES,
groups: GROUPS,
@ -253,8 +255,11 @@ export default {
return { title: this.$t("sessions.title") };
},
computed: {
selectedGroupWithoutNone() {
return this.selectedGroup !== this.groups.NONE ? this.selectedGroup : undefined;
},
currency() {
return store.state.currency || "EUR";
return store.state.currency || CURRENCY.EUR;
},
energyTitle() {
return this.$t("sessions.chartTitle.energy");
@ -369,24 +374,24 @@ export default {
}
},
totalPrice() {
return this.currentSessionsWithPrice.reduce((acc, s) => acc + s.price, 0);
return this.currentSessionsWithPrice.reduce((acc, s) => acc + (s.price ?? 0), 0);
},
pricePerKWh() {
const list = this.currentSessionsWithPrice;
const energy = list.reduce((acc, s) => acc + s.chargedEnergy, 0);
return energy ? this.totalPrice / energy : null;
return energy ? this.totalPrice / energy : 0;
},
currentSessionsWithCo2() {
return this.currentSessions.filter((s) => s.co2PerKWh !== null);
},
totalCo2() {
const list = this.currentSessionsWithCo2;
return list.reduce((acc, s) => acc + s.co2PerKWh * s.chargedEnergy, 0);
return list.reduce((acc, s) => acc + (s.co2PerKWh ?? 0) * s.chargedEnergy, 0);
},
co2PerKWh() {
const list = this.currentSessionsWithCo2;
const energy = list.reduce((acc, s) => acc + s.chargedEnergy, 0);
return energy ? this.totalCo2 / energy : null;
return energy ? this.totalCo2 / energy : 0;
},
costTitle() {
const type = this.activeType === TYPES.PRICE ? "Price" : "Co2";
@ -426,10 +431,6 @@ export default {
startDate() {
return new Date(this.sessions[0]?.created || Date.now());
},
topNavigation() {
const vehicleLogins = store.state.auth ? store.state.auth.vehicles : {};
return { vehicleLogins, ...this.collectProps(TopNavigation, store.state) };
},
sessionsWithDefaults() {
return this.sessions.map((session) => {
const loadpoint = session.loadpoint || this.$t("main.loadpoint.fallbackName");
@ -456,7 +457,7 @@ export default {
},
vehicleList() {
const vehicles = store.state.vehicles || {};
return Object.entries(vehicles).map(([name, vehicle]) => ({ name, ...vehicle }));
return Object.entries(vehicles).map(([name, vehicle]) => ({ ...vehicle, name }));
},
loadpointList() {
const loadpoints = store.state.loadpoints || [];
@ -468,7 +469,7 @@ export default {
monthName() {
const date = new Date();
date.setMonth(this.month - 1, 1);
return this.fmtMonth(date);
return this.fmtMonth(date, false);
},
csvLinkLabel() {
if (this.period === PERIODS.MONTH) {
@ -497,8 +498,8 @@ export default {
lastThreeMonths.setMonth(lastThreeMonths.getMonth() - 3);
// Aggregate energy to get sorted list of loadpoints/vehicles for coloring
const aggregateEnergy = (group) => {
return this.sessionsWithDefaults.reduce((acc, session) => {
const aggregateEnergy = (group: Exclude<GROUPS, GROUPS.NONE>) => {
return this.sessionsWithDefaults.reduce((acc: Record<string, number>, session) => {
if (new Date(session.created) >= lastThreeMonths) {
const key = session[group];
acc[key] = (acc[key] || 0) + session.chargedEnergy;
@ -508,8 +509,11 @@ export default {
};
// Assign colors based on energy usage
const assignColors = (energyAggregation, colorType) => {
const result = {};
const assignColors = (
energyAggregation: Record<string, number>,
colorType: Exclude<GROUPS, GROUPS.NONE>
) => {
const result: Record<string, string> = {};
let colorIndex = 0;
// Assign colors by used energy in the last three months
@ -533,11 +537,11 @@ export default {
return result;
};
const loadpointEnergy = aggregateEnergy("loadpoint");
const loadpointColors = assignColors(loadpointEnergy, "loadpoint");
const loadpointEnergy = aggregateEnergy(GROUPS.LOADPOINT);
const loadpointColors = assignColors(loadpointEnergy, GROUPS.LOADPOINT);
const vehicleEnergy = aggregateEnergy("vehicle");
const vehicleColors = assignColors(vehicleEnergy, "vehicle");
const vehicleEnergy = aggregateEnergy(GROUPS.VEHICLE);
const vehicleColors = assignColors(vehicleEnergy, GROUPS.VEHICLE);
const solar = { self: colors.self, grid: colors.grid };
const cost = { price: colors.price, co2: colors.co2 };
@ -583,14 +587,24 @@ export default {
},
groupEntriesAvailable() {
if (this.selectedGroup === GROUPS.NONE || !this.currentSessions.length) return false;
return new Set(this.currentSessions.map((s) => s[this.selectedGroup])).size > 1;
return (
new Set(
this.currentSessions.map(
(s) => s[this.selectedGroup as Exclude<GROUPS, GROUPS.NONE>]
)
).size > 1
);
},
showSolarYearChart() {
return this.period !== PERIODS.MONTH && this.selectedGroup === GROUPS.NONE;
},
showExtraCharts() {
const hasMultipleEntries =
new Set(this.currentTypeSessions.map((s) => s[this.selectedGroup])).size > 1;
new Set(
this.currentTypeSessions.map(
(s) => s[this.selectedGroup as Exclude<GROUPS, GROUPS.NONE>]
)
).size > 1;
const isGrouped = [GROUPS.LOADPOINT, GROUPS.VEHICLE].includes(this.selectedGroup);
const isSolar = this.activeType === TYPES.SOLAR;
const isNotMonth = this.period !== PERIODS.MONTH;
@ -600,14 +614,14 @@ export default {
suggestedMaxAvgPrice() {
// returns the 98th percentile of avg prices for all sessions
const sessionsWithPrice = this.sessions.filter((s) => s.pricePerKWh !== null);
const prices = sessionsWithPrice.map((s) => s.pricePerKWh);
return this.percentile(prices, 98);
const prices = sessionsWithPrice.map((s) => s.pricePerKWh ?? 0);
return this.percentile(prices, 98) ?? 0;
},
suggestedMaxAvgCo2() {
// returns the 98th percentile of avg co2 emissions for all sessions
const sessionsWithCo2 = this.sessions.filter((s) => s.co2PerKWh !== null);
const co2 = sessionsWithCo2.map((s) => s.co2PerKWh);
return this.percentile(co2, 98);
const co2 = sessionsWithCo2.map((s) => s.co2PerKWh ?? 0);
return this.percentile(co2, 98) ?? 0;
},
suggestedMaxAvgCost() {
return this.activeType === TYPES.PRICE
@ -617,22 +631,22 @@ export default {
suggestedMaxCo2() {
// returns the 98th percentile of total co2 emissions by time period
const sessionsWithCo2 = this.sessions.filter((s) => s.co2PerKWh !== null);
const co2Map = sessionsWithCo2.reduce((acc, s) => {
const co2Map = sessionsWithCo2.reduce((acc: Record<string, number>, s) => {
const key = this.dateToPeriodKey(new Date(s.created));
acc[key] = (acc[key] || 0) + s.co2PerKWh * s.chargedEnergy;
acc[key] = (acc[key] || 0) + (s.co2PerKWh ?? 0) * s.chargedEnergy;
return acc;
}, {});
return Math.max(this.percentile(Object.values(co2Map), 98), 5); // 5kg default
return Math.max(this.percentile(Object.values(co2Map), 98) ?? 0, 5); // 5kg default
},
suggestedMaxPrice() {
// returns the 98th percentile of total price by time period
const sessionsWithPrice = this.sessions.filter((s) => s.price !== null);
const priceMap = sessionsWithPrice.reduce((acc, s) => {
const priceMap = sessionsWithPrice.reduce((acc: Record<string, number>, s) => {
const key = this.dateToPeriodKey(new Date(s.created));
acc[key] = (acc[key] || 0) + s.price;
acc[key] = (acc[key] || 0) + (s.price || 0);
return acc;
}, {});
return Math.max(this.percentile(Object.values(priceMap), 98), 1); // 1 CURRENCY default
return Math.max(this.percentile(Object.values(priceMap), 98) ?? 0, 1); // 1 CURRENCY default
},
suggestedMaxCost() {
return this.activeType === TYPES.PRICE ? this.suggestedMaxPrice : this.suggestedMaxCo2;
@ -647,10 +661,10 @@ export default {
this.loadSessions();
},
methods: {
changePeriod(newPeriod) {
let month = this.month;
let year = this.year;
let period = newPeriod;
changePeriod(newPeriod: PERIODS) {
let month: number | undefined = this.month;
let year: number | undefined = this.year;
let period: PERIODS | undefined = newPeriod;
switch (period) {
case PERIODS.TOTAL:
month = undefined;
@ -664,8 +678,12 @@ export default {
}
this.$router.push({ query: { ...this.$route.query, period, month, year } });
},
dateToPeriodKey(date) {
const options = { year: "numeric", month: "numeric", day: "numeric" };
dateToPeriodKey(date: Date) {
const options: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "numeric",
day: "numeric",
};
if (this.period === PERIODS.YEAR) options.day = undefined;
if (this.period === PERIODS.TOTAL) options.month = undefined;
return date.toLocaleDateString(undefined, options);
@ -673,44 +691,46 @@ export default {
async loadSessions() {
const response = await api.get("sessions");
// ensure sessions are sorted by created date
const sortedSessions = response.data.sort((a, b) => {
return new Date(a.created) - new Date(b.created);
const sortedSessions = response.data?.sort((a: Session, b: Session) => {
return new Date(a.created).getTime() - new Date(b.created).getTime();
});
this.sessions = sortedSessions;
},
showDetails(sessionId) {
showDetails(sessionId: number) {
this.selectedSessionId = sessionId;
const modal = Modal.getOrCreateInstance(document.getElementById("sessionDetailsModal"));
const modal = Modal.getOrCreateInstance(
document.getElementById("sessionDetailsModal") as HTMLElement
);
modal.show();
},
csvHrefLink(year, month) {
csvHrefLink(year?: number, month?: number) {
const params = new URLSearchParams({
format: "csv",
lang: this.$i18n?.locale,
});
if (year) params.append("year", year);
if (month) params.append("month", month);
if (year) params.append("year", year.toString());
if (month) params.append("month", month.toString());
return `./api/sessions?${params.toString()}`;
},
updateType(type) {
updateType(type: TYPES) {
this.selectedType = type;
settings.sessionsType = type;
},
updateGroup(group) {
updateGroup(group: GROUPS) {
this.selectedGroup = group;
settings.sessionsGroup = group;
},
updateDate({ year, month }) {
updateDate({ year, month }: { year: number; month: number }) {
this.$router.push({ query: { ...this.$route.query, year, month } });
},
percentile(arr, p) {
percentile(arr: number[], p: number): number | null {
if (arr.length === 0) return null;
const sorted = arr.sort((a, b) => a - b);
const index = (p / 100) * (sorted.length - 1);
return sorted[Math.floor(index)];
},
},
};
});
</script>
<style scoped>