Commit Graph
100 Commits
Author SHA1 Message Date
tajniak81andClaude Opus 5 1418a566fd The charging tabs drag, like a car's do
Reordering the bar meant editing the template, which is a poor way to ask for
Home chargers first. The tabs now drag into either order on the same native
drag events as a car's tabs and the garage, down to the live reorder as the
pointer crosses a tab, the grab cursor, and the rail's lock holding the bar
still for anyone who would rather not nudge it on the way to a tab.

The arrangement is saved on the profile as charger_tab_order, beside the garage
order and for the same reason: it is a layout choice that should follow the
account rather than the browser, unlike which cards are folded. The field is
reconciled onto the users collection at boot, so no migration step. Its
normalizer is the garage's, which now takes the field name and cap as
arguments instead of being copied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 21:10:52 +02:00
tajniak81andClaude Opus 5 2a5d21d328 Controls first, because that is what the page is opened for
The column opened with the charger picker and its address — the two things
touched once and then never again — and put the start button below them. Order
reversed: control, then connection, then readings, then information.

The comments on both cards described the old order, so they move with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 21:06:02 +02:00
tajniak81andClaude Opus 5 b48019b08f 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>
2026-09-01 21:04:55 +02:00
tajniak81andClaude Opus 5 bcc44da735 Setting up the connection is not the same as using it
The control card opened with the parts that get touched once — which charger,
and where it lives on the network — and only then reached what gets used every
day. Split in two: a connection card holding the picker, the address and the
connected badge, then a control card holding the tiles and the buttons.

The not-connected hint and the error line move up with the connection, since
that is what they are about, and the control card simply does not appear until
there is a connection to control over. That also retires the inner template
that repeated the card's own condition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 20:12:05 +02:00
tajniak81andClaude Opus 5 5547daa689 The readings get their own card
The register data was appended to the control card, which turned a card for
acting on the charger into a long scroll with the buttons at the top of it.
It is a separate concern and now a separate card: control above, readings
below, alongside the charger information card that was already there.

Its title says readings rather than information, since the card below it holds
what the record knows about the charger while this one holds what the charger
itself just said.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 20:10:13 +02:00
tajniak81andClaude Opus 5 05f8bd07ed The register map, kept locally without keeping it in the repo
The Anker Modbus reference page is published as an artifact and lives in the
project root as a working copy. It is generated rather than authored, and the
published page is the one that gets updated, so tracking the local copy would
only invite the two to drift apart in review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:49:39 +02:00
tajniak81andClaude Opus 5 62cb691f98 Everything the register map carries, sorted the way it gets asked about
The Modbus snapshot reported about half of what one poll already brings back.
The rest was read into the block and thrown away: line-to-line voltages,
reactive and apparent power per phase, the PWM flag, the control-pilot voltage,
and the identity block's product number, rated power and current range. All of
it now decodes — no extra requests, the registers were in hand already.

Added alongside it: the control block, read back over FC03. It answers a
question the live registers cannot, which is what the charger is *set* to as
opposed to what it is doing — a boost that was asked for reads there while the
live block still reports none running. Best effort, so a charger that refuses
it still reports its state.

Two registers the spec leaves blank are decoded on the hardware's evidence. The
control-pilot voltage reads 11873 while the CP signal register reports state A,
which that enum names as 12 V, so the register is millivolts. The identity
block's current range is in amps, whatever its unit column says about watts and
kVA.

The charging card lays this out in sections rather than a wall of forty numbers:
per-phase measurements as the matrix they are, then live state, then settings,
then the device itself, with alarms surfacing only when a word is non-zero.
Strings in en/da/pl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:37:22 +02:00
tajniak81andClaude Opus 5 aaa89dfe10 Relays that run at 33 degrees, not 331
The two relay temperatures came back as 331 and 319 from a charger sitting idle
with nothing plugged in. The spec's gain column says 1 for both, so we reported
them as 331 °C and 319 °C — a reading that would have meant a fire rather than a
wallbox at room temperature.

The gain is 10. The same table hands the maximum current setting a unit of watts
and the timeout a unit of amps, so its unit and gain columns are not load-bearing
here; what settles the alignment is the LED brightness two registers earlier,
which reads exactly 100 at gain 1, and the fact that the neighbouring registers
all decode as tabulated. Read back from the charger afterwards: 33.1 °C and
31.9 °C.

The field becomes a float, as the voltages and currents beside it already are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:01:29 +02:00
tajniak81andClaude Opus 5 cf4fd14b56 The table the charger actually keeps its measurements in
Modbus mode never returned a reading: every status poll came back as "the
charger did not answer", though the charger was answering all along. It was
refusing the question. The A5191 splits its map across two tables where the
spec's single 2xxxx column suggests one — 20000-20100 are input registers and
reject FC03 with an illegal-address exception at every address in the range,
while 21000-21005 really are holding registers and read back over FC03. We
inferred one space from the spec's layout and asked for all of it with FC03.

The client learns FC04, sharing a body with FC03 since the two differ only in
which table the server consults, and the plugin's two measurement reads move to
it. Writes stay on FC06, where the controls already live.

Confirmed against an A5191 on firmware 1.0.6.1: identity, live block and the
control registers all decode as the spec tabulates them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:42:39 +02:00
tajniak81andClaude Opus 5 f025dc100f The local mode, offered by the menu that picks the mode
Modbus TCP has been a working control path since the server learned to dial
the charger, and the Charging page has asked for the address it needs — but
the Control mode selector never listed it. The mode could be reached only by
writing it through the API, which is to say not at all.

Both selectors now offer it, and both stop offering what it does not use: the
OCPP provisioning card and its "Use this one" button are gated on the modes
where the charger dials us, and Modbus gets a line saying where its address is
asked for instead. On the phone that gate is more than tidiness — the field is
a DropdownButtonFormField, so a mode saved from the web left it holding a value
none of its items matched.

Strings added in en/da/pl for both apps. The mode hint no longer says control
happens "over OCPP", because it no longer always does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:26:21 +02:00
tajniak81andClaude Opus 5 1aedc0ddc7 A serial the account does not list is still a serial worth showing
The dropdown could only show a serial it had an option for. A remembered one the
account does not report — a charger imported before the account was linked, one
the cloud is quiet about today — selected nothing, so the field sat blank while
that serial was the one every command went to. Nothing on screen said which
charger was being driven, or let it be corrected.

The field now falls back to the text box in that case, the way it already does
when the account lists no chargers at all, and shows the serial actually in
force. One link switches between picking and typing, so a serial off the list is
not a dead end and the list is not the only way in; coming back to it lands on a
charger the list holds rather than blanking the dropdown again.

Which of the two is showing is decided when the list arrives, not on every
keystroke — recomputing it as the serial is typed would turn the text box into a
dropdown mid-word, the moment what had been typed happened to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:54:54 +02:00
tajniak81andClaude Opus 5 7ccd785742 The charger's address, asked for where control is set up
Modbus mode had no way in from the app: the mode could be picked in Settings,
but the address the server dials had to be PUT by hand. The control card now
asks for it in the place the charger is already being controlled from.

Which provisioning the card shows follows the mode, because the two are not
alternatives to each other — OCPP installs a token into the charger, Modbus
records where the charger is. So does what the card offers: boost is a Modbus
command and appears there, clear-limit and reset are OCPP ones the register map
has no equivalent for and stay behind.

The status tiles read whichever snapshot arrived. An OCPP session counts a meter
in Wh and names a connector state; a Modbus snapshot counts the session's own
energy and names the charger's. Both land in the same two tiles.

When nothing answers, the server has already tried the address and says what it
found, so the card shows that rather than a hint written in advance.

The hint naming Own and Proxy CSMS as the way to control a charger was true
until there was a third mode; it now names all three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:27:06 +02:00
tajniak81andClaude Opus 5 f7472bada3 Reach the charger where it is, instead of waiting for it to call
OCPP asks the charger to dial us: a public endpoint, a TLS certificate, and a
route in through the customer's router. Our own handler then demanded two more
things the V1 does not offer — TLS on a charger that connects over ws://, and
Basic auth credentials the Anker app has no field for — so every connection was
turned away before the upgrade.

Anker publishes a Modbus TCP register map for this charger, and it inverts the
problem: we dial the charger, on its own network, with no inbound reachability
to arrange. That works for a charger behind a router that OCPP cannot reach at
all.

internal/modbus is the protocol, hand-rolled against the spec like the MQTT and
WebSocket clients beside it. The plugin's modbus.go is the V1's map: the same
0-8 status enum the cloud already reports, per-phase measurements, and the
writable registers behind start, stop, current limit, boost and phase mode. A
new "modbus" control mode routes the existing control endpoints down it, so the
REST surface, the rate limit, the confirmation step and the audit trail are the
ones already there.

The commands the register map has no equivalent for say so by name rather than
failing as unknown, and a current below the charger's 6 A floor is refused
because it pauses the charge rather than slowing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:03:27 +02:00
tajniak81andClaude Opus 5 e9a82a1cca One hostname a charger can be told about and actually reach
Charger control needs TLS, and the stack speaks plain HTTP, so the README
said "terminate TLS in a reverse proxy" and left the operator to work out
which four settings have to agree. This adds the proxy: a Caddy overlay
that fronts the Web App BFF — which already carries /api/ and /ocpp/ — so
browsers and chargers arrive at the same name and the certificate is issued
on first boot.

The four settings are derived from DV_DOMAIN, since one value getting typed
right is better odds than four: OCPP_PUBLIC_URL, OCPP_REQUIRE_TLS back on,
CORS, and TRUST_FORWARDED_PROTO on the Web App. That last one is the
non-obvious one — without it the BFF overwrites Caddy's X-Forwarded-Proto
with its own plaintext hop and the API Server rejects the charger it just
told to connect over wss.

The README's OCPP section was stale besides: it still sent chargers to the
API Server port alone, from before the BFF carried that path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 15:36:56 +02:00
tajniak81andClaude Opus 5 9f5c8dc49a The address chargers are given is now an address that answers
The API Server tells a charger to dial the host it was itself asked on.
The panel asks through the Web App, so the address handed out is the Web
App's — which proxied /api/ and nothing else, and answered the WebSocket
handshake at /ocpp/ with index.html. A charger pointed at the endpoint the
screen showed could never connect to it, and the screen went on saying
"Not connected" without a hint as to why.

Both front doors now carry /ocpp/ through to the API Server: the BFF via
the same reverse proxy, which relays the 101 by hijacking, and the
all-in-one image's nginx via a location of its own, with timeouts long
enough for a session that is idle between heartbeats.

The proxied hop also has to say how the client arrived, since the API
Server reads X-Forwarded-Proto to decide a charger reached it over TLS.
That header is set from this server's own connection and overwrites
whatever came in: believing a client on that point would let a plaintext
charger claim wss and walk past OCPP_REQUIRE_TLS. TRUST_FORWARDED_PROTO
opts into the inbound value for the one deployment where it is true — TLS
ending at a proxy in front of the stack.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 14:31:24 +02:00
tajniak81andClaude Opus 5 019c28db85 The card is about the charger you picked, not all of them
Charger information stacked every imported charger, so the list beside it
selected one and the card ignored the choice — with two chargers the page
said everything twice and the picking meant nothing. It now shows the
selected one alone, and falls back to the first until something is picked:
a card that stays blank until clicked is a worse greeting than the charger
most people have only one of.

The per-block ring goes with it. Highlighting the selection inside a card
that shows nothing else is a distinction without a second thing to draw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 13:01:09 +02:00
tajniak81andClaude Opus 5 9686cae8b9 The charger row asks the same way everything else does
Removing a home charger kept its own inline prompt, written before there
was an app-wide one. Two mechanisms for one question is one too many: it
now calls askConfirm() like every other destructive action, and the panel,
its pending-removal state and the ask/cancel pair go with it. The button
still disables while the delete is in flight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 12:09:59 +02:00
tajniak81andClaude Opus 5 c9ffb9c698 One prompt for the whole app, asked where the browser cannot refuse it
Fifteen destructive actions were still gated behind window.confirm(), the
same call that made removing a home charger look broken: a browser that
suppresses native dialogs never shows it and returns false, so deleting a
service, a part, a user or an organization would quietly not happen and say
nothing about why.

askConfirm() puts the question in the page and resolves to what was
actually pressed, so each call site changed by one line and reads the way
it did before. ConfirmDialog is mounted once in App.vue and draws over
everything, including a modal — removing a server is asked from inside one,
which is what Modal's new zIndex is for.

The home charger keeps its own inline prompt: that one belongs to its row
rather than to the middle of the screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 12:02:38 +02:00
tajniak81andClaude Opus 5 749f42f481 Remove asks in the page, not in a dialog the browser may refuse to show
Removing a charger was gated behind window.confirm(). A browser that
suppresses native dialogs — an embedded webview, a blocked-dialogs setting
— does not show it and hands back false, so the click answered "no" on the
user's behalf: no request, no error, nothing. The button looked broken and
the endpoint was never reached. It always had been fine; a delete with an
unknown id still answers 404 and the ownership path still resolves.

The prompt is part of the row now — the warning, Cancel, Remove — the way
the charger reset in the same view already asks. Remove disables while the
delete is in flight, and a failure lands in the error line the list
already has.

Fifteen other confirm() call sites share the flaw and are left for their
own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 11:56:04 +02:00
tajniak81andClaude Opus 5 21a54c4cfa The bolt in the list takes the colour the badge already had
Every charger in "Your chargers" drew its bolt in the same green, state or
no state — the list's one visual signal said nothing. It now takes the
green and amber the information card's badges use, off the same live map,
so the list answers "which one is up?" without opening anything.

A charger the service is silent about draws muted rather than green: a
green bolt for an unknown charger is a claim. The icon carries a title as
well, so the state is not left to colour alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 11:52:04 +02:00
tajniak81andClaude Opus 5 7cbc81778c Every row, every time, dashes included
The information card drew a row only when it had a value, so two chargers
side by side had two different shapes and neither could be read against the
other — and a field the service is silent about looked the same as a field
the card never offers. Every row is drawn now, with a dash where there is
nothing to say, which is itself worth seeing.

Service id loses the rule that hid it when it matched the serial. Kept, it
would have printed a dash for a charger that does have one, and a dash that
means "no" where the answer is "the same as above" is worse than the
repetition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 11:47:23 +02:00
tajniak81andClaude Opus 5 ad785ee9f8 The fields the cloud sends, kept all the way to the card
Normalizing a provider's charger list threw most of the answer away:
firmware, the site id, how the charger is registered, the charge power and
the cloud's own OCPP reading all arrived from Anker and none of them got
past providerCharger, which carried seven fields and dropped the rest. The
information card could not show what it was never handed.

It carries them now, and the card lays them out: firmware beside the model,
site and site id where the charger lives, "Registered as" for standalone /
in a system / bound, and — when the service knows — state, charge power and
OCPP status. Charge power is relayed exactly as worded upstream, since the
unit is theirs and putting one on it here would be inventing it. Settings'
own list gains the firmware in its subtitle. A field no view supplied still
leaves no row, so an account whose chargers stand outside a system reads
shorter rather than emptier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 11:43:23 +02:00
tajniak81andClaude Opus 5 86ea97b414 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>
2026-09-01 11:22:02 +02:00
tajniak81andClaude Opus 5 4203ca93db What a charger is, readable without a CSMS to control it with
The Home chargers tab put a card where the control panel goes and, with
neither Own nor Proxy CSMS turned on, filled it with a single sentence
pointing at Settings — an empty box beside a list of two real chargers the
app already knew the vendor, model and serial of.

That knowledge now has somewhere to be. Charger information lists every
imported charger with the fields its record actually holds; a field the
service never sent is left out rather than shown as a dash, and the
provider's own id appears only when it is something other than the serial.
The selected charger carries the same accent ring the list beside it does,
so picking one still reads as picking the one control drives. The card
stands on its own either way: with control on it sits beneath it, and with
control off the Settings hint becomes a footnote under something worth
reading instead of the whole card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 11:12:17 +02:00
tajniak81andClaude Opus 5 d71c1b4691 The panel header stops folding "Sign out" onto two lines
The console shell was capped at max-w-4xl (896px), narrow enough that the
header row ran out of room and the last button wrapped mid-word. It now
uses the same 1368px cap as the Web App shell, so the header has the space
it always assumed it had. Rebuilt the embedded dist to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 10:47:10 +02:00
tajniak81andClaude Opus 5 190ae923a6 The Anker token outlives the request that fetched it
The manager builds a throwaway plugin instance for every per-user call —
HealthCheckWith, InvokeWith, InvokeBatchWith each construct, Init, probe and
Shutdown. The auth token lived on that instance, so it died with the HTTP request
that fetched it: opening the Anker panel signed in once for the health probe and
again for the charger list, and a page that also asked for OCPP info signed in a
third time. Every refresh, a fresh login.

