Commit Graph
15 Commits
Author SHA1 Message Date
tajniak81andClaude Opus 5 3f9d5b943f Four questions asked of an account that answers twenty-one
The connector called ten endpoints of the read surface the map lists, and
the charger card showed four views. Everything else an EV charger can
reach is now a capability too: the sessions and the history, the savings,
the sharing, the binding, the group, the Wi-Fi, the firmware and its
update log, the tamper records, the site's own detail, price, networks
and energy — plus the vehicle catalogue, dynamic pricing, the currencies
and the notification views. Thirty-eight endpoints, one action each. The
two message views are GET, so the request path grew a GET half that shares
the login retry with the POST one.

The per-charger fan-out asks all of them, six at a time rather than one
after another, and a charger that belongs to a site brings that site's
four views with it once the by-serial lookup has found it. A view that
answers with nothing now says so instead of vanishing: the station record
is empty for a standalone charger because it has no station, which is an
answer worth reading. And "source 0" in the OCPP box carries the address
the account's endpoint list gives it.

Anker's account-level writes stay out, as do the endpoints whose payloads
were only ever read out of the app package.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 22:53:36 +02:00
tajniak81andClaude Opus 5 1a7f04cba0 A dash printed above the value it was missing
The Charger information card read "—" beside State and OCPP status while the raw
block three rows below it printed chargerStatus 1 and ocpp_connect_status 2. The
account had answered both. The merge asked for the state as evChargerStatus,
operating_state or status, which is how the standalone and station views spell
it, and the bound-device view — the one this account actually answers from —
spells it chargerStatus. The OCPP state it never asked that view for at all. All
three views now read through one fillDevice, which tries every spelling a view is
known to use, so a value any of them sends reaches the row that was drawing a
dash for want of it.

The same views were carrying the whole box-on-the-wall half unread: the Wi-Fi
network and its MAC, the signal strength, the Bluetooth MAC, the time zone, when
the account bound the charger, how the app can reach it — BLE, Wi-Fi — and the
product shot for the model, which now sits beside the charger's name in both
apps. Named rows, in three languages, the way the register map's readings are
named.

One field wanted the opposite treatment. The device record carries blue_password,
the charger's own Bluetooth pairing password, and the card was printing it in
clear into every screenshot anyone takes of that page. Any leaf key holding a
password, secret, token, private key or certificate is now masked in the raw
block: that the field exists is worth reporting, its value is not.

Four endpoints answer only when a serial is named, so none of them could belong
to the list the card is drawn from, and nothing had ever called them. The station
record, the charging totals, the OCPP backend and the RFID cards now arrive
through a charger-details capability behind
GET …/anker-solix/chargers/{sn}/details, asked for the charger being looked at,
best effort, each view reporting its own failure — an account that is not the
owner cannot read the cards, which is a fact about the account rather than an
error in the read.

Those four are shown under the cloud's own keys, and that is not an oversight.
The REST map documents which endpoints exist and what each is for; it does not
document a single one of their payloads. Naming those fields is the next commit,
made from what actually comes back, now that there is somewhere to see it.

Not touched: the endpoints the map marks ready but unwired — session history,
site price, OTA, sharing, notifications — each a feature rather than a row on this
card; and the unmapped ones, which the map warns delete sessions and unbind
devices with payloads nobody has ever seen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:45:08 +02:00
tajniak81andClaude Opus 5 641f427db4 The chargers you own, on the phone as well
The web's Charging page grew a real home half while the phone kept a demo one.
There, a charger is a record imported from a connected service; here it was a
hardcoded row called "Home charger", and the only real thing on the tab was a
single OCPP control card. Modbus had been a working transport for a while, and
the phone had no way to give it the address it needs.

The home tab is now the four cards the web shows, about whichever charger is
picked, and the list of the ones you have imported. Control acts on the charger
and offers what the transport actually has — boost on Modbus, clear-limit and
reset on OCPP. Connection asks for a serial or an address depending on which,
and falls back to a text box for a serial the account does not list. Readings
render the Modbus snapshot the way it gets asked about: the per-phase matrix,
what the charger is doing, what it is set to, what it is, and any alarm word.
Information stands without a control mode at all, because what a charger is is
known either way; beside it the service's own view of whether it is reachable,
asked for when the tab is opened rather than on every build.

Rearranging is the one place the two apps differ, for the reason the car's view
picker already differs: the web drags the tab bar and the card headings, and on
a touch screen the bar owns that gesture and a heading is the fold toggle. Both
arrangements are made in a sheet with handles instead, and still saved to
chargerTabOrder / chargerCardOrder on the profile — so an arrangement made in
either app shows up in the other. Which cards are folded stays on the device.

Settings groups its integrations into the same categories the API Server panel
does, says Online as well as Offline, and shows firmware in a charger's line.
Shared strings are copied out of the web's i18n files rather than retyped, per
TRANSLATIONS.md; only the arrange sheet's own three are written here.

