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",
"connector": "Stik",
"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…",
"chargersEmpty": "Der blev ikke fundet nogen EV-lader på denne konto.",
"chargerUnnamed": "Lader uden navn",
"chargerOnline": "Online",
"chargerOffline": "Offline",
"chargerUse": "Brug denne",
"states": {
+6 -1
View File
@@ -91,7 +91,11 @@
"power": "Power",
"connector": "Connector",
"providerId": "Service id",
"added": "Added"
"state": "State",
"added": "Added",
"online": "Online",
"offline": "Offline",
"refresh": "Refresh"
},
"stations": {
"heading": "Nearby",
@@ -323,6 +327,7 @@
"chargersLoading": "Loading…",
"chargersEmpty": "No EV charger was found on this account.",
"chargerUnnamed": "Unnamed charger",
"chargerOnline": "Online",
"chargerOffline": "Offline",
"chargerUse": "Use this one",
"states": {
+6 -1
View File
@@ -107,7 +107,11 @@
"power": "Moc",
"connector": "Złącze",
"providerId": "Identyfikator w usłudze",
"added": "Dodano"
"state": "Stan",
"added": "Dodano",
"online": "Online",
"offline": "Offline",
"refresh": "Odśwież"
}
},
@@ -328,6 +332,7 @@
"chargersLoading": "Wczytywanie…",
"chargersEmpty": "Na tym koncie nie znaleziono żadnej ładowarki EV.",
"chargerUnnamed": "Ładowarka bez nazwy",
"chargerOnline": "Online",
"chargerOffline": "Offline",
"chargerUse": "Użyj tej",
"states": {
+94 -9
View File
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, onMounted } from "vue";
import { ref, computed, onMounted, watch } from "vue";
import { t } from "../i18n";
import { api } from "../api";
import { formatDateTime } from "../lib/format.js";
@@ -115,6 +115,64 @@ function providerLabel(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() {
homeChargersError.value = "";
try {
@@ -136,6 +194,7 @@ function onChargerImported(charger) {
showChargerImport.value = false;
homeChargers.value = [...homeChargers.value, charger];
selectHomeCharger(charger);
loadChargerLive(true);
}
async function removeHomeCharger(c) {
@@ -166,6 +225,7 @@ function chargerInfoRows(c) {
["power", c.powerKw ? `${c.powerKw} kW` : ""],
["connector", c.connector],
["providerId", c.providerChargerId === c.serial ? "" : c.providerChargerId],
["state", chargerStateLabel(liveFor(c)?.status)],
["added", c.created ? formatDateTime(c.created) : ""],
];
return rows
@@ -228,12 +288,16 @@ function cancelReset() {
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 () => {
await loadHomeChargers();
api
.listChargerProviders()
.then((list) => (chargerProviders.value = list))
.catch(() => (chargerProviders.value = []));
await loadChargerProviders();
if (chargerTab.value === "home") loadChargerLive();
await loadCtlMode();
if (ctlActive.value) await loadChargers();
await refreshCtl();
@@ -475,7 +539,18 @@ onMounted(async () => {
what the charger *is* is known either way, so with control off this
card is what fills the column instead of a bare hint. -->
<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
v-for="c in homeChargers"
@@ -485,9 +560,19 @@ onMounted(async () => {
>
<div class="flex items-center justify-between gap-2">
<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">
{{ providerLabel(c.provider) }}
</span>
<div class="flex shrink-0 items-center gap-2">
<!-- Reachability, said either way. A charger the service says
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>
<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">
+7 -1
View File
@@ -1555,7 +1555,13 @@ onBeforeUnmount(() => {
</p>
</div>
<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") }}
</span>
<span v-if="c.statusDesc" class="dh-badge" :class="c.statusDesc === 'charging' ? 'dh-badge-success' : 'dh-badge-neutral'">