Four cards is three too many to scroll past

The charging column grew a card at a time and now runs well past a screen, so
each of the four folds away: connection, control, readings, information. The
mechanism is the provider panel's, down to the chevron and the localStorage
key — folding is a reading habit of this browser, not an account setting, and
one learned gesture should work in both places.

The connected badge stays in the connection header while it is folded, since
whether the charger is reachable is the thing worth seeing without opening
anything. Charger information keeps its Refresh button beside the toggle
rather than inside it, a button being no place for another button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-01 21:04:55 +02:00
co-authored by Claude Opus 5
parent bcc44da735
commit b48019b08f
+99 -7
View File
@@ -57,6 +57,41 @@ const sessionMetrics = computed(() =>
// --- Real OCPP control (Anker Solix), gated by the per-user control mode ---
// The demo session card above is presentational; this card drives a real charger
// via the control endpoints when the user has picked Own/Proxy CSMS in Settings.
// --- Folding the cards ---
//
// The column runs long once the readings are in it, so every card folds away.
// Which ones are folded is kept in localStorage rather than on the profile, the
// same way the provider panel keeps its own: it is a per-device reading habit,
// not an account setting, and it should survive leaving the tab. Keyed by card,
// so a fold outlives switching charger.
const COLLAPSED_KEY = "dv_charging_collapsed";
const collapsed = ref(readCollapsed());
function readCollapsed() {
try {
const raw = JSON.parse(localStorage.getItem(COLLAPSED_KEY) || "[]");
return Array.isArray(raw) ? raw.filter((id) => typeof id === "string") : [];
} catch {
return []; // unreadable (hand-edited, or written by an older version)
}
}
function isOpen(id) {
return !collapsed.value.includes(id);
}
function toggleCard(id) {
collapsed.value = isOpen(id)
? [...collapsed.value, id]
: collapsed.value.filter((k) => k !== id);
try {
localStorage.setItem(COLLAPSED_KEY, JSON.stringify(collapsed.value));
} catch {
// A full or blocked store just means the choice lasts this visit only.
}
}
const ctlMode = ref("off");
const ctlSerial = ref(localStorage.getItem("dv_ctl_serial") || "");
const ctl = ref(null); // { connected, status, controlMode, ... }
@@ -728,12 +763,27 @@ onMounted(async () => {
<!-- Real OCPP control (Own/Proxy CSMS) the real home-charger control -->
<div class="flex flex-col gap-4">
<div v-if="ctlActive" class="dh-card p-4">
<div class="flex items-center justify-between">
<button
type="button"
class="flex w-full items-center justify-between gap-3 text-left"
:aria-expanded="isOpen('connection')"
@click="toggleCard('connection')"
>
<p class="text-sm font-semibold text-strong">{{ t("charging.control.connectionTitle") }}</p>
<span class="dh-badge" :class="ctlConnected ? 'dh-badge-success' : 'dh-badge-warning'">
{{ ctlConnected ? t("charging.control.connected") : t("charging.control.disconnected") }}
<!-- Whether the charger is reachable is the one thing worth seeing
with the card folded, so the badge rides in the header. -->
<span class="flex shrink-0 items-center gap-2">
<span class="dh-badge" :class="ctlConnected ? 'dh-badge-success' : 'dh-badge-warning'">
{{ ctlConnected ? t("charging.control.connected") : t("charging.control.disconnected") }}
</span>
<svg
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
class="h-4 w-4 shrink-0 text-muted transition-transform" :class="isOpen('connection') ? '' : '-rotate-90'"
><path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" /></svg>
</span>
</div>
</button>
<div v-show="isOpen('connection')">
<div class="mt-3 flex gap-2">
<select v-if="pickingFromList" v-model="ctlSerial" class="dh-input" @change="refreshCtl">
@@ -809,12 +859,26 @@ onMounted(async () => {
{{ ctl?.detail || t(ctlIsModbus ? "charging.control.modbusHint" : "charging.control.connectHint") }}
</p>
<p v-if="ctlError" class="mt-2 text-sm text-danger">{{ ctlError }}</p>
</div>
</div>
<!-- Acting on the charger, once there is a connection to act over. The
card above is where that connection is set up. -->
<div v-if="ctlActive && ctlConnected" class="dh-card p-4">
<p class="text-sm font-semibold text-strong">{{ t("charging.control.title") }}</p>
<button
type="button"
class="flex w-full items-center justify-between gap-3 text-left"
:aria-expanded="isOpen('control')"
@click="toggleCard('control')"
>
<p class="text-sm font-semibold text-strong">{{ t("charging.control.title") }}</p>
<svg
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
class="h-4 w-4 shrink-0 text-muted transition-transform" :class="isOpen('control') ? '' : '-rotate-90'"
><path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" /></svg>
</button>
<div v-show="isOpen('control')">
<div class="mt-3 grid grid-cols-2 gap-2">
<div class="rounded-control bg-sunken px-3 py-2">
@@ -896,13 +960,27 @@ onMounted(async () => {
</button>
</div>
</div>
</div>
</div>
<!-- What the charger reports over Modbus. Its own card rather than a
tail on the control one: control is for acting on the charger, and
this is a long read that pushed the buttons off the screen. -->
<div v-if="ctlIsModbus && ctlConnected" class="dh-card p-4">
<p class="text-sm font-semibold text-strong">{{ t("charging.modbus.title") }}</p>
<button
type="button"
class="flex w-full items-center justify-between gap-3 text-left"
:aria-expanded="isOpen('readings')"
@click="toggleCard('readings')"
>
<p class="text-sm font-semibold text-strong">{{ t("charging.modbus.title") }}</p>
<svg
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
class="h-4 w-4 shrink-0 text-muted transition-transform" :class="isOpen('readings') ? '' : '-rotate-90'"
><path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" /></svg>
</button>
<div v-show="isOpen('readings')">
<div class="mt-3 flex flex-col gap-2">
<section v-if="modbusPhases.length" class="rounded-control bg-sunken p-3">
<h4 class="eyebrow">{{ t("charging.modbus.phases") }}</h4>
@@ -977,6 +1055,7 @@ onMounted(async () => {
<p class="mt-1 text-[11px] text-muted">{{ t("charging.modbus.alarmsHint") }}</p>
</section>
</div>
</div>
</div>
<!-- Charger information: everything the record holds about each imported
@@ -985,7 +1064,18 @@ onMounted(async () => {
card is what fills the column instead of a bare hint. -->
<div class="dh-card p-4">
<div class="flex items-center justify-between gap-2">
<p class="text-sm font-semibold text-strong">{{ t("charging.info.title") }}</p>
<button
type="button"
class="flex grow items-center justify-between gap-3 text-left"
:aria-expanded="isOpen('info')"
@click="toggleCard('info')"
>
<p class="text-sm font-semibold text-strong">{{ t("charging.info.title") }}</p>
<svg
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
class="h-4 w-4 shrink-0 text-muted transition-transform" :class="isOpen('info') ? '' : '-rotate-90'"
><path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" /></svg>
</button>
<button
v-if="homeChargers.length"
type="button"
@@ -997,6 +1087,7 @@ onMounted(async () => {
</button>
</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">
<div class="flex items-center justify-between gap-2">
@@ -1029,6 +1120,7 @@ onMounted(async () => {
<!-- Control mode off: say why the card above is missing, here, where
there is now something to read it against. -->
<p v-if="!ctlActive" class="mt-3 text-xs text-muted">{{ t("charging.stations.noControlHint") }}</p>
</div>
</div>
</div>