The card beside it had boxes, so this one gets boxes

Charger information was one long list of everything the service knows;
the readings card next to it had been splitting its fields into a box
per group all along. Same treatment here: Device, Status, Network and
On the account, plus the service's own fields and the per-charger views,
each in its own sunken section under a heading. Both clients, since the
web and the phone draw the same card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5 committed 2026-09-02 22:18:45 +02:00
1 parent 1a7f04cba0
commit fb42791f8d
8 files changed
+234 -166

No files matched your search

+79 -54
View File
@@ -868,44 +868,59 @@ function homeChargerSubtitle(c) {
return [c.serial, c.model, c.siteName].filter(Boolean).join(" · ");
}
// Everything the card can say about one charger, as label/value rows. Every row
// is drawn every time, a field nothing supplied included: which fields a charger
// has an answer for is itself worth seeing, and a row that comes and goes with
// the data makes two chargers side by side impossible to read against each
// other. Nothing to say is said with a dash.
function chargerInfoRows(c) {
// Everything the card can say about one charger, in groups: what the box is,
// what it is doing, how it is connected, and how it sits on the account. Four
// short lists under headings, one box each the way the readings card draws its
// own, beat one long list nobody can find a field in. Every row is drawn every
// time, a field nothing supplied included: which fields a charger has an answer
// for is itself worth seeing, and a row that comes and goes with the data makes
// two chargers side by side impossible to read against each other. Nothing to
// say is said with a dash.
function chargerInfoGroups(c) {
const live = liveFor(c) || {};
const rows = [
["vendor", c.vendor],
["model", c.model],
["firmware", live.firmware],
["serial", c.serial],
["site", c.siteName || live.siteName],
["siteId", live.siteId],
["sources", sourcesLabel(live.sources)],
["power", c.powerKw ? `${c.powerKw} kW` : ""],
["connector", c.connector],
["state", chargerStateLabel(live.status)],
// Relayed as the service words it — the unit is upstream's, so putting one
// on it here would be inventing it.
["chargePower", live.power],
["ocpp", ocppStatusLabel(live)],
// The box on the wall, as opposed to the charging: the networks it is on,
// where it thinks it is, and when the account first saw it.
["wifiName", live.wifiName],
["wifiMac", live.wifiMac],
["signal", isSet(live.wifiRssi) ? `${live.wifiRssi} dBm` : ""],
["bleMac", live.bleMac],
["relatedBy", (live.relatedBy || []).join(" · ")],
["timeZone", live.timeZone],
["linked", live.linkedAt ? formatDateTime(new Date(live.linkedAt * 1000)) : ""],
["providerId", c.providerChargerId],
["added", c.created ? formatDateTime(c.created) : ""],
const groups = [
["device", [
["vendor", c.vendor],
["model", c.model],
["firmware", live.firmware],
["serial", c.serial],
["power", c.powerKw ? `${c.powerKw} kW` : ""],
["connector", c.connector],
]],
["status", [
["state", chargerStateLabel(live.status)],
// Relayed as the service words it — the unit is upstream's, so putting one
// on it here would be inventing it.
["chargePower", live.power],
["ocpp", ocppStatusLabel(live)],
]],
// The box on the wall, as opposed to the charging: the networks it is on.
["network", [
["wifiName", live.wifiName],
["wifiMac", live.wifiMac],
["signal", isSet(live.wifiRssi) ? `${live.wifiRssi} dBm` : ""],
["bleMac", live.bleMac],
["relatedBy", (live.relatedBy || []).join(" · ")],
]],
// Where it thinks it is, and how the account came to know it.
["account", [
["site", c.siteName || live.siteName],
["siteId", live.siteId],
["sources", sourcesLabel(live.sources)],
["timeZone", live.timeZone],
["linked", live.linkedAt ? formatDateTime(new Date(live.linkedAt * 1000)) : ""],
["providerId", c.providerChargerId],
["added", c.created ? formatDateTime(c.created) : ""],
]],
];
return rows.map(([key, value]) => ({
key,
label: t(`charging.info.${key}`),
value: value === "" || value == null ? "—" : value,
return groups.map(([id, rows]) => ({
id,
label: t(`charging.info.groups.${id}`),
rows: rows.map(([key, value]) => ({
key,
label: t(`charging.info.${key}`),
value: value === "" || value == null ? "—" : value,
})),
}));
}
@@ -1865,8 +1880,11 @@ onMounted(async () => {
</div>
<div v-show="isOpen('info')">
<!-- The charger picked in the list beside this card, and only it. -->
<div v-if="selectedHomeCharger" class="mt-3 rounded-control bg-sunken p-3">
<!-- The charger picked in the list beside this card, and only it. Its
fields are split into a box each, the way the readings card draws
its groups: one list of everything the service knows is harder to
find a field in than several short ones under headings. -->
<div v-if="selectedHomeCharger" class="mt-3 flex flex-col gap-2">
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
<!-- The product shot the app shows for this model, when the
@@ -1894,41 +1912,48 @@ onMounted(async () => {
</span>
</div>
</div>
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<template v-for="row in chargerInfoRows(selectedHomeCharger)" :key="row.key">
<dt class="text-[11px] text-muted">{{ row.label }}</dt>
<dd class="data break-all text-[11px] text-body">{{ row.value }}</dd>
</template>
</dl>
<section
v-for="group in chargerInfoGroups(selectedHomeCharger)"
:key="group.id"
class="rounded-control bg-sunken p-3"
>
<h4 class="eyebrow">{{ group.label }}</h4>
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<template v-for="row in group.rows" :key="row.key">
<dt class="text-[11px] text-muted">{{ row.label }}</dt>
<dd class="data break-all text-[11px] text-body">{{ row.value }}</dd>
</template>
</dl>
</section>
<!-- The rest of what the service knows, in the service's own words.
It only appears when there is something in it, which is also how
a charger the cloud says nothing more about stays quiet. -->
<template v-if="chargerAttrRows(selectedHomeCharger).length">
<p class="eyebrow mt-3">{{ t("charging.info.rawTitle") }}</p>
<dl class="mt-1 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<section v-if="chargerAttrRows(selectedHomeCharger).length" class="rounded-control bg-sunken p-3">
<h4 class="eyebrow">{{ t("charging.info.rawTitle") }}</h4>
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<template v-for="row in chargerAttrRows(selectedHomeCharger)" :key="row.key">
<dt class="data break-all text-[11px] text-muted">{{ row.key }}</dt>
<dd class="data break-all text-[11px] text-body">{{ row.value }}</dd>
</template>
</dl>
<p class="mt-1 text-[11px] text-muted">{{ t("charging.info.rawHint") }}</p>
</template>
<p class="mt-2 text-[11px] text-muted">{{ t("charging.info.rawHint") }}</p>
</section>
<!-- The views that answer per charger rather than per account. Each
says what it knows, or why it could not be read — an account
that is not the charger's owner cannot read the cards, which is
a fact about the account rather than a failure. -->
<template v-for="view in chargerDetailViews" :key="view.id">
<p class="eyebrow mt-3">{{ t(`charging.info.views.${view.id}`) }}</p>
<p v-if="view.error" class="mt-1 text-[11px] text-muted">{{ view.error }}</p>
<dl v-else class="mt-1 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<section v-for="view in chargerDetailViews" :key="view.id" class="rounded-control bg-sunken p-3">
<h4 class="eyebrow">{{ t(`charging.info.views.${view.id}`) }}</h4>
<p v-if="view.error" class="mt-2 text-[11px] text-muted">{{ view.error }}</p>
<dl v-else class="mt-2 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<template v-for="row in view.rows" :key="row.key">
<dt class="data break-all text-[11px] text-muted">{{ row.key }}</dt>
<dd class="data break-all text-[11px] text-body">{{ row.value }}</dd>
</template>
</dl>
</template>
</section>
</div>
<p v-if="homeChargers.length === 0" class="mt-2 text-xs text-muted">