The readings and information cards look up some sixty keys by name at render
time, so models_format_test now guards those the way it guards the car's — an
enum value is deliberately left out, since a charger may report a number this
release has never heard of and falling back to it is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 08:27:16 +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 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 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 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 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 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 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 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 ee28b522c7 Bring the Phone App up to parity with the web app
Four rounds of web-app features never reached the phone: fuel, maintenance,
document and reminder tracking; attachments; the currency setting and the
locale split; and technical check history. The README claimed full parity
throughout, so the gap was invisible. Catch the phone up, mirroring the web
components field for field.

Car detail grows the web app's tabs, in its order: technical checks,
maintenance, fuel (with the summary panel), documents and reminders, beside
the existing service and parts lists. The derived figures are the server's
and are rendered as "—" wherever it sent null — a window with a missed fill
has no consumption, and a plausible-looking 0.0 there would be a lie.

Attachments hang off service records, technical checks, workshop visits,
refills, documents and parts on identical terms, so one field and one apply
helper cover all six rather than being copied per form. As on the web, the
form only collects intent: the file endpoints address a record that must
already exist, so a create-with-file is two calls, and a failure on the
second reports as an attachment error because the metadata is committed.

Two bugs fixed on the way:

- _carPayload omitted technicalCheckIntervalDays. The API rewrites every
  column from the body, so any car edit — including the one-tap odometer
  update — silently zeroed the car's inspection interval.
- main() never called initializeDateFormatting, so month names ignored the
  chosen language that the new Language picker exists to set.

Luxembourgish and Romansh are deliberately left off the language list: intl
ships no symbols for them and throws rather than falling back, which would
take out every date on screen. The browser has full ICU data and has no such
limit, so the web app can offer them. The server only validates a locale's
shape, so an unrenderable tag can still arrive from the web; format.dart
resolves through a supported-language check and falls back to en-US.

Labels for the language/region/currency lists are hand-kept because Dart has
no Intl.DisplayNames. The lists mirror validCurrencies in me.go.

file_picker is pinned to ^10: v8 compiles against android-34, which no longer
builds against the other plugins' compileSdk requirement of 36.

Adds the project's first test, covering the parts that fail silently rather
than loudly — null derived fields, the badge wording, and the locale guard.

The phone was not authorized over ADB, so the UI was not exercised on a
device: this is analyzer-, test- and build-clean, and every JSON field name
and route was cross-checked against models.go and server.go.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:15:01 +02:00
tajniak81andClaude Opus 4.8 ae6ed4ac1e Rebuild API Server on the PilotVault structure
Mirror PilotVault's API Server layout and add the superadmin console,
plugin system, runtime PocketBase settings, and user/organization
management. The car domain (cars, service records, parts, sharing) is
carried over unchanged apart from the auth switch.

Layout: main.go -> cmd/server/main.go; module carcontrol/api ->
drivervault/apiserver. internal/api is split by concern (auth, users,
orgs, settings, plugins, status, health, respond).

Auth: replace the server-minted HS256 JWT and the sessions collection
with a PocketBase token proxy. /api/auth/login relays PocketBase's
{token, record}, and every protected request re-resolves that token
against PocketBase, so a role change or deletion takes effect at once
instead of waiting out a token. AUTH_SECRET is obsolete and internal/auth
is gone. Per-device session listing/revocation goes with it: PocketBase
tokens are stateless. Changing a password rotates the user's token key,
which invalidates every token already issued.

Roles: add superadmin alongside user/admin, plus an organizations
collection and users.organization. Admins are scoped to their own
organization; superadmins span all of them. Guards prevent changing your
own role, deleting your own account, an admin touching a superadmin, and
deleting an organization that still has members.

Plugins: new internal/plugins package with one contract over two kinds --
builtin (compiled in) and external (any HTTP service, registered at
runtime with no rebuild). State persists to plugins.json; secrets are
masked on read and preserved when saved back at the mask.

PocketBase settings: /api/admin/pb-config applies a new connection at
runtime and persists it to .env. It deliberately does not require a
working service account, so a wrong or unreachable connection can still
be fixed from the panel.

Panel: rebuilt as the superadmin console -- login gate, status, users,
organizations, PocketBase, plugins, and the endpoint reference.

Clients: update the Web App and Phone App for the PocketBase token shape,
the move of user management to /api/users ({users}/{user} envelopes, with
password resets folded into PATCH), and the removal of sessions. Both now
mirror the server's real guards rather than the old last-admin rule, and
parse PocketBase's field-level error shape.

Config: modern POCKETBASE_*/API_ADDR names with legacy PB_*/PORT
fallbacks, so existing .env files keep working. Also fixes /api/status
probing the Web App on 8090 instead of DriverVault's 5173.

Run scripts/setup-pocketbase.mjs to add the organizations collection and
grow users.role; every client must log in once more.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 22:29:45 +02:00
tajniak81 ba3f227361 Initial commit 2026-07-06 08:50:52 +02:00