Online said out loud, not left as the absence of "Offline"

Settings listed a charger's reachability with one badge, drawn only when
the cloud said online:false. A reachable charger got nothing — the same
nothing a charger the cloud declined to report on gets — so the account's
two chargers read as one offline and one unremarkable. Online now has a
badge of its own, and the silent case stays silent: not saying is not the
same as saying no.

The Charging page said nothing at all, having only the record, which knows
what a charger is and not whether it is answering. So it asks: each
connected service's charger list is read into a live map keyed by the
provider's own id, and the information card carries the badge and, when
the service reports one, the operating state. Asking costs a round trip
per service, so it happens when the home tab is first opened — the moment
the question is being asked — and after that only when the user presses
Refresh or imports a charger. A service that will not answer leaves its
chargers unbadged rather than offline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-01 11:22:02 +02:00
co-authored by Claude Opus 5
parent 4203ca93db
commit 86ea97b414
5 changed files with 119 additions and 13 deletions
+6 -1
View File
@@ -105,7 +105,11 @@
"power": "Effekt", "power": "Effekt",
"connector": "Stik", "connector": "Stik",
"providerId": "Id hos tjenesten", "providerId": "Id hos tjenesten",
"added": "Tilføjet" "state": "Tilstand",
"added": "Tilføjet",
"online": "Online",
"offline": "Offline",
"refresh": "Opdater"
} }
}, },
@@ -324,6 +328,7 @@
"chargersLoading": "Indlæser…", "chargersLoading": "Indlæser…",
"chargersEmpty": "Der blev ikke fundet nogen EV-lader på denne konto.", "chargersEmpty": "Der blev ikke fundet nogen EV-lader på denne konto.",
"chargerUnnamed": "Lader uden navn", "chargerUnnamed": "Lader uden navn",
"chargerOnline": "Online",
"chargerOffline": "Offline", "chargerOffline": "Offline",
"chargerUse": "Brug denne", "chargerUse": "Brug denne",
"states": { "states": {
+6 -1
View File
@@ -91,7 +91,11 @@
"power": "Power", "power": "Power",
"connector": "Connector", "connector": "Connector",
"providerId": "Service id", "providerId": "Service id",
"added": "Added" "state": "State",
"added": "Added",
"online": "Online",
"offline": "Offline",
"refresh": "Refresh"
}, },
"stations": { "stations": {
"heading": "Nearby", "heading": "Nearby",
@@ -323,6 +327,7 @@
"chargersLoading": "Loading…", "chargersLoading": "Loading…",
"chargersEmpty": "No EV charger was found on this account.", "chargersEmpty": "No EV charger was found on this account.",
"chargerUnnamed": "Unnamed charger", "chargerUnnamed": "Unnamed charger",
"chargerOnline": "Online",
"chargerOffline": "Offline", "chargerOffline": "Offline",
"chargerUse": "Use this one", "chargerUse": "Use this one",
"states": { "states": {
+6 -1
View File
@@ -107,7 +107,11 @@
"power": "Moc", "power": "Moc",
"connector": "Złącze", "connector": "Złącze",
"providerId": "Identyfikator w usłudze", "providerId": "Identyfikator w usłudze",
"added": "Dodano" "state": "Stan",
"added": "Dodano",
"online": "Online",
"offline": "Offline",
"refresh": "Odśwież"
} }
}, },
@@ -328,6 +332,7 @@
"chargersLoading": "Wczytywanie…", "chargersLoading": "Wczytywanie…",
"chargersEmpty": "Na tym koncie nie znaleziono żadnej ładowarki EV.", "chargersEmpty": "Na tym koncie nie znaleziono żadnej ładowarki EV.",
"chargerUnnamed": "Ładowarka bez nazwy", "chargerUnnamed": "Ładowarka bez nazwy",
"chargerOnline": "Online",
"chargerOffline": "Offline", "chargerOffline": "Offline",
"chargerUse": "Użyj tej", "chargerUse": "Użyj tej",
"states": { "states": {
+94 -9
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, computed, onMounted } from "vue"; import { ref, computed, onMounted, watch } from "vue";
import { t } from "../i18n"; import { t } from "../i18n";
import { api } from "../api"; import { api } from "../api";
import { formatDateTime } from "../lib/format.js"; import { formatDateTime } from "../lib/format.js";
@@ -115,6 +115,64 @@ function providerLabel(id) {
return chargerProviders.value.find((p) => p.id === id)?.label || id; return chargerProviders.value.find((p) => p.id === id)?.label || id;
} }
async function loadChargerProviders() {
try {
chargerProviders.value = await api.listChargerProviders();
} catch {
chargerProviders.value = [];
}
}
// --- Live reachability, keyed by the provider's own id for the charger ---
// The record says what a charger is; only the service it came from knows
// whether it is reachable right now, so that half is asked for separately and
// held beside the records rather than in them. Asking costs a round trip to
// each connected service, so it happens when the home tab is first opened —
// the moment the question is being asked — and on demand after that.
const chargerLive = ref({}); // provider charger id → { online, status }
const chargerLiveLoading = ref(false);
const chargerLiveLoaded = ref(false);
async function loadChargerLive(force = false) {
const connected = chargerProviders.value.filter((p) => p.connected);
if (chargerLiveLoading.value || connected.length === 0) return;
if (chargerLiveLoaded.value && !force) return; // switching tabs is not a new question
chargerLiveLoading.value = true;
const live = {};
await Promise.all(
connected.map(async (p) => {
try {
const res = await api.listProviderChargers(p.id);
for (const c of res?.chargers || []) live[c.id] = { online: c.online, status: c.status };
} catch {
// A service that will not answer leaves its chargers unknown rather
// than offline — this page cannot tell those two apart.
}
})
);
chargerLive.value = live;
chargerLiveLoaded.value = true;
chargerLiveLoading.value = false;
}
// The live half for one imported charger, or null when the service it came from
// says nothing about it (disconnected since the import, or a charger added by
// hand). Both services we speak to id a charger by its serial, so the serial is
// a sound fallback for a record imported before the provider link was stored.
function liveFor(c) {
return chargerLive.value[c.providerChargerId] || chargerLive.value[c.serial] || null;
}
// The cloud's own slug for what the charger is doing (charging, standby, …),
// translated. The vocabulary is the integration's, so the wording lives with it
// in Settings rather than being said twice.
function chargerStateLabel(slug) {
if (!slug) return "";
const key = `settings.integrations.states.${slug}`;
const label = t(key);
return label === key ? slug.replace(/_/g, " ") : label;
}
async function loadHomeChargers() { async function loadHomeChargers() {
homeChargersError.value = ""; homeChargersError.value = "";
try { try {
@@ -136,6 +194,7 @@ function onChargerImported(charger) {
showChargerImport.value = false; showChargerImport.value = false;
homeChargers.value = [...homeChargers.value, charger]; homeChargers.value = [...homeChargers.value, charger];
selectHomeCharger(charger); selectHomeCharger(charger);
loadChargerLive(true);
} }
async function removeHomeCharger(c) { async function removeHomeCharger(c) {
@@ -166,6 +225,7 @@ function chargerInfoRows(c) {
["power", c.powerKw ? `${c.powerKw} kW` : ""], ["power", c.powerKw ? `${c.powerKw} kW` : ""],
["connector", c.connector], ["connector", c.connector],
["providerId", c.providerChargerId === c.serial ? "" : c.providerChargerId], ["providerId", c.providerChargerId === c.serial ? "" : c.providerChargerId],
["state", chargerStateLabel(liveFor(c)?.status)],
["added", c.created ? formatDateTime(c.created) : ""], ["added", c.created ? formatDateTime(c.created) : ""],
]; ];
return rows return rows
@@ -228,12 +288,16 @@ function cancelReset() {
resetPassword.value = ""; resetPassword.value = "";
} }
// Opening the home tab is the moment reachability is being asked about; the
// public half never needs it.
watch(chargerTab, (tab) => {
if (tab === "home") loadChargerLive();
});
onMounted(async () => { onMounted(async () => {
await loadHomeChargers(); await loadHomeChargers();
api await loadChargerProviders();
.listChargerProviders() if (chargerTab.value === "home") loadChargerLive();
.then((list) => (chargerProviders.value = list))
.catch(() => (chargerProviders.value = []));
await loadCtlMode(); await loadCtlMode();
if (ctlActive.value) await loadChargers(); if (ctlActive.value) await loadChargers();
await refreshCtl(); await refreshCtl();
@@ -475,7 +539,18 @@ onMounted(async () => {
what the charger *is* is known either way, so with control off this what the charger *is* is known either way, so with control off this
card is what fills the column instead of a bare hint. --> card is what fills the column instead of a bare hint. -->
<div class="dh-card p-4"> <div class="dh-card p-4">
<p class="text-sm font-semibold text-strong">{{ t("charging.info.title") }}</p> <div class="flex items-center justify-between gap-2">
<p class="text-sm font-semibold text-strong">{{ t("charging.info.title") }}</p>
<button
v-if="homeChargers.length"
type="button"
class="dh-btn dh-btn-ghost !px-2 !py-1 text-xs"
:disabled="chargerLiveLoading"
@click="loadChargerLive(true)"
>
{{ chargerLiveLoading ? t("common.loading") : t("charging.info.refresh") }}
</button>
</div>
<div <div
v-for="c in homeChargers" v-for="c in homeChargers"
@@ -485,9 +560,19 @@ onMounted(async () => {
> >
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<p class="truncate text-sm font-semibold text-strong">{{ c.name }}</p> <p class="truncate text-sm font-semibold text-strong">{{ c.name }}</p>
<span v-if="c.provider" class="dh-badge dh-badge-neutral shrink-0"> <div class="flex shrink-0 items-center gap-2">
{{ providerLabel(c.provider) }} <!-- Reachability, said either way. A charger the service says
</span> nothing about stays silent: unknown is not offline. -->
<span v-if="liveFor(c)?.online === true" class="dh-badge dh-badge-success">
{{ t("charging.info.online") }}
</span>
<span v-else-if="liveFor(c)?.online === false" class="dh-badge dh-badge-warning">
{{ t("charging.info.offline") }}
</span>
<span v-if="c.provider" class="dh-badge dh-badge-neutral">
{{ providerLabel(c.provider) }}
</span>
</div>
</div> </div>
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1"> <dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<template v-for="row in chargerInfoRows(c)" :key="row.key"> <template v-for="row in chargerInfoRows(c)" :key="row.key">
+7 -1
View File
@@ -1555,7 +1555,13 @@ onBeforeUnmount(() => {
</p> </p>
</div> </div>
<div class="flex shrink-0 items-center gap-2"> <div class="flex shrink-0 items-center gap-2">
<span v-if="c.online === false" class="dh-badge dh-badge-warning"> <!-- Reachability, said either way. A charger the cloud does
not report on at all (no `online` field) says nothing —
silence there is unknown, not online. -->
<span v-if="c.online === true" class="dh-badge dh-badge-success">
{{ t("settings.integrations.chargerOnline") }}
</span>
<span v-else-if="c.online === false" class="dh-badge dh-badge-warning">
{{ t("settings.integrations.chargerOffline") }} {{ t("settings.integrations.chargerOffline") }}
</span> </span>
<span v-if="c.statusDesc" class="dh-badge" :class="c.statusDesc === 'charging' ? 'dh-badge-success' : 'dh-badge-neutral'"> <span v-if="c.statusDesc" class="dh-badge" :class="c.statusDesc === 'charging' ? 'dh-badge-success' : 'dh-badge-neutral'">