Anker throttles passport/login per IP per minute and answers code 26161 ("Failed
to request.") once tripped, so this is the shape of the failure the panel has been
reporting; the cloud has also historically kept one token per account, so each of
those logins could evict the one the mobile app was holding.

Tokens and the login backoff now live in a package-level session keyed by the
account signing in, so every instance configured for that account shares one
login. Re-configuring the same credentials keeps the token; a different account,
or the same account on the other regional server, gets its own session. Sessions
unused for a fortnight are pruned, so an edited password does not leave its entry
behind for the life of the process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 23:11:52 +02:00
tajniak81andClaude Opus 5 c1aee0fac1 One refused sign-in, not five: the chargers poll no longer locks the account
A chargers poll asks four cloud views. Each called apiRequest, each found no
token, and each ran its own login — so a login Anker refuses was offered four
times in one poll, and the next poll spent the fifth. Five is what disables the
account for ten minutes, which is how "code 26161: Failed to request." turned
into "your account has been disabled" on the very next attempt.

The plugin now remembers a refused login instead of repeating it: the failure is
cached and replayed to every caller until a backoff window passes — a minute at
first, doubling to fifteen, or the full ten minutes when Anker says it has
already locked the account (code 10019). New credentials clear it, so a fixed
password is tried at once.

chargerInventory signs in once up front. A login the cloud refuses is not four
views failing, so it is reported as itself rather than as three warnings with the
lockout notice buried in the last one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 22:54:24 +02:00
tajniak81andClaude Opus 5 423bc2ab16 The charger list sorted by a field the collection never had
Opening Home chargers answered {"status":400,"message":"Something went wrong
while processing your request."} — PocketBase's generic refusal, here for an
unknown sort field. home_chargers declares its own fields and nothing else:
PocketBase adds no created field to a collection defined through the API, which
is exactly why control_audit and organizations declare theirs. The list handler
sorted by created anyway. It was the only handler in the server that sorts by
created — every other one sorts by name, km or date, fields their collections
actually declare — so the gap had never had a chance to show.

The field is now declared, and reconcile adds it to the collection already
standing on the next boot, since home_chargers is in reconcileOrder. Import order
is the only order a charger has: it carries no date of its own, and a wallbox
bolted to a wall does not accumulate events the way a car does.

The list also stops depending on that. A rejected sort now falls back to the
unsorted query rather than failing the request: the order is a nicety, the list
is not, and an owner reading a database error about a field they cannot see is
the worst of both. It also makes the deploy order stop mattering — the page works
before the bootstrap has run, and the sorted query wins once it has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:34:48 +02:00
tajniak81andClaude Opus 5 06f91578da Integrations, sorted into the categories the panel already sorts them into
The API Server panel has grouped plugins by category since the plugin list was
drawn — car manufacturers, EV chargers, notifications, each tab carrying its
count. The Settings page had the same integrations in one flat stack, so the two
screens described the same set of things in two different shapes. Now they agree:
the same categories, the same counted tabs, the same accent border on the active
one. The category ids come from the Category* constants both sides already read,
so nothing here invents a third vocabulary.

Only a category holding something gets a tab, which is the panel's rule too: two
tabs today rather than six empty ones. Below one category the bar hides itself
entirely — with nothing to switch between, a single tab is a label pretending to
be a control.

The active group falls back to the first rather than to a fixed id, because the
three integration views load independently and a tab can be momentarily empty on
first paint; falling back keeps the section populated instead of blanking it.
The cards moved into a space-y-4 wrapper and lost their per-card mt-4, so the
first card sits the same distance under the bar whichever tab is showing —
previously Toyota was the only one that could ever be first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:20:08 +02:00
tajniak81andClaude Opus 5 a3f69fa5ef Home chargers: your own wallbox as a record, imported the way a car is
The Home chargers tab has been showing a hardcoded "Home charger · 11 kW · NACS"
since it was drawn, and the control card asked for a serial as free text — a
number printed on a box hanging in a garage, typed in by hand while the connected
account already knew it. The garage solved the same problem for cars a while ago,
so this is that solution aimed at the wall: pick the charger off a service you
have connected, press Import, and it becomes a record of yours.

A charger is a record rather than a live listing because it has to outlive the
account it came from. Disconnect Anker and the wallbox is still on the wall; the
integration is how the charger was found, not what it is. Hence home_chargers,
owned by a person and not related to any car — it charges whichever car is
plugged into it, and it outlives all of them — and hence no sharing: a charger is
one household's business in a way a car shared with a partner is not.

The provider layer is vehicleproviders.go's shape on purpose, down to the soft
gate: a listing answers 200 with an empty list and the sentence that says what to
do about a closed gate, a write answers 400, because there the caller asked for
something that did not happen. Anker and Greencell are two adapters over plugins
that already exist, so the next charger service is an adapter appended to
chargerSources() and nothing else. What is deliberately absent is the car
import's checkbox panel: a charger is a name, a serial and the hardware behind
it, all of which the list already carries, so there is nothing to choose and the
whole screen is pick one, press Import.

Only the name is editable afterwards. The rest describes hardware and came from
the service, and the provider link is written by the import endpoint alone, so
renaming a charger cannot quietly orphan it from the account it tracks. Deleting
one says as much in its confirmation: the charger is untouched, and importing it
again brings the record straight back.

The Anker gate moved into ankerGate() beside greencellGate(), because the same
four-case switch was about to exist in a third place. Behaviour is unchanged —
the same sentences, and the probe still skips the personal opt-in, since checking
credentials is what you do before switching the integration on.

The phone app still has the old tab; parity there is a separate change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:07:21 +02:00
tajniak81andClaude Opus 5 a809980d8b Anker health: count the chargers the panel lists, not the ones one endpoint admits to
The probe still asked get_user_bind_and_not_in_station_evchargers and read its
userBindEvChargersCount, so it reported "0 EV charger(s) bound to account" for an
account whose two chargers the panel was listing directly underneath — the same
blind spot the capability was just moved off, left behind in the health check. It
now takes the same inventory the chargers capability returns and counts that.

Authenticated with nothing on the account is degraded rather than ok, following
Greencell's rule: the half we address answers, and the empty half is the account
or the country that picks the regional server, so the message says so instead of
reporting a healthy connection to nothing. A count reached with some view missing
says how many views stayed silent, because the number is then a floor rather than
a total. The web panel colours degraded amber, as it already did for Greencell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 20:46:02 +02:00
tajniak81andClaude Opus 5 e138fad3f4 Anker: every charger on the account, not just the ones outside a station
get_user_bind_and_not_in_station_evchargers is the only list the connector ever
asked for, and its name says exactly what it withholds. A charger that belongs to
a system is not in it. Its userBindEvChargersCount, though, counts every charger
bound to the account — so an owner with two chargers in a system got "authenticated;
2 EV charger(s) bound to account" from the health probe and an empty list from the
capability that is supposed to show them. A working login that finds nothing.

So the capability now asks every view the cloud has and merges them by serial. The
standalone list still answers for chargers standing on their own; get_site_list
walks the systems and reads each one through get_scen_info, falling back to
get_system_running_info where that is silent — the power-service / HES split
charger-state already knows; and get_relate_and_bind_devices contributes model,
firmware and the Wi-Fi flag, and discovers anything in the A519 family that the
first two missed. Whichever way a charger was registered, one of the three has it.

The merge is first-writer-wins per field rather than last view overwriting: the
standalone record knows the name, the site record knows the live state, and neither
should blank what the other established. A view that fails is a warning on the
document instead of an error on the call, because one dead endpoint should not
cost the chargers the other two found. Only losing all three is a failure. When
nothing comes back at all the response says so in its own words and names the
remaining suspect — country picks the regional server, and the wrong one
authenticates happily and shows an empty account.

The other half of "not showing any chargers" was that neither client ever showed a
list. The serial was a text box, and the number is printed on a charger hanging on
a wall. Both apps now list what the account holds — name, serial, model, site,
state, an offline badge — and hand the serial to the OCPP control card instead of
asking anyone to go and read it. Where control is off the list still stands on its
own, as the answer to the first question an owner has after entering credentials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 20:13:36 +02:00
tajniak81andClaude Opus 5 a7cab50e06 Apprise: a gateway to hand a message to, not a hundred protocols to carry
Apprise is a Python library that speaks 100+ notification services behind one URL
grammar — mailto://, tgram://, ntfy://, discord://. None of that is portable to a
server that takes no dependencies, and none of it needs to be: caronc/apprise-api
wraps the library in HTTP and is meant to run as a container beside us. So the
connector carries no notification protocols of its own. It posts a body to an
endpoint the operator runs and lets Apprise fan it out, which is also why adding
a service later costs nothing here.

Targets are addressed one of two ways and configKey is the switch. Stateful means
the URLs live on the Apprise server under a key, narrowed by a tag expression, and
recipients are then edited there — no credential for any downstream service is
ever held in DriverVault. Stateless means the URLs travel with the request, from a
secret config field, which is simpler for one destination and worse for ten. A
call that names its own key or urls takes that destination alone rather than
merging with the configured one: honouring a caller's URLs while still falling
back to the configured key would deliver the message somewhere nobody asked for.

baseUrl is Required, which no other connector's address is. Toyota, Anker and
Greencell leave everything blank at the global layer because the superadmin → org
→ user cascade exists to fill it in, and a blank there means "let the user
choose". There is no cascade behind this one — a notification gateway is
infrastructure the operator runs, not an account a driver owns — so nothing
further down can supply the address, and a blank is simply a plugin that cannot
work. Better to fail at enable than at the first notification nobody sees.

Three limits are choices rather than gaps. /add and /del are not implemented: the
Apprise config belongs to the operator, we post to it, and a connector that can
delete a notification config has a wider blast radius than one that can only send
through it. privacy=1 is forced on /json/urls rather than offered as a parameter,
so a target listing reads mailto://user:****@host and downstream tokens stay on
the Apprise side of the wire. Attachments are remote URLs the Apprise server
fetches; multipart upload is the API's own path for files and not ours.

Health follows the rule Greencell set. A reachable server whose config holds
nothing to notify is degraded, not down: the half we address works and the missing
half is the operator's config. Two cases earn their own line — a config key set
against a server running with stateful mode disabled can never resolve, and /status
answers 417 rather than 500 when Apprise finds a problem with itself, so that is a
parsed answer and not a transport failure. A proxy that strips our Accept header
gets the same codes back as plain text, which is read rather than called
unreadable; an HTML error page from something that is not Apprise is not, and a
test pins the difference.

Notifications needed a category of their own, and that is the one change outside
the plugin: the constant, the tab order in PluginsCard.vue, and the label in all
three panel languages. The cost is now written down in the plugins README beside
the Descriptor example, since the previous five categories predate anyone having
to add a sixth.

The plugin's tests run against an apprise-api stand-in built from that project's
views.py — both notify paths, the override rules, 204-as-empty against
424-as-failure, and every health branch. builtin_test.go is the other half: the
blank-import list in builtin.go is a silent failure mode, since a connector left
out of it compiles, passes its own tests, and never appears in the panel. What is
not covered is a live instance; there is no Docker on this machine, so the wire
contract comes from reading upstream's source rather than from running it, and a
smoke test against a real deployment is still worth doing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 22:43:07 +02:00
tajniak81andClaude Opus 5 a49d48f659 The panel's webfonts, in the one position CSS accepts them
The lockup was the visible symptom and the wrong suspect. Matching it to the Web
App's component changed nothing a reader would notice, because the panel was not
rendering Archivo at all — it was rendering system-ui's italic bold, which is a
different letterform at the same size, and had been since the stylesheet was
written.

The Google Fonts @import sat after @import "tailwindcss". Tailwind v4 inlines
its import into the rules it generates, so anything importing after it is no
longer at the top of the sheet, and CSS drops an @import that follows real
rules. The built stylesheet carried zero occurrences of fonts.googleapis.com;
the build had been saying so on every run, in a warning easy to read as noise
about a comment. Moving the font import above Tailwind's is the whole fix, and
the Web App's own stylesheet has always had that order with a comment explaining
it — that comment comes across, plus what it cost here.

This was never only the wordmark. Every rule reaching for --font-sans or
--font-mono was falling back too, which is the entire panel: the section nav,
the card titles, and the endpoint tables whose monospace is how a path reads as
a path. Checked against the built bundle rather than the dev server, since the
dev pipeline is exactly what was hiding it: the page now reports Archivo italic
800 loaded, and the wordmark measures 115.05px — the same width the Web App's
rail lockup measures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 18:17:09 +02:00
tajniak81andClaude Opus 5 e648634ce1 The plugin list, grouped by what a plugin actually is
Category has been in the plugin contract since it was written — apis-external,
drives-external, drives-local — and every builtin declared the same one, so it
grouped nothing. Two of the three talk to a wallbox and one talks to a car
manufacturer, and those are different questions an operator arrives with: the
Toyota card is where a driver's account gets linked, the Anker and Greencell
cards are where a charger's broker and credentials live. So vehicles and
chargers join the constants and the three builtins say which they are.

The panel groups on that field rather than on a list of names, which is what
keeps an external plugin from needing panel code. Tab order mirrors the
constants; a category with nothing in it gets no tab, and a single group hides
the bar entirely, so an install with one connector looks exactly as it did.
A category the panel does not recognise — or an empty one — falls to the
external-APIs tab rather than vanishing, because a plugin nobody can see is a
plugin nobody can disable. The selected tab falls back to the first group when
its own goes away, which is what removing the last external plugin does.

Registration still asks only for name, base URL and provider, so a plugin
registered at runtime lands under Other APIs until its manifest names a
category. That path already works and is the honest default: the panel is
guessing about a service it has never spoken to, and the service can say.

The header lockup is the other half. It was a copy of the Web App's mark rather
than the same mark, and copies drift — a 32px icon against 28, a 24px wordmark
against 21.6, "Driver" at text-strong instead of white, "Vault" a step lighter
than brand-400. The Web App's Logo.vue moves in verbatim, props included. The
one thing it cannot inherit is which variant to render: the Web App's rail is
always dark, while this panel flips with its own theme toggle, so on-dark is
bound to the theme and the hand-rolled bar fills that existed to survive that
flip are gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 23:30:56 +02:00
tajniak81andClaude Opus 5 340a81b0d6 Greencell: the charger on your own broker, not a cloud it never had
The HabuDen has no cloud API to connect to. It is commissioned over Bluetooth in
the Greencell GC app, pointed at an MQTT broker the owner runs, and from then on
publishes there — so the connector is an MQTT client rather than an HTTP one,
and nothing in it reaches Greencell. The wire contract is Home Assistant's own
greencell component and the greencell_client 1.0.3 library beneath it, which is
the only published description of the topics: a BROADCAST on /greencell/broadcast
draws device announcements, and /greencell/evse/{sn}/ carries current in
milliamps, voltage, power under "momentary", the EVSE state, and the access level
chosen in the app.

That meant an MQTT client, and the server takes no dependencies, so internal/mqtt
is hand-rolled the way internal/ocpp's RFC 6455 layer is. It is scoped to what
this connector needs and says so: QoS 0 for everything we send, clean session,
no reconnect — a connection lives for one plugin call, which is exactly how the
manager builds and tears down an instance. Inbound PUBLISH is accepted at QoS 0,
1 and 2 with the acknowledgements each requires, because the QoS of a delivery is
the broker's choice and not ours; an unacknowledged QoS 1 is redelivered forever.

Read-only, and the reason is worth writing down rather than rediscovering. A
device in EXECUTE mode accepts START, STOP, SET_CURRENT and QUERY — but the topic
those go to appears in no source: not Greencell's integration page, not
greencell_client, and Home Assistant ships sensor-only for that same reason.
Publishing to a guessed topic would be a control feature whose failure mode is a
driver believing they stopped a charge. So the access level is reported, and
commandTopic is the seam: an operator who has watched their own broker and found
theirs sets it, and a state read then sends QUERY — the one command a READ-mode
device also honours — instead of waiting out the charger's publish cadence. The
day the topic is public, control is a payload away from the same field.

What the cascade resolves here is a broker, not an account, so host, port, TLS and
credentials resolve together from the highest layer that names a host: an
organization's address paired with a user's password would address a broker with
credentials never meant for it. The serial, the QUERY topic and the listen window
each describe the charger rather than the endpoint, so each resolves on its own.

Two reading rules the tests pin. A phase the device did not report stays nil
rather than zero, because zero amps on a charger is a real measurement — a JSON
null decoding to 0.0 was a live bug until a test caught it — and a partial read
returns with received/complete flags instead of failing, since a device that
publishes some topics on a slower cadence is still worth reading. And a reachable
broker with no charger on it is degraded, not down: the half we configure works
and the missing half is the device. The plugin's end-to-end tests run against an
in-process broker written to the raw wire format, so a bug in the client cannot
hide behind a matching bug in the fixture.

The apps get the third connector card. The panel needed nothing — it renders a
plugin's ConfigFields itself — but the per-user panes are still hand-written per
integration, which is now three near-copies and the argument for the generic
version already noted in the plugins README. The web form splits the broker from
the charger because the server resolves them differently. The phone card is a
declarative config against the shared widget, which gained a number field type, a
degraded state that reads amber rather than red, and a fix for a locked field
that was covering its own displayed value with dots. Twenty keys in three
languages across both apps; Greencell, HabuDen and the literal QUERY join the
proper nouns that stay in English.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 22:00:44 +02:00
tajniak81andClaude Opus 5 5e6b8b4b1c Anker Solix: the charger's mode, and the modes it can be moved into
The connector was written against anker-solix-api v3.7.0 and upstream is at
3.8.1 now. The reassuring half of the check first: nothing we depend on moved.
The passport/login ECDH exchange, the headers, and every endpoint path this
plugin calls are identical across v3.7.0...v3.8.1 — the only apitypes movement
touching an EV charger was get_device_rfid_cards being reordered within its own
dict. The 400 new lines in charger.py are the A2345 USB charger, which shares a
filename with our device and nothing else.

What did land for the V1 is two entries in the release notes, and both are MQTT:
3.8.0 gave standalone chargers the usage-mode entity they were missing, 3.8.1
added a switch that reads those modes as a plain on/off so EVCC and its like
have a binary to hold. We control chargers over OCPP, not MQTT, so the command
path is not ours to port. The reading of state underneath it is, and that half
does come over the cloud.

So charger-state. The status code arrives under two different names depending on
which system family a site belongs to — operating_state inside a scene's
charging_pile_list, evChargerStatus inside HES system running info — and
upstream's poller quietly renames both to ev_charger_status on ingest, which is
the tell that they are the same number. We ask both and merge, because a site
answering only one of them is the normal case rather than a fault; the call
fails only when neither view is there. chargerMode and chargerModeOptions then
follow ev_charger_mode_state and ev_charger_mode_options as written, including
the rule that a stopped charger is startable only from standby, and the binary
is the same one 3.8.1 chose: everything that is not stop_charge counts as on.

The gap worth naming is that the boost flag and the plug and start countdowns
reach upstream over MQTT and never over the cloud, so three of the six modes
cannot occur here. That is not a bug to be found later — chargerMode takes them
as parameters and the callers pass their zero values, so the day an MQTT source
exists the derivation is already correct and only its inputs change. The package
doc says so in the scope list beside the other limits.

Five endpoints upstream has had all along and we never exposed come with it,
all EV-charger-scoped: the site scene, energy_analysis under device_type
ev_charger, a charger's RFID cards, Anker's own OCPP endpoint list, and one
vehicle's details. charger-status takes the featuretype it was hardcoding at 1,
since upstream's exporter asks for both 1 and 2 and there was never a reason for
us to see only half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:39:30 +02:00
tajniak81andClaude Opus 5 fd75833707 The cabin's temperature, and the one it is heading for
The climate cards landed with the endpoint migration, but only as two more
folded dumps of key/value pairs. What a driver opens that tab for in January is
one number, and it was three taps down inside a card called Climate.

So currentTemperature and targetTemperature join the headline readings, beside
the pair of electric ranges and for the same stated reason: neither figure
answers the question on its own. A cabin at 12° means nothing until you know it
is climbing towards 21°, and the gap between them is how long to leave the
scraper in the boot. Being derived from headlineMetricSpecs, both are arrangeable
the moment they exist — a car's saved order of readings can name them without
anything else being told they are there, and a test now says so rather than
leaving it to be noticed when a PATCH starts rejecting a key.

The unit is fixed at Celsius, because Toyota Connected is the European service
and there is no imperial reading to convert from. That is a default and not a
claim: a payload that names its own unit is still believed over it, the way
every other reading here works, so a service that one day reports Fahrenheit is
labelled Fahrenheit rather than relabelled into a wrong Celsius.

The two apps needed the two labels in three languages each and nothing else.
That is the shape working: a section is an id the app localizes and a reading is
a key it localizes, so a card added on the server arrives in both clients
already folded, already arrangeable, already translated. The one thing the Web
App did need was a corrected comment — the note explaining why cards fold still
said Toyota reports eight sections, and it is the argument for folding them, so
it should count the ten there now are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 19:45:58 +02:00
tajniak81andClaude Opus 5 22a22ec43a Toyota: the status route the car answers, not the one it retired
Toyota put the /v1/global/remote read routes behind AWS SigV4 in mid-2026. A
bearer token is no longer a credential there, so the doors-and-windows card has
been asking a gateway that answers 403 — the one section of the provider tab
that could only ever have been in error. The MyToyota app reads that state from
/v1/vehicle/status now and pytoyoda followed it in 5.2.0; so does the connector.
The electric route did not move, and the comment above the endpoint block says
which of the two namespaces each one lives in, because the obvious tidy — sweep
the rest onto /v1/vehicle/* — would break the ones that still work.

The same migration gave the climate reads a home worth porting: /v1/vehicle/
climate-status is what the cabin is doing, climate-settings the preset it was
told to do it at. Both are GETs with a vin, both are new cards on the tab, and
their headings are in all three languages on both apps. Nothing about the tab's
plumbing changed to hold them — a section is an id, an action, and whatever JSON
comes back, which is the point of that shape.

Left where they are: the POST wake calls. Upstream refreshes a stale reading by
waking the modem, and this connector is documented as read-only, so climate and
status show what the car last reported rather than what it would say if asked
twice. The cost is a reading that can be hours old, and it is the honest one to
pay for a connector that promises not to touch the vehicle.

Two tests keep the migration from being undone by hand: one fails if any
advertised capability points back at a retired route, the other if a capability
is advertised without being wired into Invoke, which is the way the next
endpoint would go missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 19:40:31 +02:00
tajniak81andClaude Opus 5 a203414ceb Phone App: the garage on the car's own screen
The one place a service badge is worth reading is the car it belongs to, and
that is the one place the app could not be opened: Android Auto runs no Flutter
engine, so a Flutter app is simply absent from the head unit. The same APK now
carries a second face — Car App Library templates the host draws itself, in
Kotlin under android/app/src/main/kotlin/com/drivervault/phoneapp/car/.

Two screens. The garage lists a car per row with its due badge on the second
line, worst first, because the host renders only the first handful of rows and
the car this list exists to mention is the overdue one rather than whichever was
added first. A tap opens what that car has coming: the odometer, the next
service, and the reminders the server holds for it — typed in and auto-derived
from documents and the service schedule alike, in the order it sorted them.

None of it is a second implementation of the app. VaultStore reads the session
the phone signed in with — the active server's base and token — out of
shared_preferences' own store, which both halves share, so a server switched on
the phone is the server the car reads from with nothing to keep in step; only
cc_active_base is new, because an untouched home entry carries no address of its
own, its base being kDefaultApiBase, a compile-time define nothing outside Dart
can see. CarStrings reads the same assets/i18n files by the same dot paths, so a
badge on the head unit is the string format.dart already puts on the phone, in
the language the account chose: of the 32 keys the car screens ask for, 28 are
keys a phone screen already used, and only carApp.* is theirs. CarFormat is
format.dart's twin — same date pattern and number grouping from the account's
settings, same worst-of-date-and-km service badge. A new test reads the Kotlin
for the keys it looks up and fails if any is missing from a language file, since
the analyzer's reach stops at the Dart.

It only reads. A screen you cannot type into is a poor place to edit a car and a
driver is a poor person to ask, so VaultApi has no write in it to reach for by
accident.

Three things the README now says out loud. The service is declared IOT, the
closest category the library defines for something that is a garage rather than
a map or a media player, which matters to a store submission and not to a
sideload. The app lock does not reach the head unit: the flag is in memory and
the credentials behind it in encrypted storage, neither readable from the car
service, and there is no fingerprint reader in a dashboard to satisfy it with.
And the home charger is not on there — the chargers endpoint relays its plugin's
payload verbatim with no shape to read, and the serial the control card is
driven by is never persisted, so the car would have nothing to name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 18:26:46 +02:00
tajniak81andClaude Opus 5 fa569c4030 Sign in before the first call, not after a 401 that never comes
Logging in failed on both the Web App and the Phone App with PocketBase's
{"data":{},"message":"The requested resource wasn't found.","status":404}.
The login itself succeeded; the profile fetch right after it — GET /api/me
— was what 404ed, and the web app renders the relayed body on the login
form, so it read as a rejected sign-in.

PocketBase answers a record read it will not allow with 404 rather than
401: it hides the record instead of refusing the credentials. The pb
client only re-authenticated on a 401, so with no cached token the first
request went out carrying no Authorization header at all, came back 404,
and the retry never fired. Nothing ever tried again — the server kept
404ing long after PocketBase was healthy.

The cache is empty in exactly the two cases that matter: a startup where
the up-front Authenticate failed because PocketBase wasn't up yet, which
main.go treats as non-fatal on purpose so a superadmin can still log in
and fix the connection; and a Reconfigure from the panel, which clears
the token so the new credentials get used.

So acquire the token before the first attempt rather than hoping for a
401 to prompt it, across all four superuser paths. Bad credentials now
surface as the authentication failure they are instead of masquerading
as a missing record. With no service account configured there is nothing
to acquire and the call proceeds as before, since the endpoints that need
superuser access already answer 503 on their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 10:33:33 +02:00
tajniak81andClaude Opus 5 12ec10a797 Phone App: more than one server, and a session for each
The web app can be pointed at two DriverVault stacks and switch between them in
a click. The phone had one address and one session: reaching a second garage
meant retyping the API base in Server settings and signing in again, losing the
first server's token on the way — the same act, undone, every time you switched
back.

So lib/servers.dart is the web's servers.js ported rather than reinvented, down
to the storage keys: cc_servers holds the list, cc_active_server the one being
read, cc_session_<id> the token minted by that server and no other. The two apps
describe the same thing the same way, and the upgrade path falls out of it —
cc_token, cc_user and cc_server_url are read once at boot and folded onto the
home entry, so the build carrying this signs nobody out.

Home is the address the build ships with (kDefaultApiBase, still overridable per
device from the login screen) and cannot be removed: it is what a dropped session
falls back to. Any other server is added by address, with /api appended if the
path is left off, because a server a phone can reach is internet-facing already.

The part worth reading twice is which session a rejection ends. ApiClient no
longer holds a base or a token — it pins the active server's id, base and token
at the moment a request goes out, so a 401 arriving after a switch clears the
session of the server that actually refused it rather than whichever one is
active by then. The fallback is the web's: a remote server timing out drops its
own token, the app returns to home while home is still signed in, and only when
nothing is left to fall back to does the login screen come back. Log out still
clears every server at once, since leaving the app means leaving all of them.

Switching rebuilds the shell, keyed on the active id, because record ids belong
to the server that issued them — a garage, a charging page and a settings panel
still holding the other server's rows would each have to be told to forget them
separately. The appearance prefs come across with the profile of whoever owns
the account on the server now active.

Where the picker lives is the one place the phone cannot copy the web. There is
no app rail here, so it became the first button in the Garage header, beside the
theme toggle and log out, which is that same cluster. It names the active server
once there is a choice and goes straight to adding the second when there isn't;
the eyebrow reads GARAGE · Work for the reason the rail names it — two garages
otherwise look identical. The login screen gets its own way in, because a remote
session can expire and land you there with that server still active, and a
picker reachable only from inside the app would leave nowhere to go.

One judgment call inside the sheet: saving a connected server at a new address
saves and stops, rather than falling through to the sign-in it now needs. The
token was minted by the PocketBase behind the old address and is dropped with
it, but the credentials to replace it were never asked for, so treating the save
as a login would report an empty password as the error.

The strings are copied out of Web App/web/src/i18n/ like the rest of the shared
wording. Two are not the web's: home reads "the address this app ships with"
rather than "served with this app", since the phone has no origin to be served
from, and sameOrigin has no meaning here at all and was dropped.

Biometric sign-in stays global. It was never per-server and replays its stored
credentials against whichever server is active; making it per-server is a change
of its own, and the login screen now names the server it is about to sign into.

Nothing changes on the API Server. On Android there is no origin to allow, so
the CORS list the web app has to satisfy to reach a second server doesn't enter
into it.

Verified: flutter analyze is clean and flutter test passes, 35 tests to 46. The
new ones cover the registry — a bare origin gaining its /api, a fresh install
knowing one unnamed server on the built-in address, the legacy keys landing on
home and being cleared, two servers holding their tokens apart, a rename keeping
a session where a move drops it, removing the active server falling back to a
home that is still signed in, home refusing to be removed, and a restart reading
the list, the active id and every session back.

Not verified: none of it has been run. There is no device or emulator on this
machine and no API Server to answer, so the picker, the add sheet, a real
connect, the 401 fallback and the shell rebuild on a switch exist only as code
the analyzer is happy with — the tests reach the registry, not a screen. No APK
was built. The legacy migration was exercised against mocked SharedPreferences,
which is not a phone that had the old build on it: that is the first thing to
check on a device, since the failure mode is a silent sign-out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:47:01 +02:00
tajniak81andClaude Opus 5 35e6c511b7 Changed parts: the list is the car's, not the app's
The Changed parts section offered all three parts to every car. An EV changes no
oil, and a checkbox nobody will ever tick is one more thing to read past on every
service — so which parts a car records now belongs to the car, the same way its
tabs, its Information rows and its Service history columns already do.

It works the way those three do because a fourth mechanism for the same idea
would be a fourth to keep in step: hidden_service_parts on the car, validated by
the endpoint that already does this, stored as the hidden set so a part added in
a later release is on by default, and needing write access because the choice
belongs to the car and everyone it is shared with sees it.

There is no order beside it, which is the one place this departs from the other
three. Those arrange things whose position means something — a tab bar reads left
to right, a table's columns are read across. The parts are a checkbox list inside
a single column, and moving Cabin air filter above Oil says nothing. Adding one
later is the same shape as the others if that turns out to be wrong.

A part switched off leaves the form and the history together — the chips on the
phone's cards, the web column's summary and the panel it opens. "I don't record
this" means it stops taking up room, not that it takes up room saying nothing,
which is the rule a hidden column already follows. That is the judgment call
here: a car with five years of oil changes hides them all by switching the part
off. Nothing is written to the records, so switching it back on brings every one
of those chips back, which is what makes the call safe to reverse.

The part that would have been a silent data bug: the API rewrites all three
booleans from the body of a service update, so a form that simply stopped
sending a hidden part would set it false on the next edit of any old record.
Both forms therefore keep every part in their state and submit every one — only
the checkboxes are filtered. The mirror of that is a *new* record, where a hidden
part starts false rather than at its `initial`, since ticking a box nobody was
shown is not a default, it's a guess. Oil is the only part with initial: true, so
that case is live the moment anyone hides it.

Verified: go vet and go test ./... pass, with a new test covering that every part
is hideable (unlike the tabs and the columns — a service that changed nothing is
a real service), that the "parts" column key is refused as a part key and a part
key as a column key, and that no part is also a column. flutter analyze is clean
and flutter test passes 32 to 35, the new ones covering visibleParts, that a
hidden part's chips go while its stored boolean stays, and the picker's fourth
section. npm run build is clean.

Both apps were driven against throwaway stub APIs. Web: the picker saved
{"hiddenServiceParts":["oil"]}, the table's parts cell went from "Oil & Oil
filter +2" to "Engine air filter, Cabin air filter", the record whose only part
was oil went to an empty cell, the panel dropped to two rows, the add form
offered two unticked boxes where oil's initial: true would have ticked one, and
editing the three-part record sent changedOil:true back with a box that was never
on screen. Phone: the same car rendered chips "Engine air, Cabin air", "Changed
parts —" for the oil-only record, and an add sheet with exactly two unticked
boxes.

Not verified: no automated test guards the web behaviour — the web app still has
no test runner, so the above was read out of the live DOM and the outgoing
request bodies by hand. The phone's picker was checked by widget test and by
rendering, but its Save was not driven end to end. Neither app was run against
the real API Server: bootstrap appends the new field on the next start, and until
that start a client sending hiddenServiceParts takes a 400 — they deploy together
from this repo, but the server must go first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:30:08 +02:00
tajniak81andClaude Opus 5 7718b32013 Phone App: off the plugins that bring their own Kotlin
flutter build apk warned that file_picker and shared_preferences_android apply
the Kotlin Gradle Plugin themselves, and that a future Flutter will refuse to
build an app whose plugins do. Both have versions that let Flutter's built-in
Kotlin do it instead; neither of them is a version bump on its own.

shared_preferences_android was free — 2.4.27 is inside the constraint that was
already there and only pub.lock was holding it back. file_picker is not: 10 and
11 both apply KGP, so 12 is the floor, and 12 split into federated packages
whose windows one wants win32 ^6, which flutter_secure_storage 9 forbids.

So the fix reaches flutter_secure_storage, and that is the part worth reading
twice. v11 satisfies win32 but its changelog is explicit: data written by a
version before v10 is unusable after it, because v10 is what migrates the
Jetpack Security (EncryptedSharedPreferences) backend Google deprecated to the
package's own ciphers. Going 9 to 11 in one step would leave the stored
credentials unreadable and quietly switch biometric login off for anyone who
had it on. v10 satisfies win32 ^6 just as well, so the constraint is pinned
below 11 with the reason written down: once a build carrying v10 has run on
every device that had biometric login enabled, the ceiling can go.

encryptedSharedPreferences: true goes with it — v10 ignores the parameter and
migrates on first access, and v11 has removed it.

file_picker 12's API is smaller and the call sites got smaller with it.
FilePicker.platform.pickFiles returning a result whose files list had to be
checked for emptiness becomes FilePicker.pickFile returning one nullable file,
which is what both callers wanted. PlatformFile.bytes (populated only when
withData was asked for) becomes readAsBytes(), so the "bytes, or read the path,
or give up" ladder both callers carried is one await — and the give-up branch
that raised errors.noFile and the import's notJson is gone, because a file that
was picked can now always be read.

Verified: flutter analyze is clean and flutter test still passes 32. flutter
build apk --debug succeeds and prints no KGP warning, where the build before
this named both plugins.

Not verified: nothing was exercised on a device — the phone came off USB before
the reinstall, so this APK has not run. The two things to try first are the
ones that changed under the picker: attach a PDF to a service record, and
Settings, data, import a previously exported JSON. Biometric login is the third
— it should survive, since v10 migrates rather than resets, but a device that
had it on is the only place that claim can be checked, and if the migration
does fail the app treats it as stale credentials and asks for the password.
Android is the only target built; the win32 bump underneath is untested because
this app has no windows/ folder to build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:06:11 +02:00
tajniak81andClaude Opus 5 6b7abb4b84 Phone App: a service card laid out by the car's own columns
The Service history columns became a property of the car two commits ago, and
the phone was left out of it on the grounds that it has no table to arrange.
But the arrangement is not the table's — it belongs to the car, and everyone it
is shared with sees it. A reader who switched Oil off on the web still had it
on every card here, which makes the setting look broken rather than absent.

A card is not a table, so the columns cannot be cells. Consecutive short ones
share a wrapping line, which flows left to right and then down and so keeps the
arrangement intact; the parts, the notes and the file each take a line of their
own. That means the grouping follows the car's order rather than the
catalogue's — move Notes between Km and Next date and the short columns split
around it — which is the part a hand-written card gets wrong by collecting the
short columns first and appending the blocks after them, quietly undoing the
arrangement it was asked to honour. serviceColumnRuns is a function for exactly
that reason: it is the piece worth a test.

The date carries no heading where every other column does. A card list is read
down its dates, and "Date" in front of one says nothing the date doesn't — the
same judgment the server makes by refusing to hide it. It is offered in the
picker anyway, ticked and locked, because a row missing from that list is a row
nothing on this screen can drag; the web drags the column headings themselves,
which on a touch screen is the scroll's gesture. A column that is on but empty
says so ("Notes —") rather than vanishing: it was switched on deliberately, and
a card that silently drops it reads as a record that failed to load.

Changed parts arrives with it. Every part shares the one column — they are a
growing list and a column apiece would widen the web's table without end — and
lib/service_parts.dart is the twin of the web's lib/serviceParts.js, so the
form's checkboxes and the card's chips come from one list and adding a part is
one entry plus its boolean on service_records. The chips keep their own shorter
wording; the form keeps the web's, which is what stops the two apps naming the
same part differently.

Verified: flutter analyze is clean and flutter test passes, 22 tests to 32 —
the new ones cover that the arrangeable set is the hideable one plus the date,
that a field key is not a column key, that adding a part adds no column, the
run grouping, and a widget test of the picker showing the date's is the only
locked checkbox. The screen itself was driven against a throwaway stub API: a
default car renders date, Km, Next date, Next km, chips, notes and file in that
order, and a car hiding km and file with the order [notes, date, nextKm, parts,
km, nextDate] rendered exactly that — notes first, both hidden columns gone, the
short columns split around the chips. updateCarView round-trips both new fields
under the names records.go decodes.

Not verified: the picker's own Save button — the tap landed in the harness but
the request never reached the stub, which reads as the fire-and-forget future
being cut off at teardown, since the same call made directly worked. Drag was
exercised through the reorder callback, not by a finger. Nothing here needs the
API Server to change: both fields already ship, and a phone running against an
older one simply reads empty lists and shows every column.

Two commits needed nothing: the web's masked date box answers <input
type="date"> rendering in the browser's locale, which a picker-only field
cannot have, and the garage card's width answers a badge that wrapped, which
this badge cannot. car.services.next goes, its prose replaced by the columns
that now say it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:32:45 +02:00
tajniak81andClaude Opus 5 f7caeaf907 A date typed in the order you chose, not the browser's
Settings › Date format steered every date the app printed, but not one it
asked for: `<input type="date">` renders in the browser's own locale and no
page setting can move it, so DD-MM-YYYY tables sat above 08/22/2026 boxes.

DateField takes over the typing half — a masked box whose segment order comes
from the same prefs.dateFormat lib/format.js reads — and leaves the picking
half to the browser, behind a calendar button. The value in and out stays ISO,
so no caller changed. Native validation now also catches a full-but-impossible
date; the old input let a half-typed one through as no date at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:18:18 +02:00
tajniak81andClaude Opus 5 d4033dbcef A build date you may only half know; one look for an empty cell
Two changes, both about showing what is actually known rather than a tidier
version of it.

The build date asked for a day. A car's build date is often only a year, or a
month and a year - the VIN plate is stamped with a month, the papers carry a
day, a grey import neither - so a field insisting on all three is answered
either with an invented day or with nothing, and both throw away what the owner
did know. The field now picks its own precision: a full date, a month and year,
or a year, each with the control that suits it. A year is typed rather than
picked, because a date picker that makes you walk back to 1998 is worse than
four keystrokes.

Stored as the ISO prefix - "2015", "2015-03", "2015-03-10" - which is ISO 8601
reduced precision, and printed back at exactly that precision. The three shapes
sort and compare as strings in date order, which is why the prefix is stored
rather than a date with a precision field beside it. The formatter takes the
string apart rather than parsing it: "2015-03" read as a UTC instant and printed
in local time hands back February west of Greenwich.

Narrowing the precision keeps what is still true, so a day dropped from
"2015-03-10" leaves "2015-03". Widening clears the field. That is the awkward
half of the control and it is deliberate: there is nothing to widen a year with,
and leaving "2015" behind an empty month box would store a date the screen is
not showing.

The column was free text with no validation at all, which was tolerable while
only a date picker could write it and is not now that three shapes are legal.
normalizeBuildDate parses rather than pattern-matches, so "2015-13" and
"2015-02-31" are refused instead of stored as something no reader can print.

The phone needed changing to avoid destroying this. It parsed buildDate with
DateTime.tryParse, which returns null for "2015" - so a half-known date would
have shown as a dash, and saving the car from the phone would have written ""
back over it. It holds both date fields as the string they arrived as now,
prints them at their own precision, and hands back anything it cannot set. Its
picker still only makes full dates; a precision control there is a separate job.

Separately: an empty cell of the service table had three different looks in one
row. The dash under Notes was body-coloured, as though it were content; the one
under File was 12px, having borrowed the size of the Download button that would
otherwise be there; the one under Changed parts was muted at 14px. They are one
constant now, muted at the row's own size, which is what Next date and Next km
already did for a missing value. The Download link keeps its own styling - it is
an action, not a value.

Verified in a browser: a stored "2015-03" loads as month precision in a month
picker, month to year narrows to "2015", year to day clears, "19x98abc" typed
into the year box sanitises to "1998", saving sends buildDate:"1998" and the
Information tab then reads "1998" - while a full first-registration date beside
it still reads 06-08-2026. All five empty cells across the three columns now
compute to the same size, colour and weight, with the filled ones unchanged. go
vet and go test ./... pass with a new test over the three valid shapes and six
rejects; flutter analyze is clean and 22 tests pass, one new, covering a
half-known date in two date formats and the time zone that could shift it; npm
run build is clean.

Not verified: First registration still demands a full date. The same argument
applies to it and the field is now a reusable component, but it was not asked
for and is one line away. The web formatter's month-name paths - the DMY and MDY
formats, which spell the month out - are covered only by the phone's mirror of
the logic, the web app still having no test runner. A car created through the
Toyota import bypasses the new validation; it only ever produces full dates, so
nothing invalid gets in that way, but it is not guarded. Both apps need
redeploying before any of this is visible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 12:11:39 +02:00
tajniak81andClaude Opus 5 c5d431c560 Changed parts: one column in the service table, not one each
Oil & Oil filter, Engine air filter and Cabin air filter had a column of Yes/No
each in the Service history table, 375px of the 1022px table between them for
three bits of information. The form has always kept them together in one Changed
parts section, which is the honest shape: they are one answer to one question
about a service, not three unrelated readings. The table said otherwise, and the
list is going to grow - every part added would have taken another column and
pushed the table into a sideways scroll.

They are one column now, 234px with the widest summary on screen, and its width
no longer depends on how many parts exist. The cell names what was changed
rather than counting it, because a history is read down the page and "2" tells
you nothing about which two; past two names it becomes the first part and a
tally, which is what keeps one line one line as the list grows. Nothing changed
reads as an em dash.

The detail is a dropdown, not a dialog. This is read-only detail about one row
of a table you are reading down: a modal would black out the rows being compared
against and charge an open-and-close for each one. It is pinned under the button
it was opened from, closes on an outside click, Escape or a scroll - it is fixed
to a point on the screen, so a table that moves underneath would leave it
pointing at the wrong row - and there is one panel rather than one per row. It
lists every part with a Yes or a No, the unchanged ones included, so the em-dash
row still answers the question instead of being a dead cell.

One list in lib/serviceParts.js now drives the form's checkboxes, the cell's
summary and the panel. That is the point of the change as much as the width is:
adding a part was three edits that had to agree, and is now one entry plus its
boolean on the API's service_records collection. The form builds its state and
its payload from the list rather than naming the three fields twice - the save
payload is unchanged in shape, which was checked against the wire rather than by
reading it.

This walks back part of the previous commit, which had just made all three
hideable separately: the server's column set drops oil/engineFilter/cabinFilter
for a single "parts" key, and a test now asserts those three are not columns of
their own, so the table cannot drift back. A car with ["oil"] stored as hidden
would quietly get the combined column - nothing has that stored, the deployed
stack predating the feature, and stale keys are dropped on read rather than
erroring.

Verified in a browser against a stub API: all four summary cases (one part
named, two named, three as "Oil & Oil filter +2", none as an em dash); the panel
opens anchored under its button with the right Yes/No for the row, stays inside
the window, and closes on outside click, Escape, scroll and a second click,
switching rows without leaving a second panel behind; the picker offers "Changed
parts" as one entry and hiding it sends {"hiddenServiceColumns":["parts"]};
dragging sends "parts" in the order with the hidden column holding its slot; the
Edit dialog renders from the shared list and its PATCH still carries all three
booleans with the unticked one false. go vet, go test ./... and npm run build
are clean.

Not verified: no automated test covers any of it - the web app still has no test
runner, so the cases above were driven by hand. The drag and the panel were
exercised through dispatched events rather than a pointer, the browser pane not
compositing, so the native drag image and the panel's behaviour under a real
click-and-hold are unchecked. The dropdown overlaps the rows beneath it, which
is what a dropdown does but was not weighed against a taller table. The deployed
Web App still shows three columns until it is redeployed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 11:16:20 +02:00
tajniak81andClaude Opus 5 b60d929ed6 Service history: columns you can switch off and rearrange
A car's page has let you choose and arrange two things for a while - which tabs
it shows, and which rows the Information tab lists, both dragged into whatever
order you like. The Service history table was left out of that: nine columns,
hardcoded, in one order, for every car. An EV shows Oil & Oil filter and Engine
air filter on every row of a history that will never record either, and a reader
who mostly wants Notes has to look past four columns of dates and distances to
reach it.

It works the way the other two do, because a third mechanism for the same idea
would be one to keep in step. Both lists are properties of the car, so everyone
it is shared with sees the same table, and both need write access to set. The
columns are stored as the hidden set rather than the visible one, so a column
added in a later release is on by default. The arrangement covers the hidden
columns too, which is what makes a column switched back on return to where it
was instead of reappearing at the end - verified below, since that is the part
of this shape that is easy to get wrong and invisible until somebody hits it.

Date cannot be switched off. Every row of that table is work done on a day, and
a history with the day taken out stops being a history; it can still be dragged
anywhere, which is exactly the rule Information already follows in the tab bar.
That is a judgment call and the annotation that prompted this only circled the
other eight columns - moving "date" into hideableServiceColumns and dropping the
filter in the picker would reverse it in two lines if it turns out to be wrong.

Server: hidden_service_columns and service_column_order on the car, validated
against their own key sets by the endpoint that already does this for tabs,
fields and readings. The arrangeable set is derived from the hideable one plus
the date rather than written out again, so the two cannot drift as columns are
added. Bootstrap appends missing fields to existing collections, so the two
columns appear on the next server start with no migration to run.

Web: the table stopped being nine hardcoded th/td pairs and is now driven by one
list of columns, head and body from the same source, which is what stops a moved
or hidden column from shifting the headings out of line with the cells. The
cells are built a row at a time rather than a call per cell, so a long history
doesn't rebuild every cell three times to read its text, its classes and whether
it is the file column. The column headings kept their existing car.services.col*
translations - the keys are mapped rather than derived, because renaming a dozen
strings in three languages to save a lookup table would be the wrong trade. Four
new strings in all three languages.

Verified: go vet and go test ./... pass, with new tests covering both key sets -
that hiding the date is refused, that a field key is not a column key, and that
the arrangeable set is the hideable one plus the date. npm run build is clean.
The page itself was driven in a browser against a throwaway stub API: the
rewritten table renders identically to the hardcoded one, switching two columns
off removed exactly those two from head and body with the rest still aligned and
sent {"hiddenServiceColumns":["oil","engineFilter"]}, dragging Notes onto Km
reordered head and body live and saved an order with the hidden columns still
holding their places, switching Oil back on returned it between Next km and
Cabin air filter rather than to the end, and a read-only share gets no gear
button, no draggable headings and no drag hint.

Not verified: the drag was exercised by dispatching drag events at the
component's own handlers, not by a pointer - the browser pane was not
compositing, which rules out both screenshots and a real drag - so the native
drag image and cursor are unchecked. No automated test guards any of the web
behaviour; the web app still has no test runner. The API rejects unknown JSON
fields, so this web build against an older API Server would take a 400 when
saving the picker: they deploy together from this repo, but one must not ship
without the other. The phone app is deliberately untouched, having no column
table to arrange, and ignores both new fields.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 10:31:52 +02:00
tajniak81andClaude Opus 5 caf4d2996d A garage card wide enough for the badge it carries
The service badge gained a second trigger in the last commit, so it now reads
"OK · 353d · 13.612 km" where it used to read "OK · 353d". On the garage card
there was nowhere to put the extra quantity. Three columns fit the 1024px shell
at 328px each, and at that width the badge wrapped with the unit alone on a
second line - a pill 44px tall reading "13.612" above "km" - while the name
beside it truncated to "Toyota bZ4X To...". The card had been sized for the
badge that used to be there.

The column count now follows from the width a card needs rather than from
breakpoints: an auto-fill track with a 24rem minimum, which the 1024px shell
answers with two columns of 502px. A desktop garage is therefore 2-up where it
was 3-up. That is the honest answer at this container width - the third column
was what squeezed both the name and the badge - and it is the change here most
worth disagreeing with, since it is visible on every garage and not only on the
cars with two triggers. min(24rem,100%) keeps a phone, narrower than one track,
on a single column rather than overflowing it.

The badge also stops wrapping outright. Its label is a phrase whose parts have
to stay together, and separating a number from its unit is the one break it
must not take. The name beside it truncates instead, which it was already
prepared to do - so on a phone, where a card is 271px, the badge stays whole
and the name gives way.

Verified in a browser against the real card: 328 -> 502px, the badge 44px over
two lines -> 24px on one, "Toyota bZ4X To..." -> "Toyota bZ4X Touring" in full,
and at 375px one column, no sideways scroll, badge still on a single line. npm
run build is clean and the compiled CSS carries both rules.

Not verified: no automated test covers this - the web app has no test runner, so
the widths above were measured by hand. The layout was checked with one car on
the screen; a garage of several was not, though the change is to the track
rather than to the card. The deployed Web App still serves the previous build
and will keep showing three narrow cards until it is redeployed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 10:13:21 +02:00
tajniak81andClaude Opus 5 e5759df52c Say how far the next service is, not only how long
The service badge has always watched two triggers - the next-due date and the
next-due odometer reading - and shown one of them. It ranked the two and
printed the worse one's sentence, so a car comfortable on both read "OK ·
354d" and never said that the odometer target was 13.612 km away, even though
the Information card right under it prints the 15.000 km the badge is
counting towards. Whichever trigger arrives first ends the interval, so
naming only one of them describes half the thing.

Both are named now. With both signals known the label is a severity headline
followed by each trigger as a bare quantity - "OK · 354d · 13.612 km", "Due
in 12d · 13.612 km" - which is the shape reminderStatus in the same file
already uses, it having had the two-trigger problem first. The signals gained
the number behind their own wording to make that possible; they were
returning only a formatted sentence.

Wording is unchanged wherever only one signal has data, which is the case
this rewrite most risked disturbing: a car with no odometer target still
reads "OK · 354d" exactly as before, one with no service date still reads
"13.612 km left", and neither still reads "No data". The new keys are only
reached when there are genuinely two numbers to print.

An overdue badge lists only the triggers that have actually passed. "Service
Overdue 30d · 13.612 km" would read as overdue by 13.612 km, which is the
opposite of what that number means, so the trigger that is still comfortable
stays out of a sentence headed "Overdue". It costs the remaining distance on
a date-overdue badge; the alternative costs the reader's trust in the number.

The phone carried a line-for-line copy of this logic and gets the same
treatment rather than being left a version behind - the two would otherwise
disagree about the same car on the same day. Its signals become a private
record type, since Status is public and shared with the expiry, reminder and
warranty badges that have no second trigger and no use for the field.

Two new keys (status.okIn, status.serviceOverdueBy) in all three languages in
both apps. The day and km fragments they interpolate were already translated
for the reminder badge, so the parts assemble in Polish and Danish without
new wording: "OK · 354 dni · 13 612 km", "OK · 354 d · 13.612 km", each with
its own grouping separator.

Verified by flutter analyze (clean), flutter test - 21 pass, including the
key-parity test that would have caught a key added in English alone - and npm
run build for the web. The web function was driven through the real module in
a browser over ten cases: both signals known at each severity, each of the
two overdue alone, both overdue together, either signal missing, neither, and
a zero-odometer car, in all three languages.

Not verified: no new automated test covers this. The web app has no test
runner and the phone's format tests cover the catalogue lookups rather than
the badge, so the ten cases above were checked by hand and are not guarded
against the next edit. The deployed Web App still serves the previous build
and will keep reading "OK · 354d" until it is redeployed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 23:23:19 +02:00
tajniak81andClaude Opus 5 b4e99240c6 API panel: document the whole REST surface, not half of it
The API section's tables are hand-maintained and carry a comment saying they
mirror the routes in server.go. They had stopped: 60 rows against 141 routes,
so 81 endpoints were reachable and undocumented. Everything added since the
tables were written is in that gap - technical checks, fuel, charging,
maintenance, documents, reminders, attachments, the vehicle-provider and
integration surfaces, and the OCPP socket - along with the per-car
sub-resources (fuel-stats, charging-stats, the provider routes) and
PUT /api/cars/{id}/view. Ten new groups, 60 rows to 123, and nothing listed
that no longer exists.

Checked by extracting every mux.Handle route from server.go and diffing it
against every row in the tables rather than by reading both lists: the seven
attachmentRoutes calls expand to their 21 concrete routes, the three static
panel paths are excluded, and the diff is empty in both directions. That
script is not committed - it is a one-off, and a real guard belongs in the Go
tests where it can see the mux, which is worth doing if these tables drift
again.

The flat list endpoints require ?car={id} - they refuse a cross-car listing
so access can be enforced - so the ones that do now say it. Attachments are
documented once with {records} standing for the seven collections that take a
file, because all seven behave identically and seven copies of three rows
would bury that. EndpointTable grew an optional note line under the header to
say what {records} means.

Two things fixed while in there. PUT rendered uncolored - methodClass has had
no PUT entry since the superadmin group started using it - and the longest
paths, the per-charger OCPP control routes, overran the card and were clipped
by its overflow-hidden rather than scrolling; the table sits in an
overflow-x-auto wrapper now. Thirteen of the eighteen tables scroll at phone
width, four of them before this commit's rows were added.

Group titles are translated in all three languages, plus one new auth label
for the OCPP socket, which authenticates the charger with OCPP Basic auth
rather than a bearer token. The endpoint descriptions stay English:
TRANSLATIONS.md calls them developer reference documentation, and this commit
does not reopen that.

Verified by go build ./... and go vet ./internal/api, and by driving the
panel in a browser against a mock API Server standing in for the real one -
all eighteen tables render, the {records} note and the PUT color show, the
Polish titles resolve, and no table clips at 375px. dist is rebuilt so the
embedded panel matches the source.

Not verified: nothing ran against a live API Server, so the descriptions are
checked against the handlers' code and comments rather than against
responses. The mock only answers /api/identity and /api/status, which is
enough to get past the login gate and reach the section.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:56:53 +02:00
tajniak81andClaude Opus 5 a25b31842d Round the connected service's readings; keep sheet buttons off the nav bar
Both found by driving the installed app on a phone rather than by reading the
code, which is worth noting: the second one is invisible in a simulator with
gesture navigation turned off.

The bZ4X's tab showed "Electric range (A/C on) 99.744 km" beside "Electric
range (A/C off) 103.9 km". The long number is a reading converted out of
miles: headlineMetrics multiplied by 1.609344 and printed whatever came out,
so a range estimate claimed to know the distance to the metre, and the two
readings disagreed about their own precision on the same card. Distances now
keep one decimal and percentages none, applied by the reading's kind rather
than by whether it was converted - a provider reporting 99.744 km natively
gets the same treatment. Anything else is left alone, because without knowing
what it measures there is no safe place to cut. The odometer already rounded
to a whole number on its own path; this only changes the headline readings.

The Add-user sheet's "Create user" button sat underneath the system
navigation bar. Every one of these sheets padded its bottom with
viewInsets.bottom, which is the keyboard - correct while typing and wrong the
rest of the time, because with the keyboard down that inset is zero and the
navigation bar is still there. They take the larger of the keyboard and the
navigation bar now, since a raised keyboard covers the bar and the two must
not be added. One helper on DriverVault rather than the same expression in
six files, which is how the six drifted into being identical and identically
wrong.

Verified: go build, go vet and go test ./... pass, with a new test covering
the conversion (62 mi reads 99.8 km), a native over-precise reading, a
percentage, and the odometer's whole number surviving. flutter analyze clean,
21 tests pass, and the rebuilt release APK was installed on the phone - the
Create user button now sits clear of the navigation bar, where the screenshot
that prompted this showed it clipped.

Not verified: the rounding is not visible on the phone yet. It talks to a
deployed API Server that has not been rebuilt from this commit, so that tab
will keep reading 99.744 until the server is redeployed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:44:05 +02:00
tajniak81andClaude Opus 5 dc6febf815 Phone App: finish the admin screen; translate the last English strings
Three loose ends from the last two commits, each of which was named as
deliberately-not-done and none of which is worth carrying further.

The phone's create-user sheet had no organization picker. The endpoint has
taken an `organization` since orgs existed and the Web App has offered the
choice all along, so a superadmin on the phone could only ever create
accounts in their own org - a silent restriction rather than a stated one.
The sheet now loads the orgs and offers them to a superadmin, with the same
blank "no organization" option and the same hint as the web. An admin still
gets no picker, because the server forces its own org on their members and a
picker that cannot change the outcome is a lie. The listing is manager-only
and can fail, in which case the picker offers only "no organization" rather
than blocking the form.

A locked role picker or delete action was greyed out with no reason given.
The web has explained itself in a title attribute since those guards existed,
and the sentences - admin.cantChangeOwnRole and the rest - have been sitting
translated in the phone's own language files since the screen was translated.
Hover has no touch equivalent, so the two controls take different routes: a
long-press on the role picker shows the reason as a tooltip, and the overflow
menu carries it under the action, because a disabled menu item cannot be
long-pressed and silently greying it out is the thing being fixed.

settings.integrations.* and charging.control.* were English-only in *both*
apps - 70 keys, identical text, identical key sets - so they are translated
once and land in all four language files. OCPP and CSMS are protocol names
and stay; product names (Toyota Connected, MyToyota, Anker Solix, Lexus) stay;
everything else follows the wording already in each language's file.

The Web App's files are edited as text rather than round-tripped through a
JSON dump, because they keep a blank line before every nested block and a
dump flattens it - a 900-line translation file is hard enough to read
without losing its paragraphs. Both diffs are purely additive as a result.

Both apps now have every key in all three languages: 738 in the web, and the
phone reports zero fallbacks. A new test locks that in - every key en.json
carries must exist in pl.json and da.json - and it was checked by deleting a
key and watching it fail, because a guard that cannot fire is not a guard.

Verified by flutter analyze (clean), flutter test - 21 pass, 1 of them new -
flutter build apk --debug, and npm run build for the Web App. The key checker
reports 575 static t() keys in the phone and 738 in the web resolving with no
fallbacks in either language.

Not verified: still nothing run against a live API Server or on a device. In
particular the organization picker's happy path - a superadmin creating an
account into a chosen org - has not been exercised end to end; it is the one
piece here that touches the API rather than only the language files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:13:37 +02:00
tajniak81andClaude Opus 5 4372b870ac Phone App: take the admin users screen off hardcoded English
The last screen the phone rendered in English regardless of the language
picker. Its strings are the web AdminUsers.vue's, which have been translated
since b6bb6b1, so the admin.* subtree is copied across the same way the rest
were and Polish and Danish arrive complete.

Two strings are the phone's own, and only because the two UIs confirm
differently: the web deletes behind a browser confirm(), which supplies its
own title, and reports a password reset by closing the modal. The phone has a
dialog title and a snackbar to fill, so admin.deleteTitle and
admin.passwordUpdated are new.

The role picker printed its values raw - Text(r) over "user"/"admin"/
"superadmin" - which happened to read as English because the API's enum is
English. It goes through admin.roles.* now, like the web's, and the new
lookup is covered by the catalogue test alongside the other dynamic ones.

The Web App needed nothing: AdminUsers.vue was already fully translated,
including the role options and the tooltips explaining why a locked row
cannot be edited. Checked rather than assumed - it has 31 t() calls and no
bare text nodes in the template.

Two differences from the web turned up while reading them side by side, both
feature gaps rather than translation ones, and both left alone here: the
phone's create-user sheet has no organization picker for a superadmin (the
server puts the account in the creator's org), and the phone disables a
locked role dropdown or delete action without saying why, where the web
explains it in a title attribute. The strings for that explanation are now
sitting in the phone's language files, so it is a small change if wanted.

Verified by flutter analyze (clean), flutter test - 20 pass, 1 of them new -
and flutter build apk --debug. The key checker reports all 568 static t()
keys resolving in en.json.

Not verified: still nothing run against a live API Server or on a device.

TRANSLATIONS.md and the Phone App README both listed this screen as
outstanding; the only gap either records now is settings.integrations.* /
charging.control.*, which is the web app's too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:58:00 +02:00
tajniak81andClaude Opus 5 a2d9efec7e Phone App: take the car screen off hardcoded English
The previous commit left the car screen half translated: its tab labels went
through t(), and everything underneath them did not. A Polish user opening a
car got translated tabs over English tiles, English forms and English
dialogs, which is worse than either extreme because it reads as a bug rather
than as a missing translation.

So the whole screen and everything it opens now reads from the language
files: the record tiles, the share and delete-car dialogs, the service and
part sheets it hosts, record_form_sheets.dart, car_form_sheet.dart, and the
attachment field whose buttons surface inside all of them.

Almost none of these strings are new. The Web App has said all of this in
three languages since b6bb6b1, so forms.*, enums.*, attachment.* and errors.*
are copied out of its language files the same way car.* was, and Polish and
Danish arrive complete. What is written here is only what the phone alone
needs, and the categories are worth naming because they are the reason the
two apps' files are not identical: tooltips, because the web labels its
buttons; the tiles' running prose, because the web lays the same data out as
table columns; client-side validation, because the web leans on the browser's
`required`; and the snackbars.

Three things changed shape rather than just wording.

The per-record delete prompts were one template with a noun slotted in -
"Delete this $what?" - which does not survive translation into a language
that inflects the noun. Each collection now names its own confirmation
string, which is what the web already had.

The delete-car dialog counted with a hand-rolled `"$n $noun${n == 1 ? '' :
's'}"`. Polish has three plural forms, so that could not be translated at
all; it now goes through the CLDR plurals in car.delete.*. It also only ever
named service records and parts, while the cascade takes maintenance, fuel,
charges and documents too - the translated body names all six, so it is now
passed the whole data set rather than two counts.

The enum labels (fuel types, maintenance type/status, document and reminder
types) were four const maps duplicated between the tiles and the pickers.
They are one lookup against enums.* now, with an unknown value falling back
to the raw key rather than a blank - the server owns that enum, and a value
added there should stay legible in an app that has not caught up.

Found and fixed while testing: the view picker rendered the literal string
"car.tabs.provider" as a row label on an unlinked car. That key does not
exist by design - a linked car's tab is named after the service, an unlinked
one falls back to car.tabs.connected - and the picker was the one caller that
did not know it.

Verified by flutter analyze (clean), flutter test - 19 pass, 7 of them new -
and flutter build apk --debug. The new tests cover what the analyzer cannot
see: the lookups built from a key at render time (car.tabs.$key,
enums.fuelType.$v, the delete dialog's plural counts, the connected service's
readings) are checked to have a real label in all three languages, so a
catalogue entry with no translation fails a test instead of reaching a screen
as a raw key path. That is the check that caught the bug above. A one-off
script also confirmed all 550 static t() keys resolve in en.json.

Not verified: still nothing run against a live API Server or on a device.

Known gaps, deliberately left: admin_users_screen.dart is still English, and
settings.integrations.* / charging.control.* exist in en.json only. The
second one is not the phone's alone - the Web App has exactly the same gap,
so translating that OCPP and connector vocabulary belongs to both apps in one
pass rather than letting the phone run ahead of the app the strings are
copied from. Both are now recorded in TRANSLATIONS.md, which had claimed the
car screen as untranslated and the web app as complete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:49:48 +02:00
tajniak81andClaude Opus 5 e249c2f4d8 Phone App: connected service, charging cost, a tab picker and data export
Both READMEs claimed web parity with data export/import as the only
omission. That was three gaps out of date: the car screen had no
connected-service tab, no per-car charging-cost tab, and no way to say what
a car's page shows - all three of which the web has had since the car view
became a property of the car rather than of the browser.

The tab bar was the thing blocking the rest. It was a fixed list of eight
Tab(text: "Information") literals, so it could neither grow a tab nor read
an arrangement, and it sat outside the translation system that the rest of
the app has used since b6bb6b1. It now builds from the car's own tabOrder
and hiddenTabs, and labels come from car.tabs.* like the web's.

Rather than retype four subtrees of strings in three languages, the shared
ones - car.*, settings.advanced, forms.charging, forms.import and the
common keys the phone was missing - are copied out of the Web App's own
language files, with the phone's existing wording winning every collision.
Polish and Danish therefore arrive complete and cannot drift between the
two apps. Only five strings are genuinely phone-only: the reorder hint, the
saved-file message, the open action and two validation lines.

The connected-service tab mirrors ProviderPanel: headline readings, the
offer to take a provider odometer that is ahead of the stored one, the
vehicle record, and one collapsible card per capability, rendered from the
server's flattened key/value pairs so a provider adding a field surfaces it
without touching this app. Two deliberate differences. The raw-payload
disclosure is dropped - Toyota's eight sections are megabytes of JSON on a
phone screen, and the flattened fields carry the same content. And the
readings cannot be dragged here, though a stored metricOrder is still
honoured, so an arrangement made on the web carries over.

The view picker takes the same line on gestures. The web rearranges by
dragging the tab bar itself and the Information rows themselves; on a touch
screen that gesture belongs to the tab bar, so both arrangements are made
in the picker with a handle instead, and hiddenTabs, hiddenFields, tabOrder
and fieldOrder all save in one PUT. The key catalogues live in
car_view_sheet.dart and mirror hideableCarTabs / arrangeableCarTabs /
hideableCarFields in cars.go, because the server rejects anything else.
arrangeKeys applies a partial stored order the way the API documents it: an
unknown key is dropped and an unnamed one follows the arranged ones, which
is what puts a tab added in a later release at the end of somebody's page
rather than the middle of it.

Charging cost is the electric twin of Fuel and is built as one - the same
stats panel, tile and form shape, measured between full charges. It is the
per-car cost log, not the Charging section in the bottom bar, which remains
the charger network and OCPP control.

Export and import needed a phone answer to two browser affordances. The
export is written to the app's documents directory under the filename the
server's Content-Disposition names, and offered to whatever opens JSON via
open_filex - the same route attachments already take. The import goes
through the system file picker, validates the file locally, and confirms
with the number of cars the file actually holds, because the server always
creates new records and never merges.

The one field worth calling out on the client: _carPayload still leaves the
provider link and the view arrangement out, matching carPayload in
records.go, so saving the car form cannot silently unlink a car or undo an
arrangement.

Known gap, deliberately not closed here: the older sheets in
record_form_sheets.dart and most of car_detail_screen.dart still carry
hardcoded English. Everything added here and every tab label goes through
t(), but translating the rest of the car screen is its own change and would
have buried this one.

Verified by flutter analyze (clean), flutter test - 13 pass, 6 of them new,
covering arrangeKeys against partial, unknown and duplicate keys, the
charging models keeping uncomputed figures null rather than a plausible
zero, the new Car fields, and ProviderSnapshot parsing an unreachable
provider as an answer rather than a failure - and flutter build apk
--debug, which succeeds. The Kotlin Gradle plugin warnings in that build
are pre-existing.

Not verified: nothing was run against a live API Server or on a device, so
the new screens have not been driven end to end - only compiled, analyzed
and unit-tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:33:58 +02:00
tajniak81andClaude Opus 5 215c027ada Panel: give the API Server a name, and a tab to set it in
The Web App can be pointed at more than one DriverVault, but a server it
adds is only ever identified by the URL that was typed into the connect
dialog. Nothing on the other end says what it is called, so the switcher
has no name to show that the operator did not invent locally.

So the server now carries one. SERVER_NAME joins the config, defaulting to
"DriverVault API Server" so /api/health always has something a client can
display rather than an empty string every caller has to special-case.

GET/PUT /api/admin/server-config follow the pb-config and webapp-config
shape exactly: superadmin only, applied at runtime and then persisted to
.env, with the same "applied but could not be saved" warning when the write
fails. There is no /test sibling, because a name is a label and not an
address - there is nothing to probe. The length cap counts runes rather
than bytes, so a 64-character Polish or Danish name is not cut off at the
halfway mark.

/api/health reports it, unauthenticated, which is the point of the whole
change: a client adding this server by URL can label it from the probe it
already makes, instead of needing a second and authenticated call before it
can draw the entry.

In the panel it is a new API Server tab, first in the superadmin group
since it is this server itself, ahead of the PocketBase and Web App tabs
that describe what it talks to. Strings in all three languages, and the
route table in the README and the API reference tab both grow the two new
endpoints.

Known gap, deliberately not closed here: the compose files do not pass
SERVER_NAME, so under Docker a rename from the panel writes the container's
.env and no volume keeps it - it reverts to the default the next time the
container is recreated. Wiring it as ${SERVER_NAME:-} would make the host
.env authoritative, at the cost of the other trap the previous commit
documented, where the environment silently overrides the panel on every
restart. That is a call about the deployment, not about this endpoint.

Verified by new tests over the handler: the rename applies at runtime,
lands in .env, reaches /api/health, is rejected without touching .env when
blank or over-long, and accepts a name of exactly the limit in multi-byte
runes. go build, go vet and go test ./... pass. Drove the built panel in a
browser against a stub backend - the tab renders, loads the current name,
saves, and reads correctly in Polish - and ran the rebuilt api-server.exe
and webapp.exe end to end, confirming the embedded bundle really contains
the new tab and that SERVER_NAME reaches /api/health through both the
server itself and the Web App's proxy.

Not verified: no Docker build, so the images still serve the old panel
until they are rebuilt and pushed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:59:00 +02:00
tajniak81andClaude Opus 5 9258532952 Docker: give the panel's settings screens a permanent home in .env
ee4ac44 removed the api_data volume, which left the panel's Settings ->
PocketBase and Settings -> Web App screens with nowhere to persist to: they
apply at runtime and the container environment wins again on restart. That
is only acceptable if the environment is actually reachable by an operator,
and for two of those keys it was not - WEBAPP_URL was hardcoded in all four
compose files, and POCKETBASE_URL in the two multi-container ones, so
there was no supported way to change them at all.

Both are now ${VAR:-default} with the previous hardcoded value as the
default, so nothing moves for an existing .env while the keys become
settable. CORS_ALLOW_ORIGINS and the admin credentials already were.

The env examples grow a section naming every setting the panel can also
change, saying plainly that the panel's version lasts only for the life of
the container, and giving the commented-out line to make it stick. It also
records the trap in WEBAPP_URL: it is a container-to-container call, so it
has to be reachable from the API Server rather than from a browser, which
is why the default is a service name and not localhost. POCKETBASE_URL is
described as repointable in the multi-container stack and left alone in the
AIO image, where it addresses that container's own PocketBase.

Also dropped two leftovers from when there were two volumes: the storage
sections still said "either".

Checked by parsing all five compose files and asserting each interpolation
default matches the value it replaced, so this cannot have moved a default
by accident. go build and go test ./... still pass (untouched here). Not
verified: no Docker CLI, so no `docker compose config` render and no build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:38:48 +02:00
tajniak81andClaude Opus 5 ee4ac441be Plugins: drop the plugins.json migration, and the volume it needed
The project has no public installs, so there is nothing to migrate from.
MigrateLegacyFile, the file-backed Store it read through, PLUGINS_FILE and
the legacy path threaded through the Server all go. What is left is one
store, PocketBase, and a plugins package that touches no filesystem at all.

That was the last thing keeping api_data alive, so the volume goes too. All
four compose files now declare exactly one volume, pb_data, and the
standalone API Server compose declares none - it talks to an external
PocketBase and has nothing of its own to keep. Backing up the stack is
backing up one path again.

Both images get simpler for it. The API Server image loses VOLUME /data and
the su-exec entrypoint that existed only to fix a mounted volume's
ownership, so it goes back to a plain USER app; its working directory is
now /app and holds nothing. The AIO image loses its second volume and
chowns only /pb/pb_data.

One consequence worth stating plainly, because it is a small regression
rather than a no-op. The panel's Settings -> PocketBase and Settings -> Web
App screens write .env in the working directory, which is now ephemeral. In
the multi-container stack that changes nothing: compose sets all five of
those keys as container environment, and loadDotEnv only applies a key that
is not already set, so the file could never win a restart there anyway. In
the AIO image it did win for POCKETBASE_ADMIN_EMAIL/_PASSWORD, which are
not in that container's environment - so a service account fixed from the
panel now lasts only until the container is recreated. Both READMEs say so.
Moving those two screens into the app_settings singleton would close it
properly; the PocketBase URL and credentials cannot follow, since they are
how the database is reached in the first place.

go build, go vet and go test ./... pass; the compose files parse and each
resolves to a single pb_data volume. Not verified: no Docker CLI here, so
neither image was built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:41:02 +02:00
tajniak81andClaude Opus 5 660af5736a Plugins: create the settings collection instead of waiting for it forever
01a8fec fixed the advice that led operators into this, but advice is not a
guard: a stack still running PB_BOOTSTRAP=false gets no app_settings
collection on upgrade, and the plugin panel sits at 503 while the retry
loop reads a collection that does not exist.

The fix is not to soften the reading. A missing collection stays "not
ready" rather than "no plugins configured", because the alternative lets
the first save write a fresh document over settings the server merely
failed to find - the failure this whole line of work exists to prevent.
Instead the server now fixes the cause: on a missing collection it creates
that collection and reads again.

Three pieces:

bootstrap.EnsureCollection creates one named collection from the desired
schema if absent, and nothing else. Deliberately narrower than Run - no
field reconcile elsewhere, no super-admin - so it is safe to call on a
deployment that turned the full bootstrap off. It creates the collection
the server cannot start without, not the schema the operator declined.

The store tells a missing collection apart from an outage. A 404 from a
list means the collection itself is gone: an existing but empty one answers
200 with no items. That is tagged errNoCollection, which wraps errNotReady
so every write is still refused, and IsMissingCollection narrows it. The
distinction matters because the remedies are opposites - creating
collections against a flaky database is exactly the wrong reflex, and a
test pins that an outage does not trigger it.

loadPlugins acts on the tag once, then re-reads. Failing to create is
reported as the original read error rather than the repair's, so the log
names the real problem.

Six tests: the tag and its negative in internal/plugins, and three in
internal/api against a fake PocketBase covering the collection being
created exactly once, an existing collection not being recreated, and an
outage creating nothing.

Docs from 01a8fec are corrected in the same pass - they said the panel
would answer 503 forever, which is no longer true. They now say what still
depends on the bootstrap (every other collection and field) and what does
not (app_settings alone).

go build, go vet and go test ./... pass; compose files still parse. Not
verified: no Docker CLI here, so the repair has not been exercised against
a real PocketBase, only the fake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:07:18 +02:00
tajniak81andClaude Opus 5 01a8fecf40 Docker: stop telling operators to turn off the bootstrap that upgrades them
Every deployment file advised setting PB_BOOTSTRAP=false "once the database
is established". That was harmless while the schema was static. It stopped
being harmless in 9bd5c52, which moved the plugin settings into a new
app_settings collection: a stack upgraded with the bootstrap off never gets
that collection, and a missing collection is deliberately read as "the
database is not ready" rather than "no plugins configured" - so the plugin
panel answers 503 indefinitely and the background retry spins forever.

Fixing the advice rather than the reading: treating a missing collection as
empty would let the first save write a fresh document over settings the
server had simply failed to find, which is the failure this whole line of
work exists to prevent.

So all four compose files, all four .env examples, both stack READMEs and
the AIO Dockerfile now say to leave the bootstrap on, including across
upgrades, and name the symptom an operator would otherwise have to guess
at. Turning it off is still supported, but framed as something to do only
for a database known to match the running release.

Compose files still parse as YAML; go build, go vet and go test ./... pass
(untouched by this commit - it is comments and docs only). Note that this
is guidance, not a guard: an operator who sets PB_BOOTSTRAP=false anyway
still ends up in the same place, and the server would have to re-run the
bootstrap when it finds the collection missing to make that impossible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:02:14 +02:00
tajniak81andClaude Opus 5 9bd5c523c4 Plugins: the global layer moves into the database, beside the other two
The integration cascade stored its top layer differently from the two below
it: org (L2) and user (L3) plugin config lived in PocketBase, in a
pluginSettings field, while the global (L1) layer sat in a plugins.json
next to the binary. That split was accretion rather than design - the file
was the whole store in the v1 MVP, and the per-tenant layers were later
built on PocketBase and layered on top of it instead of replacing it.

It also cost something real. plugins.json was a second state store with
different durability from pb_data: its own volume, its own ownership, its
own backup. Losing pb_data is unmissable; losing api_data was silent, which
is how "every plugin comes back disabled after a redeploy" happened.

L1 now lives in the app_settings collection - one record keyed "global",
holding its settings in a pluginSettings field, the same mechanism and the
same field name the layers below use. The documents still differ in shape,
because only L1 carries enable state and the registration of external
plugins, but the storage is no longer a special case.

The Manager grows a Store seam (PocketBase in production, file for the
import, memory for tests) and, more importantly, a loaded gate. Settings in
a database mean the store can be unreachable at boot - a cold stack, or a
service account still to be set from the panel. That must not read as "no
plugins configured", or the first save would write emptiness over real
settings. So until a read succeeds the Manager stays unloaded, every
mutation is refused, /api/admin/plugins* answers 503, and a background
retry backs off to two minutes. The same gate covers a document that will
not parse: it is never replaced by one built from an empty map, which is a
stronger guarantee than the .corrupt backup it replaces.

Writing to a store also revealed a hole in the previous fix. Classifying a
save failure as errPersist was left to each Store, and a store that
returned a plain error would fall through to the "saved, but the plugin
failed to start" branch and be reported as a 200 - the same silent-success
bug through a different door. The Manager now classifies, whatever the
Store returns; a test pins it.

Upgrades are automatic: on the first boot that finds no settings in the
database, an existing plugins.json is imported and renamed to
plugins.json.migrated. The import is refused if the store is merely
unreachable, or if the file does not parse, so a stale or broken file can
never overwrite live settings. /data is still needed - the panel rewrites
.env there when it retargets PocketBase - but plugin settings no longer
depend on it.

21 tests in internal/plugins cover both stores, including the production
path against a fake PocketBase: create-then-update of the singleton,
round-trip across a restart, an outage that leaves settings intact, a
missing collection reading as not-ready rather than empty, and the import
running exactly once. go build, go vet and go test ./... pass. Schema
changes are mirrored into scripts/setup-pocketbase.mjs as that file
requires. Not verified: no Docker CLI here, so no image was built and the
bootstrap of app_settings against a real PocketBase is untested outside the
fake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:52:47 +02:00
tajniak81andClaude Opus 5 d2803bbd93 Docker docs: /data chowns itself now, so stop asking operators to
c173ca3 gave the API Server image a root entrypoint that takes ownership of
/data and then drops to app via su-exec, which left the Volumes note in
Docker/README.md telling operators to do by hand something the image now
does for them.

The replacement says what actually happens, and keeps the two cases the
entrypoint cannot cover: a container forced to another user (`user:` in
compose, `docker run --user`) has no privileges to chown with, so its host
directory still needs preparing. It also points at the new boot warning as
the thing to look for, since the alternative symptom - plugins that enable
normally in the panel and come back disabled after a redeploy - gives no
hint about permissions.

Also states that PocketBase runs as root, so PB_DATA never had this
problem; the old note read as though it applied to both volumes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:39:23 +02:00
tajniak81andClaude Opus 5 c173ca3653 Plugins: a save that fails should say so, not vanish on redeploy
Reported symptom: every plugin comes back disabled after redeploying the
image, having been enabled before it. The persistence design was already
right - each compose file mounts api_data:/data and points PLUGINS_FILE at
/data/plugins.json - so the fault was that a failed write to that file was
invisible. Three defects, each confirmed with a test before being fixed:

A failed write was reported as success. Upsert set rec.Enabled before it
persisted, and the handler folded the resulting error into the same
200-with-warning used for "saved, but the connector failed to start". The
panel reloaded, read the in-memory record and showed the plugin enabled;
only a restart revealed that nothing had reached the disk. A save that
fails now rolls back in memory and returns 500, so the panel row shows the
error instead of "Saved".

A corrupt state file silently wiped the rest. Load returned an error,
main.go logged it and carried on with an empty record set, so the next
toggle overwrote plugins.json and took every other plugin's config with
it. An unreadable file is now moved aside to plugins.json.corrupt, and
persistLocked writes through a temp file + rename so an interrupted write
cannot produce that corrupt file in the first place.

A state file holding "null" panicked the server with "assignment to entry
in nil map" on the next save, and a null entry nil-dereferenced in Load.
Both now decode to "nothing configured".

Two changes make the next such failure loud rather than silent.
StartPlugins probes writability at boot and warns that plugin changes will
not survive a restart. And the API Server image gains the root entrypoint
the AIO image already had - chown /data, then drop to app via su-exec -
because a host bind mount (API_DATA=/srv/...) or a volume created before
/data existed arrives root-owned, and the unprivileged process cannot
write to it.

Not addressed here: a deployment that never reuses the named volume
(docker compose down -v, a renamed compose project, an anonymous volume
from a bare docker run) loses the file whatever the code does. The new
boot warning tells the two apart - writable but empty means the volume is
the problem, not permissions.

go build, go vet and go test ./... all pass. The Dockerfile change is
reviewed but not built: there is no Docker CLI on this machine, so the
su-exec privilege drop follows standard Alpine practice rather than an
observed run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:17:47 +02:00
tajniak81andClaude Opus 5 9abb03ee4f Service history: 0 km is a reading, not a blank
A car collected new sits at 0 km, and every km calculation in the app
quietly refused to work for it. ComputeDerived only filled NextServiceKm
when Km > 0, so a service record entered at 0 produced no next-due
distance at all — the date side worked, because it guards on IsZero(),
which is genuine absence rather than a number that happens to be low.

The same conflation had been copied outward from there. The reminder's
km signal wanted currentKm > 0 before it would count anything down, the
web badge and the service-life ring tested the odometer for truthiness,
formatKm printed an em dash for zero, and fuel and charging rejected a
0 km entry as "odometer (km) is required" — which is the first charge
of an EV on the driveway on delivery day. The phone app carried its own
copy of each. Editing such a car offered an empty odometer box, since
the forms only prefilled a reading above zero.

Everywhere the odometer is a measurement, absence is now tested as
absence: null in the clients, negative on the server, and the required
fields check that the box was filled rather than that the number cleared
zero. Fuel and charging validate Km < 0 instead, and their inputs drop
min="1". Completing a repeating km reminder rolls from the car's actual
reading in every case; the old fallback to the previous target existed
to keep an untracked car off a due date in the past, but CurrentKm +
RepeatKm is ahead of the car by construction, so it could not have
happened.

Left as it was: dueKm, repeatKm and the service intervals, where zero
really does encode "no trigger" and "use the default", and the liters
and kwh checks, since a zero fill is not a fill.

Maintenance is the exception. Its odometer is the one that is genuinely
optional, so zero there still has to mean "not recorded" and those three
sites keep the truthiness test, commented. Fixing that properly wants a
nullable field rather than an int, which is a schema change and its own
commit — the same shape of problem as the latency em dash in 3c4eba8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:26:56 +02:00
tajniak81andClaude Opus 5 f521d2b220 Web App: two sites, one tab — a server you can switch
Two locations means two full stacks, and until now the app could only ever
see the one that served it. The login screen's "Server settings" could point
it elsewhere, but as a global swap: it replaced the server rather than
adding one, and forgot the session you already had.

There is now a server picker at the bottom of the app rail, after signing
in rather than on the login screen. It names the server you are reading —
which matters most when both sites look identical — and switches in a click.

Each server is its own PocketBase with its own users, so nothing is
federated: you sign into each one once and a token is kept per server. The
home server is the one that served the app, reached same-origin through the
BFF proxy; any other is added by address and called straight from the
browser, which asks nothing new of the network — an API Server the phone app
can reach is internet-facing already.

Two things the switch turned out to need:

Views load on mount, so swapping the session alone left B's user looking at
A's cars. The RouterView is keyed on the active server and a switch returns
to the garage, since record ids belong to the server that issued them.

A request now carries the id of the server it went out to, and a 401 clears
that session rather than whatever is active when the answer lands. A page
fires half a dozen calls at once: the first rejection used to switch away
and the rest then cleared the session of the server it had switched to,
turning one stale remote token into a full sign-out.

A remote session expiring falls back to the home server with the entry left
in place to sign into again. Only Log out clears them all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:55:50 +02:00
tajniak81andClaude Opus 5 00d4c17392 Web App: name the tab, so it isn't one of three DriverVaults
The browser tab read "DriverVault", same as the panel, which is no help
when both are open. It's now "DriverVault · Web App".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 10:21:50 +02:00
tajniak81andClaude Opus 5 3c4eba87c8 Status: latency the panel can actually show
PocketBase and the Web App share a Docker network and answer a health
probe in well under a millisecond. Milliseconds() truncated that to 0,
and omitempty on an int64 dropped the zero from the JSON, so the panel
saw no latencyMs at all and rendered an em dash.

Latency is now a *float64 rounded to one decimal, computed from
Microseconds(). The pointer keeps an absent measurement — the API
Server row, which probes nothing — distinct from a genuinely fast one.
pbProbe follows suit, since it copies straight off svcHealth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 10:06:01 +02:00
tajniak81andClaude Opus 5 f08849e50c Docker: a build context that isn't 3.6GB, and health you can see
The all-in-one image builds from the project root, and Docker only reads
.dockerignore from the context root — so the ones under "API Server" and
"Web App" never applied to it and every AIO build shipped the whole tree,
"Phone App/build" included. A root .dockerignore allow-lists the paths that
build actually copies.

The dev split stack passed neither PB_BOOTSTRAP nor the SUPERADMIN vars, so
it created the schema and then no user to log in with. It passes them now,
and .env.example says so.

WEBAPP_URL was never set anywhere, leaving the panel status page probing
localhost:8090 — itself — and always reporting the Web App as down. Each
compose file now points it at wherever the Web App really is, and the BFF
grew a real /healthz instead of letting the SPA fallback answer probes with
index.html and look healthy no matter what.

In the AIO, PocketBase and the API Server drop to an unprivileged user;
only nginx stays root to bind :80. The entrypoint takes ownership of the
two volumes first, so data written by the old root-only image stays
writable. All three images carry a HEALTHCHECK, every compose file declares
one too (so depends_on still gates against an older pulled image), and
web-app waits for the API Server to be serving rather than merely started.

Also: pinned alpine/golang/node and PocketBase 0.39.11, so a rebuild months
from now produces the same image; nginx forwards WebSocket upgrades instead
of stripping them, with the map in http.d where Alpine actually reads it;
and a .gitattributes keeps entrypoint.sh on LF, because a CRLF shebang from
a Windows clone fails at container start with "no such file or directory".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 17:07:52 +02:00
tajniak81andClaude Opus 5 9487de84b0 Docker AIO: a folder name without a space in it
The all-in-one folder is now Docker-AIO, so -f Docker-AIO/Dockerfile
resolves without quoting. Every path that pointed at the old name
follows it: the compose build stanza, the documented build commands,
and the links from the two READMEs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:01:19 +02:00
tajniak81andClaude Opus 5 cc1dafa9f7 Cars: drag the tabs into order, and a lock for every arrangement
Two things, both about layouts you arrange by dragging.

A car's tab bar now takes a drag: the tabs reorder as the pointer crosses
them and the arrangement saves on drop — or on dragend, since a tab
released in the gap beside the bar never produces a drop and would
otherwise revert on the next load. Same native drag events as the garage
and the Information rows, so also pointer-only, and it needs write access.

The order belongs to the car, like the choice of which tabs show at all,
so everyone it is shared with sees the same bar. It is stored as the full
list of keys, hidden tabs included, so a tab switched off and back on
returns to where it was rather than to the end; a key the stored
arrangement doesn't mention — a tab added in a later release — follows
the arranged ones. Information is arrangeable although it cannot be
switched off, which is why the validation needs arrangeableCarTabs rather
than reusing hideableCarTabs; it is derived from that set so the two
cannot drift as tabs are added. tabOrder rides on the existing PUT
/api/cars/{id}/view, so a tab drag never has to resend what is hidden.
Where the page opens is unchanged: Information, wherever it now sits.

And a padlock in the sidebar, above the theme toggle, holds every
arrangement in the app still at once — the garage, a car's tabs, its
Information rows, the provider's readings. It is a guard against nudging
a layout while reading it, not a permission: it is the user's own setting
and says nothing about what anybody may edit, so locking hides your own
drag handles rather than stopping a co-owner rearranging a shared car.
Stored as dragLocked on the profile, like the theme it sits above, so a
locked account is still locked on the next device — where a folded
provider card stays one browser's reading habit. Unlocked by default, so
nothing changes until it is clicked, and while locked the grab cursor and
the drag hints go with the drag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:43:54 +02:00
tajniak81andClaude Opus 5 6191160f14 Cars: log what charging costs, and drag the provider readings
Three things, all on a car's page.

A Charging cost tab, which is the Fuel cost tab written for an electric
car: charges in kWh, consumption in kWh/100km beside km per kWh, cost per
km and price per kWh, and the same summary panel over the whole history.
It keeps the reference-point method too, and has to — a session records
the energy that went in, not what was left in the battery, so a given
number of kWh only maps to a distance between two charges that ended at
the same state. A charge to the car's usual full point plays the part of
the full tank; partial charges still count towards the cost and roll into
the next full one; and a charge taken without logging it leaves its
window uncomputed rather than reporting an implausibly good figure.
Sessions live in their own collection, the figures are derived on read
like the fuel ones, and logging a charge advances the odometer exactly as
a refill does. The tab switches off from the gear like every other, so a
petrol car need never see it.

The headline readings on the connected-service tab now drag into any
order, saved on drop. Stored on the car as metricOrder, like the
Information rows, rather than per device the way the collapsed cards are:
an arrangement is something everyone the car is shared with should see,
where a folded card is one browser's reading habit. Only what the
provider reported can be arranged, so a reading that turns up later joins
the end rather than displacing the arrangement.

Fuel is now Fuel cost, tab and heading, which is what the tab has always
been about and what pairs it with Charging cost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:17:52 +02:00
tajniak81andClaude Opus 5 2b6da642ad Provider: show the electric range with the climate control on
MyToyota reports two range figures for an EV, and only one of them was
reaching the readings: evRangeWithAc was listed as a fallback alias for
evRange, so on a car that reports both — every bZ4X — the first key won
and the second was never shown. It is its own reading now. The gap
between the two is the useful part: it is what running the A/C costs you.

Both are labelled for the pair, "Electric range (A/C off)" beside
"Electric range (A/C on)", so neither figure is left ambiguous now that
they sit next to each other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:58:07 +02:00
tajniak81andClaude Opus 5 bc798dae49 Cars: drag the Information rows into the order you want
The rows on a car's Information tab now take a drag: they reorder as the
pointer crosses them and the arrangement saves on drop — or on dragend,
since a row released in the gap between rows never produces a drop and
would otherwise revert on the next load. Same native drag events as the
garage, so also pointer-only, and it needs write access.

The order belongs to the car, like the choice of which rows show at all,
so everyone it is shared with sees the same page. It is stored as the
full list of the 14 keys, hidden rows included: a row switched off and
back on returns to where it was rather than to the end. A key the stored
arrangement doesn't mention — a row added in a later release — follows
the arranged ones, the same rule the garage uses for a car added since
the last drag.

fieldOrder rides on the existing PUT /api/cars/{id}/view, which writes
only the lists it is given, so a drag never has to resend what is hidden.
A partial arrangement is accepted; an invented key is still a 400, which
is why normalizeHidden is now normalizeKeys — it validates an order as
well as a switched-off set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:52:21 +02:00
tajniak81andClaude Opus 5 049da69c83 Cars: arrange the garage, and choose what a car's page shows
Three things you can now set up rather than live with.

The garage takes a drag: cards reorder as you drag across them and the
arrangement saves on drop — or on dragend, since a card released in the
gap between cards never produces a drop and would otherwise revert on
the next load. It is a per-user list of car ids on the profile, so it
covers cars shared with you and never reorders anybody else's garage;
the API returns /api/cars in that order, so a client only sends the new
one back. Pointer-only: touch browsers don't fire the native drag
events, and this is not worth a dependency.

A car's page is now configurable from the gear in its header: which tabs
it shows, and which of the 14 Information rows. Both belong to the car,
so everyone it is shared with sees the same page — Fuel off on an EV
stays off for all of them — and setting them needs write access. Stored
as the hidden sets, so anything added in a later release is on by
default. PUT /api/cars/{id}/view is its own endpoint precisely so an
ordinary save of the car form, which sends every other field, can never
reveal something that was deliberately switched off. Information itself
can't be hidden: a page with no tabs left would be a dead end.

The connected-service cards fold away, remembered per device, so a
provider that reports eight sections can be trimmed to the two worth
watching. A failed section keeps a short badge in its collapsed header
and puts the provider's own message — a few hundred characters of JSON,
which used to stretch the page sideways — inside the body with
everything else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:29:23 +02:00
tajniak81andClaude Opus 5 e373497958 Settings: fold Users and Organization into the Settings tabs
The left rail is back to the three places you actually go — Garage,
Charging, Settings — and user management moves inside Settings as an
admin-only tab, next to a new Organization tab that used to be a card
buried in the personal settings. Tab order is Personal settings, Users,
Organization, Integrations. /admin redirects to /settings?tab=users so
old links keep working, and ?tab= picks the starting tab in general.

AdminUsers moves from views/ to components/ since it is a panel now, not
a route, and its page header becomes a section header like its
neighbours. The personal panel was split in two around the integrations
markup, which left no gap between the Profile and Privacy cards; it is
one block again.

Creating a user gets an organization picker for superadmins, defaulting
to "no organization" so an org-less account stays a deliberate choice.
Admins see no picker: the server pins their members to their own org
regardless, which users_test.go now covers along with both superadmin
paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:38:54 +02:00
tajniak81andClaude Opus 5 cd16d4383f Orgs: let any user create an organization and become its admin
Organization writes were superadmin-only, so standing up a tenant needed
an out-of-band superadmin. Creating one is now self-service, and an admin
manages the org they belong to.

- POST /api/orgs is open to any authenticated user. A creator who isn't a
  superadmin must have no organization yet (a single-valued membership
  relation means a second one would abandon the first), and is promoted to
  the new org's admin and first member in the same request. If that
  promotion fails the org is rolled back, so it is never left stranded
  with nobody able to administer it. Superadmins still create tenants
  without joining them.
- PATCH/DELETE are manager-gated and scope an admin to their own org. An
  admin deletes theirs only as its sole member: they are detached and
  demoted to a plain user before the record goes, so the org is empty when
  it is removed. Other members still block deletion with a 409.
- /api/me now carries organization + organizationName, which the clients
  need to tell "no org yet" from "org you administer".

The panel, Web App (new OrgManager.vue in Settings) and Phone App (new
_OrganizationSection) all mirror the server's gates rather than
re-deciding them. The Phone App cached its role at login and gates the
Users tab on it, so AuthService.adoptRole refreshes that from the profile
instead of making a freshly promoted admin sign in again.

Covered by orgs_test.go, which drives the real handler + middleware chain
against a stand-in PocketBase: promotion, the already-a-member refusal,
superadmin staying unattached, the rollback, own-org scoping, the
detach-and-demote, and the blocking-member 409.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:54:02 +02:00
tajniak81andClaude Opus 5 358ee68f94 Cars: create a car from a manufacturer service, with a per-car data tab
A car can now be imported straight from the account its owner already has
with the manufacturer, and every reading that service exposes shows up on
the car's own tab. MyToyota is the first provider.

API Server — internal/api/vehicleproviders.go adds a generic layer over a
plugin that can enumerate vehicles and read data about them. Adding the
next manufacturer is one vehicleSource adapter plus a line in
vehicleSources(): no new endpoints, no Web App changes.

  GET  /api/vehicle-providers                     providers + connect state
  GET  /api/vehicle-providers/{p}/vehicles        the caller's vehicles
  POST /api/vehicle-providers/{p}/import          create a car from one
  GET  /api/cars/{id}/provider                    live snapshot for the tab
  POST /api/cars/{id}/provider                    link / unlink a car
  POST /api/cars/{id}/provider/sync               re-apply provider data

Two properties shape it. Credentials are always the caller's own, resolved
through the same global -> org -> user cascade as the integration settings,
so a shared car shows provider data only when that vehicle is on the
viewer's account — the owner's credentials are never borrowed. And upstream
shapes are not modelled: these are unofficial APIs, so the layer searches
payloads by key name for the readings worth promoting (odometer, fuel,
battery, range) and flattens the rest to dotted key/value pairs alongside
the raw JSON. A renamed field costs one blank value, not a broken page.

The Toyota gate and its wording now live in toyotaSource, so the older
/api/integrations/toyota/vehicles endpoint and the new ones cannot drift.

Manager.InvokeBatchWith shares one transient plugin instance across a batch
of actions. The tab pulls seven capabilities, and InvokeWith builds a fresh
instance per call — which for a connector that authenticates lazily means a
fresh OAuth login per call. Batching logs in once.

cars gains provider + provider_vehicle_id (schema.go and
setup-pocketbase.mjs both). carPayload deliberately omits them, so an
ordinary car edit can neither reassign the car nor break its link;
carProviderPayload writes the link on its own.

Web App — Dashboard grows an "import from service" button beside "add car",
shown only once an account is connected, opening CarImportModal: pick the
vehicle, choose what to pull (identity / fuel type / dates / odometer, all
on by default), import. ProviderPanel becomes the car's first tab, ahead of
Information, labelled with the service: headline readings, the vehicle
record, one card per capability with its raw response, and an offer to take
the provider's odometer when it is ahead of the stored one. On an unlinked
car the tab instead offers to link it, VIN-matched. Info stays the default
selection — landing on the provider tab would fire a login on every car
page view. Full en/pl/da translations.

Tests cover the payload walking, Toyota normalization, import-selection
defaults, and — through the real handler chain against a stand-in
PocketBase — that every route is registered and that a closed gate is soft
on a listing (200 + a reason the UI can show) but hard on a write (4xx, so
a caller cannot read the reply as a created car).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:13:11 +02:00
tajniak81andClaude Opus 4.8 47a9aef466 Dev: add web-bff launch config for the production BFF on :8090
Runs the Web App Go BFF (go run . in Web App/server) that serves the
embedded dist and proxies /api -> :8080, so the compiled bundle can be
verified locally alongside the Vite dev server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 11:40:53 +02:00
tajniak81andClaude Opus 4.8 8ff4b31010 Web App: order the fonts @import before Tailwind so Archivo loads
Tailwind v4 inlines @import "tailwindcss" into many rules, which pushed
the Google Fonts @import after them and violated CSS's "imports must come
first" — PostCSS dropped it and the Archivo/DM Mono webfonts never loaded.
Move the fonts import above the Tailwind import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 11:40:45 +02:00
tajniak81andClaude Opus 4.8 a0eb5e4e9d Docs: refresh every README against the current code
Verified each documented command, path, port and env var against what the
code actually does, and corrected the drift.

Phone App. Was still titled Car Control. The navigation description was
also stale: the app moved to a RootShell bottom nav (Garage, Charging,
Settings, and Users for admins), so the Settings gear and admin action
the dashboard bullet described no longer exist. Adds the Charging screen,
noting its public tab is placeholder data and only the Home tab's OCPP
control is real, and rebuilds the lib/ tree, which had lost i18n.dart,
theme.dart, widgets/ and three screens.

Web App. Node 18+ was wrong. The installed Vite is 8.1.2, whose engines
field is ^20.19.0 || >=22.12.0 - Node 18 is EOL and cannot build this.

API Server. The config table gained OCPP_REQUIRE_TLS, OCPP_PUBLIC_URL,
PB_BOOTSTRAP and DRIVERVAULT_SUPERADMIN_*, plus a note that PLUGINS_FILE
and the panel-written .env resolve against the working directory (a
volume, in Docker).

Plugins. Per-tenant credentials sat under "not yet implemented", but
/api/integrations/* has done exactly that for both built-ins for a while.
Narrowed the roadmap item to the genuinely missing generic version.

New Docker/README.md and Docker AIO/README.md: the root README's
component table linked those directories as documentation but neither had
any. The root README now points at them.

All 8 markdown files pass a relative-link check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:46:21 +02:00
tajniak81andClaude Opus 4.8 01d82ebda9 Docker: persist API Server state, wire up OCPP, drop legacy env names
Audited every Dockerfile, compose file and .env.example against the code
they deploy. Four things had drifted:

Persistence. The API Server writes plugins.json and rewrites .env (the
panel's retarget-PocketBase flow) relative to its working directory,
which was a root-owned /app while the process runs as the app user - so
both writes failed, and no volume was declared to keep them anyway. The
binary moves to /usr/local/bin and the working directory becomes a /data
volume owned by app. The AIO image gets the same via directory=/data on
its supervisord program.

OCPP. Charger control was undeployable: OCPP_REQUIRE_TLS defaults to true
and appeared in no Docker file, so a charger dialling the plain-HTTP
/ocpp/{serial} was rejected with nothing explaining why. Both OCPP vars
are now threaded through the compose files and env examples, with the
reasoning (the per-charger control token rides in a Basic-auth header).

CORS. API Server/docker-compose.yml defaulted to localhost:5173, the Vite
dev port, where every other file uses 8090.

Env names. .env.example has called PB_URL/PB_ADMIN_*/PORT legacy for a
while, but the Docker layer still used them. Container-side names are now
POCKETBASE_*/API_ADDR; the .env keys operators set stay PB_ADMIN_* so
existing .env files keep working.

Left alone deliberately: alpine:latest stays unpinned (cannot verify
current tags or test the build from here), and the golang/node bases
already match go.mod and Vite 8's floor.

Validated as YAML only - there is no Docker CLI on this machine, so no
image was built and the /data ownership fix follows standard volume
semantics rather than an observed run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:46:01 +02:00
tajniak81andClaude Opus 4.8 101df8d210 Phone App: shrink the Android launcher icon
Raises the adaptive-icon foreground inset from 16% to 25%, so the mark
occupies 54 of the 108dp canvas — well inside the 66dp safe zone — and
reads smaller against the launcher background.

Affects API 26+ only. minSdk is 24, so Android 7.0/7.1 still falls back
to the unchanged legacy mipmap rasters.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:45:00 +02:00
tajniak81andClaude Opus 4.8 8f876db19c Phone App: rename Dart package to drivervault_phone
Follows the Android package rename: updates the pubspec name, the
package: imports in the tests, the web title/manifest strings, and the
project name in the README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:29:46 +02:00
tajniak81andClaude Opus 4.8 fee4fda974 Phone App: rename Android package to com.drivervault.phoneapp
Updates the Gradle namespace/applicationId, moves MainActivity.kt to the
matching source directory, and refreshes the package id in the README.

The Dart package name in pubspec.yaml is unchanged. Since the application
id changed, existing installs will not upgrade in place and their local
secure-storage/prefs data does not carry over.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:03:16 +02:00
tajniak81andClaude Opus 4.8 a8416d97d8 Phone App: fix stretched logo mark on login screen
Match DriverVaultMark geometry to the canonical brand icon
(drivervault-icon.svg): 48-unit box, 6-wide bars at heights
16/24/32, vertically centred. The bars were previously ~35%
too tall and bottom-pinned, which read as vertical stretching.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:40:06 +02:00
tajniak81andClaude Opus 4.8 56f7f85958 Phone App: declare INTERNET permission for release builds
Flutter only adds android.permission.INTERNET to the debug and profile
manifests, so release APKs shipped with no network access at all — the app
could not reach the API Server. Declaring it in the main manifest fixes
release/production builds; debug was unaffected because its own manifest
already granted it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:28:02 +02:00
tajniak81andClaude Opus 4.8 13f50aa9a4 Phone App: add Charging screen + Settings integration tabs
Brings the Flutter app to parity with the recent Web App changes, which
touched features the Phone App did not yet have — so this builds the
Charging and Integrations subsystems, then applies the tab/fold structure.

Charging (new nav destination): split into "Public chargers" (stylized
discovery map + demo session + nearby public stations) and "Home chargers"
(the real Anker Solix OCPP control card — serial refresh, connector/energy
tiles, start/stop, current limit, password step-up on reset — plus the
user's home charger list). Gated by the per-user control mode, degrading to
a Settings hint when off.

Settings: split into "Personal settings" (the existing account/appearance/
profile/security/privacy/danger sections) and "Integrations". The latter
holds foldable Toyota and Anker Solix cards over the superadmin -> org ->
user cascade: locked fields with "inherited from" notes, org-admin scope
switch, enable toggle, save/test with health result, and Anker OCPP token
provisioning. Both tabs stay mounted (IndexedStack) so in-flight edits
survive a switch.

Adds the integration + OCPP control endpoints to api.dart, the resolved
IntegrationView/Scope/Field, IntegrationHealth and AnkerControl models, and
charging.*/settings.tabs.*/nav.charging strings (en/pl/da) plus
settings.integrations.* (en) — mirroring the Web App's own pl/da coverage,
which leaves integrations and charger control untranslated as an English
fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:03:52 +02:00
tajniak81andClaude Opus 4.8 30c9cdebe9 Add production Docker compose + on-startup PocketBase bootstrap
Introduce registry-pull production stacks (docker-compose.prod.yml) for
both the multi-container Docker setup and the all-in-one Docker AIO image,
with everything an operator needs (superuser, super-admin, ports, volumes)
driven from .env.

The API Server now bootstraps PocketBase on startup: a new internal/bootstrap
package (Go port of setup-pocketbase.mjs) creates missing collections,
reconciles existing ones, and creates the DriverVault super-admin from
DRIVERVAULT_SUPERADMIN_* when absent. Idempotent and gated by PB_BOOTSTRAP.
The PocketBase superuser is still upserted by the PocketBase container, since
the REST API cannot bootstrap the first superuser.

Move PocketBase to port 8070 (internal + published) and the web app to 8090
across both stacks, with matching CORS defaults.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 15:35:33 +02:00
tajniak81andClaude Opus 4.8 1e76c2b7f9 Refresh docs and fix Docker builds for current layout
READMEs: correct the auth model (PocketBase token relay, not JWT/sessions),
document the full feature set (technical checks, fuel, maintenance, documents,
reminders, attachments, integrations, OCPP charging control), the shipping
built-in connectors (toyota, anker-solix), and the current endpoint surface.

Docker: build against the current repo layout — Go 1.26, cmd/server entry
point, Web App source under web/. Add the missing Web App Dockerfile (Go BFF)
and .dockerignore, drop the obsolete AUTH_SECRET, modernise CORS var naming,
and standardise on drivervault-* naming.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 11:05:46 +02:00
tajniak81andClaude Opus 4.8 f9b1bcc520 Web App: fold integration cards; split Charging into two tabs
Settings → Integrations: the Toyota and Anker Solix cards are now
collapsible, like the plugin rows in the API Server panel. Each header
is a toggle with a rotating chevron; the Connected badge stays visible
when collapsed. Bodies use v-show so loaded state and in-flight edits
survive folding. Collapsed by default.

Charging: split into "Public chargers" and "Home chargers" tabs. Public
keeps the discovery map (public pins only) + demo session + nearby
public stations. Home holds the real OCPP control card (moved here) and
the user's home charger list, with a hint pointing to Settings when no
control mode is active. Adds charging.tabs.*, stations.homeHeading and
stations.noControlHint (en/pl/da).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 00:22:38 +02:00
tajniak81andClaude Opus 4.8 8656aaaee3 Split Web App Settings into tabs; allow unset plugin Control mode
Web App: Settings is now two tabs — Personal settings (account,
appearance, profile, privacy, advanced/danger) and Integrations
(Toyota, Anker Solix). Panels use v-show so loaded state and in-flight
edits survive a tab switch. Adds settings.tabs.* labels (en/pl/da).

API Server panel: non-required select config fields now render a
leading "Not set" option, so a superadmin can leave the Anker Solix
Control mode (and Toyota Brand) unset at the global layer. Previously
every option was a real value, forcing a pick that won the cascade and
locked organizations and users out of choosing their own mode. The
server side already treated an empty value as "abstain"; this closes
the UI gap. Adds plugins.notSet label (en/pl/da) and rebuilds the
embedded panel dist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 00:02:37 +02:00
tajniak81andClaude Opus 4.8 318e9c1670 Add Car Agent Device firmware (LILYGO TTGO T-SIM7600, ESP32/SIM7600)
ESP32 + SIM7600 cellular firmware for the in-car agent device: WiFi/AP
provisioning with a web admin page, GPRS/LTE connectivity, and IMEI-based
identification. The hardcoded default WiFi password has been replaced with a
YOUR-WIFI-PASSWORD placeholder so no real credential enters git history.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 21:07:45 +02:00
tajniak81andClaude Opus 4.8 19a7d48feb Harden Anker Solix OCPP control (token hashing, step-up, audit, TLS)
Security pass over the OCPP charger-control feature added in a1519f6, since
remotely actuating a physical charger is a real side effect.

Token hygiene:
  - Per-charger control tokens are stored as SHA-256 hashes + a last-4 hint,
    never plaintext. The token is shown once at generation; the status endpoint
    returns only the hint. Added a revoke endpoint that also drops any live
    session using the revoked token.

Step-up + confirmation:
  - Destructive actions (reset, unlock) require confirm:true AND a password
    re-authentication (verified against PocketBase). The Charging UI collects the
    password inline for reset.
  - Per user+charger rate limit (30/min) on control commands.

Transport + provenance:
  - OCPP_REQUIRE_TLS (default on) rejects plaintext ws:// charger connections;
    OCPP_PUBLIC_URL pins the advertised endpoint instead of trusting request
    headers.
  - Proxy-mode upstream URL is validated against a *.anker.com allowlist, so a
    spoofed ocpp-info response can't redirect the proxy.

Durable audit:
  - New control_audit PocketBase collection (added to setup-pocketbase.mjs);
    every control action, token generate/revoke and charger connect is persisted
    best-effort in addition to a structured log line.

Startup:
  - The control-token index is warmed from PocketBase on startup so a charger
    reconnecting after a restart resolves immediately.

Tests:
  - Unit tests for token hashing/eviction/revoke (no plaintext at rest),
    rate limiter, destructive-action classifier, upstream allowlist, TLS
    enforcement, and re-auth guards. A full-stack E2E (control_e2e_test.go)
    drives the real Handler with a stand-in PocketBase and a simulated charge
    point, proving step-up (400/401/200) and audit persistence end to end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 21:03:05 +02:00
tajniak81andClaude Opus 4.8 a1519f6e89 Add OCPP control for the Anker Solix EV charger (Own/Proxy CSMS)
The Anker Solix connector was read-only (cloud monitoring only). Add an
OCPP 1.6J control path with a per-user, cascading control mode:

  - off   monitoring only (default, unchanged behavior)
  - own   DriverVault is the charger's Central System (full control)
  - proxy DriverVault relays to Anker's cloud and injects commands

New internal/ocpp subsystem (stdlib-only, hand-rolled RFC 6455): a CSMS
with session management, inbound dispatch, and typed control commands
(RemoteStart/Stop, SetChargingProfile current limit, ChangeAvailability,
Reset, UnlockConnector, TriggerMessage, Get/ChangeConfiguration). Own- and
proxy-mode paths are verified end-to-end against a simulated charge point.

The charger connects to /ocpp/{serial}, authenticated with OCPP Basic auth
(serial + a per-charger control token) resolved to the owning user via an
in-memory token index. Control REST endpoints mirror the monitoring ones and
reuse the same cascade gate plus a live-session check. controlMode is a new
cascade field (global -> org -> user) advertised as a select on the plugin.

Frontend: control-mode select + provisioning card in Settings, and a real
Start/Stop/limit/reset control panel in Charging, gated on the active mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:40:22 +02:00
tajniak81andClaude Opus 4.8 367113f538 Default the Web App to :8090 (BFF) in the API Server
Point the API Server's Web App references at the production BFF port
instead of the Vite dev port (5173): the WEBAPP_URL default probed by
/api/status, and the panel's Web App URL / allowed-origins placeholders.
Update .env.example and the README env table to match. Rebuild the
embedded admin panel dist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:33:26 +02:00
tajniak81andClaude Opus 4.8 2eeff15925 Add a Charging tab to the web app sidebar
Mirror the web-dashboard UI kit's charging screen: a map panel with
charger pins, an active-session card, and a nearby-stations list. Adds
the sidebar nav item, the /charging route, and the Charging view, plus
en/pl/da translations. Session and station data are presentational
placeholders until the server exposes charging telemetry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:01:44 +02:00
tajniak81andClaude Opus 4.8 52d9614bad Wire the Anker Solix EV charger into the per-user integration cascade
Expose the anker-solix plugin to end users the same way as Toyota: a
global -> org -> user settings cascade so everyone can run it under their
own Anker account, with a superadmin (and org admin) able to impose
settings from above.

API Server: integrations_ankersolix.go mirrors integrations.go with the
Anker fields (email + password resolve as a pair from the highest layer,
country resolves on its own), plus GET/PUT/health/chargers routes under
/api/integrations/anker-solix. Secrets and inherited emails are masked;
the live probe runs server-side under the resolved credentials.

Web App: api.js client calls, a second Integrations card in Settings.vue
(scope switch, enable toggle, email/password/country with locked-field
inheritance notes, save + test), and the en.json strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:46:16 +02:00
tajniak81andClaude Opus 4.8 0793b5ec8e Add a built-in Anker Solix V1 Smart EV Charger plugin
A Go re-implementation of the auth and read-only data flow from
thomluther/anker-solix-api, scoped to the V1 Smart EV Charger (A5191)
and adapted to DriverVault's plugin contract.

Login is a custom ECDH (P-256) + AES-256-CBC password exchange against
passport/login, yielding a ~7-day auth token plus gtoken = md5(user_id)
for subsequent requests; a fresh login covers expiry and 401/403. The
country code routes to the EU or global Anker server.

Exposes read-only capabilities (chargers, charger-status, charge-stats,
charge-orders, ocpp-info, devices, sites, vehicles) with a health check
that reports the bound-charger count.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:12:45 +02:00