f7472bada347f758869a719a340709f3a02e29dd
51
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
19a7d48feb |
Harden Anker Solix OCPP control (token hashing, step-up, audit, TLS)
Security pass over the OCPP charger-control feature added in
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
5e435c5f77 |
Add per-user Toyota integration with a settings cascade
Let each user run the Toyota Connected plugin under their own MyToyota
credentials and enable/disable it for themselves in the Web App, while a
superadmin (and, in an organization, an org admin) can impose settings
from above. Resolution is a cascade — top wins, and a lower level only
fills fields the levels above left blank:
- org user: API Server (superadmin) -> org admin -> user
- org-less user: API Server (superadmin) -> user
The MyToyota email + password resolve together as a pair from the highest
layer that supplies an email; brand resolves on its own; enablement is
strictly per-user, gated by the global master switch and the org gate.
API Server:
- plugins.Manager gains RawConfig / HealthCheckWith / InvokeWith so the
cascade can read global config and probe/invoke under a per-caller
resolved config.
- internal/api/integrations.go resolves the cascade and serves
GET/PUT /api/integrations/toyota, POST .../health, GET .../vehicles.
Secrets and inherited usernames are masked before leaving the server.
- The toyota builtin's credentials are no longer required at the global
layer, so the master switch can be enabled without global credentials.
- setup-pocketbase.mjs adds a pluginSettings JSON field to the users and
organizations collections (the user and org layers of the cascade).
Web App:
- api.js gains getToyota/saveToyota/testToyota.
- Settings grows an Integrations section: an enable toggle, credential
fields with locked / "inherited from" states, a brand select, an
org-scope switch for admins, and a live test-connection button.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
b6bb6b1df0 |
Add a language-switch system with per-language files
Introduce a hand-rolled i18n layer across all three UIs, each reading its
text from per-language JSON files (English base + Polish + Danish). Nothing
in the converted screens hardcodes English any more.
- Web App (Vue): src/i18n/{en,pl,da}.json + index.js exposing t()/tSplit(),
reactive to the signed-in profile locale. Every view, component, form and
the status labels in lib/format.js go through t().
- API Server panel (Vue): src/i18n/ with its own localStorage-persisted
language (the panel has no user profile) and a header language picker.
Chrome, cards, login and API section titles translated; endpoint reference
descriptions intentionally kept in English. Rebuilt embedded dist.
- Phone App (Flutter): assets/i18n/ + lib/i18n.dart loaded at startup,
driven by AppSettings.locale. Nav, login, lock, dashboard, the full
Settings panel (incl. language picker) and format.dart status labels
translated; remaining detail screens fall back to English.
Language = the language half of the existing BCP-47 locale; the region half
still drives date/number/currency formatting. Missing keys fall back to
English, and plurals use Intl.PluralRules / Intl.plural so Polish gets the
correct one/few/many forms. Settings flags languages without a translation.
Tests updated to assert the localized (Polish) status wording; all pass.
See TRANSLATIONS.md for the format and how to add a language.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
f77cbf0e73 |
Add LPG bi-fuel and hydrogen fuel types
Add petrol_lpg, diesel_lpg and hydrogen to the fuel_type choices, across the six places that define them: the PocketBase schema, the car form and detail view in both the Web App and the Phone App, and the two doc comments that enumerate the values. The Go API needed no change — it passes fuel_type through as a free string, so PocketBase is the only validator. Model the LPG conversions as their own choices rather than a separate "has LPG" flag: the car runs on either tank, so "petrol + LPG" is what an owner picks it out as. Order each variant next to its base fuel so the dropdowns read naturally. Purely additive — existing rows keep their values and need no migration. The live PocketBase schema does still need scripts/setup-pocketbase.mjs re-run before the new choices will save, since its select field allows only the old four; the script reconciles select values on existing fields, so re-running migrates it in place. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
21c99ad762 |
Add technical check history
A car's mandatory roadworthiness inspections — przegląd techniczny, MOT, TÜV — had nowhere to live. Log them behind Service history, which this mirrors minus the odometer: an inspection falls due on a date whatever the mileage reads. The cycle is legal rather than mechanical, and it is not constant — a new car's first check falls due years later than its third. So the car's technical_check_interval_days only prefills the next date, and any check overrides it with the valid-until printed on its own certificate. A failed check derives no next date at all: reading one off a failure would stamp a reassuring "valid until" on a car that just flunked. The next-due date and its status are derived on every read rather than stored, for the same reason documents are — a stored verdict goes stale as the date passes. The response carries the same expiry shape documents use, so the web app reuses the existing badge rather than growing a second one. Each check records result, cost, station and notes, and holds the certificate as its single attachment on the same terms as every other record. Needs `node scripts/setup-pocketbase.mjs` to create technical_checks and add the interval to cars. The reformatting in records.go is gofmt realigning the car struct around a longer field name. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
03738f08dc |
Add currency setting and split locale into language + region
Costs rendered as bare numbers because the project had no currency to render them with. Add one to the profile, beside the existing appearance preferences: - users.currency in PocketBase, exposed via /api/me, validated against the same 28 codes in the schema, the API and the web app. - Settings offers the European currencies plus the non-European ones the panel already had. Labels come from Intl.DisplayNames rather than a hand-kept table, so the lists read in the user's own language and sort by what is actually on screen. The single "Language & region" picker becomes two controls over the one stored BCP-47 tag, covering European languages and countries, so the two can be mixed (English in Poland). The API now enforces the language-REGION shape: the web app feeds the tag straight to Intl, which throws on a malformed one rather than falling back. formatKm and the km-count labels passed no locale, so kilometres followed the browser while the dates and costs beside them followed the saved preference. They now share one helper that uses the preference. Rename the "Maintenance log" tab to "Maintenance", the name the reminder-type list already used. Amounts are display-only: nothing is converted, so changing currency reinterprets existing records rather than recalculating them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
07192f1238 |
Add attachments to service, maintenance, fuel and parts records
Documents already carried a single optional file: upload, download, detach,
access-checked on every request and proxied through this server, so PocketBase's
files are never public URLs. Service records, workshop visits, refills and
catalog parts all want the same thing — a receipt, an invoice, a photo of the
box — so extend it to them.
Rather than copy the document handlers four more times, lift them into one
shared layer. Every attachable collection has a car relation and a file field,
which is what lets a single set of handlers authorize and serve all of them. An
upload finishes by delegating to the collection's own GET handler, so the
response carries the full record — derived fields and all — exactly as a re-read
would. The web side gets the same treatment: one picker component and one
upload-after-save helper behind all five forms. Net effect is five features for
about the cost of the one that was already there.
Alongside:
- parts gain a notes field
- Reminders moves behind Parts catalog in the car detail tabs
- the changed-part labels spell out in full ("Oil & Oil filter" rather than
"Oil & filter"), and the form and table now agree
The PocketBase schema must be migrated before the new attachments work:
scripts/setup-pocketbase.mjs adds the file fields and parts.notes. It is
additive and safe to re-run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
e64c89a564 |
Add fuel, maintenance, document and reminder tracking
Four features layered onto cars, each following the existing parts/services pattern: a Go handler gated on requireCarAccess, snake_case PocketBase mappers, a Vue form modal, and a tab on CarDetail (now driven by an array rather than repeated markup). Fuel: refills logged with odometer, litres and cost. Consumption is derived on read from the whole history rather than stored, so correcting an old fill re-derives every window it touches with no rows to migrate. Efficiency uses the full-tank method — two consecutive full tanks are the same known level, so the fuel burned between them is exactly what was poured in. Partial fills roll into the window that closes them; a missed-fill flag leaves that window uncomputed rather than reporting an implausibly good figure. Averages in the stats rollup are distance-weighted, so a long motorway run counts for more than a trip across town — which is what actually happened to the fuel. Maintenance: workshop visits and repairs, deliberately separate from service_records. That collection is the routine interval schedule and drives next-service-due; this one is unplanned garage work with a workshop, an invoice and a labour bill, and no bearing on the interval. Documents: insurance, pollution certificates and registration papers. The renewal date is the point of the record, so expiry is assessed live on every read instead of stored and left to go stale. Scans are proxied through the API — PocketBase's collections have no public read rule, so an attachment is never a public URL and car access is re-checked per fetch. Reminders: fire on a date, an odometer reading, or both (whichever comes first). Stored reminders sit alongside read-only ones derived from document expiry and next-service-due, so a renewal date is never typed twice and can never drift from the document it came from. Derived ids are namespaced "auto:" and every write endpoint rejects them. A refill or a completed visit also writes the car's odometer forward, since it is the freshest reading there is — never backwards, so backfilling old history can't rewind the car. Adds fuel_entries, maintenance_entries, car_documents and reminders to the idempotent schema script, plus a file-field builder for attachments. Verified end-to-end against a live PocketBase with a throwaway account: 39 checks covering the efficiency maths, expiry states, the derived reminders, the upload/download round-trip, and that a stranger can reach none of it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
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>
|