diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index 61ca94e..68aa14b 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -283,6 +283,28 @@ "bleId": "Bluetooth-id", "blePassword": "Bluetooth-parringskode", "ownerId": "Ejer-id", + "fields": { + "sessions": "Sessioner", + "chargeTime": "Opladningstid", + "energy": "Opladet energi", + "co2Saved": "CO2 sparet", + "cost": "Omkostning", + "costSaved": "Sparet beløb", + "currency": "Valuta", + "mileage": "Kilometertal", + "page": "Side", + "perPage": "Pr. side", + "records": "Poster", + "from": "Fra", + "source": "Kilde", + "timeZone": "Tidszone", + "updated": "Opdateret", + "added": "Tilføjet", + "address": "Adresse", + "name": "Navn", + "cardName": "Kortnavn", + "cardNumber": "Kortnummer" + }, "groups": { "device": "Enhed", "status": "Status", diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index 8cf4e3b..74425c9 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -269,6 +269,28 @@ "bleId": "Bluetooth id", "blePassword": "Bluetooth pairing code", "ownerId": "Owner id", + "fields": { + "sessions": "Sessions", + "chargeTime": "Time charging", + "energy": "Energy charged", + "co2Saved": "CO2 saved", + "cost": "Cost", + "costSaved": "Cost saved", + "currency": "Currency", + "mileage": "Mileage", + "page": "Page", + "perPage": "Per page", + "records": "Records", + "from": "From", + "source": "Source", + "timeZone": "Time zone", + "updated": "Updated", + "added": "Added", + "address": "Address", + "name": "Name", + "cardName": "Card name", + "cardNumber": "Card number" + }, "groups": { "device": "Device", "status": "Status", diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index fe0fdc1..63619e3 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -285,6 +285,28 @@ "bleId": "Identyfikator Bluetooth", "blePassword": "Kod parowania Bluetooth", "ownerId": "Identyfikator właściciela", + "fields": { + "sessions": "Sesje", + "chargeTime": "Czas ładowania", + "energy": "Naładowana energia", + "co2Saved": "Oszczędność CO2", + "cost": "Koszt", + "costSaved": "Oszczędność kosztów", + "currency": "Waluta", + "mileage": "Przebieg", + "page": "Strona", + "perPage": "Na stronę", + "records": "Rekordy", + "from": "Od", + "source": "Źródło", + "timeZone": "Strefa czasowa", + "updated": "Zaktualizowano", + "added": "Dodano", + "address": "Adres", + "name": "Nazwa", + "cardName": "Nazwa karty", + "cardNumber": "Numer karty" + }, "groups": { "device": "Urządzenie", "status": "Status", diff --git a/Web App/web/src/views/Charging.vue b/Web App/web/src/views/Charging.vue index d653aea..90cc686 100644 --- a/Web App/web/src/views/Charging.vue +++ b/Web App/web/src/views/Charging.vue @@ -1018,6 +1018,99 @@ function chargerInfoGroups(c) { })); } +// The keys the views below answer with, in the card's words. Anker documents +// none of these payloads either, but a field like page_size or create_time says +// what it is once its key is read out, and those are named here rather than left +// as keys. Keyed by the field with any list index taken out, so list[0].name and +// list[3].name are one field asked about two records. Anything absent from this +// table keeps its own key, for the same reason the box above does: a name +// invented here would be a meaning invented here. +const VIEW_FIELDS = { + // What the account has counted for this charger. + "total_stats.charge_count": "sessions", + "total_stats.charge_time": "chargeTime", + "total_stats.charge_total": "energy", + "total_stats.co2_saving": "co2Saved", + "total_stats.cost": "cost", + "total_stats.cost_saving": "costSaved", + "total_stats.cost_unit": "currency", + "total_stats.mile_age": "mileage", + // How much of a list the view answered with — a page of a history that is + // empty is still the answer "there is nothing to page through". + page: "page", + page_num: "page", + page_size: "perPage", + total: "records", + total_count: "records", + start_use_time: { label: "from", time: true }, + // The backend the charger is pointed at, and when it last said so. + source: "source", + time_zone: "timeZone", + timestamp: { label: "updated", time: true }, + create_time: { label: "added", time: true }, + // The records a list view answers with: one OCPP endpoint, one RFID card. + "list[].address": "address", + "list[].name": "name", + "list[].source": "source", + "list[].alias_name": "cardName", + "list[].card_number": "cardNumber", + "list[].create_time": { label: "added", time: true }, +}; + +// list[0].card_number split into the list, which record, and which field. +const VIEW_LIST_KEY = /^([A-Za-z0-9_.]+)\[(\d+)\]\.(.+)$/; + +// A unix second the cloud sent as a bare number, read as a date. Zero is how +// these views say "never" rather than 1970, so it is left as it arrived. +function viewTimeValue(v) { + const n = Number(v); + if (!Number.isFinite(n) || n <= 0) return v; + return formatDateTime(new Date(n * 1000)); +} + +// One field of one view: named when the table above has a name for it, and +// keyed by the cloud's own key when it does not. The key is kept either way — +// it is what the row is drawn under and what makes it unique. +function viewRow(key, lookup, value) { + const spec = VIEW_FIELDS[lookup]; + const label = typeof spec === "string" ? spec : spec?.label; + return { + key, + label: label ? t(`charging.info.fields.${label}`) : key, + named: !!label, + value: typeof spec === "object" && spec?.time ? viewTimeValue(value) : value, + }; +} + +// One view's fields, with the lists it answered with grouped a record at a time. +// list[0].* and list[1].* are two RFID cards, two endpoints, two anything: read +// as one alphabetical run of indexed keys they are unreadable, and as a block +// each — under the record's own name, when it has one — they are the list the +// view actually sent. The record's name becomes the heading rather than a row, +// so it is said once. +function viewRowGroups(attrs) { + const rows = []; + const items = new Map(); + for (const key of Object.keys(attrs).sort()) { + const m = VIEW_LIST_KEY.exec(key); + if (!m) { + rows.push(viewRow(key, key, attrs[key])); + continue; + } + const [, list, index, field] = m; + const id = `${list}[${index}]`; + if (!items.has(id)) { + const label = attrs[`${id}.alias_name`] || attrs[`${id}.name`] || `#${Number(index) + 1}`; + const titleKey = attrs[`${id}.alias_name`] ? `${id}.alias_name` : attrs[`${id}.name`] ? `${id}.name` : ""; + items.set(id, { key: id, index: Number(index), label, titleKey, rows: [] }); + } + const item = items.get(id); + if (key === item.titleKey) continue; + item.rows.push(viewRow(key, `${list}[].${field}`, attrs[key])); + } + return { rows, items: [...items.values()].sort((a, b) => a.index - b.index) }; +} + // --- The account's other views of one charger --- // // Four endpoints answer only when a serial is named — the station record, the @@ -1063,9 +1156,7 @@ const chargerDetailViews = computed(() => { id: view.id, error: view.error || "", note: view.note || "", - rows: Object.keys(view.attrs || {}) - .sort() - .map((key) => ({ key, value: view.attrs[key] })), + ...viewRowGroups(view.attrs || {}), })); }); @@ -2062,15 +2153,32 @@ onMounted(async () => {

{{ t(`charging.info.views.${view.id}`) }}

{{ view.error }}

-

+

{{ t("charging.info.viewEmpty") }}

-
- -
+
+ +
+ +
+ +
+

{{ item.label }}

+
+ +
+
+

{{ view.note }}