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>
DriverVault
A car control & service-tracking system (originally "Car Control Project").
Built incrementally — starting with a car maintenance tracker (modeled on
Car Service.xlsx) and growing toward live integration with the car via a
cellular ESP32 device and third-party services (Toyota Connected, EV chargers).
Architecture
All clients communicate with the database only through the API Server — nothing talks to PocketBase directly.
┌──────────────────┐
Web App (Vue) ─────▶│ │
Phone App (Flutter)▶│ API Server │────▶ PocketBase
Home Assistant ────▶│ (Go, stdlib) │ (10.2.1.10:8027)
Car Agent (ESP32) ─▶│ │
└──────────────────┘
| Component | Stack | Status | Docs |
|---|---|---|---|
| API Server | Go (stdlib) | ✅ built, running, verified | API Server/README.md |
| Database | PocketBase | ✅ running, schema + seed done | — |
| Web App | Vue 3 + Vite + Tailwind v4 | ✅ full feature set (below) | Web App/README.md |
| Phone App | Flutter (Android) | ✅ web parity + biometric login | Phone App/README.md |
| Docker | Compose (multi-container / all-in-one) | ✅ deployment configs | Docker/README.md · Docker-AIO/README.md |
| Car Agent Device | ESP32 + SIM7600 (LILYGO TTGO) | 🚧 firmware in progress | Car Agent Device |
| Home Assistant Plugin | — | ⬜ later | — |
The Web and Phone apps are at feature parity.
Features
- Maintenance tracking — cars, service history (date/odometer + which parts were changed), and a per-car parts catalog, with next-due date/km status from the spreadsheet formulas.
- Technical checks — the mandatory roadworthiness inspections (przegląd techniczny / MOT / TÜV): result, cost, station and the certificate's valid-until, which overrides the car's interval and drives the next-due date.
- Maintenance log — workshop visits and repairs outside the routine schedule: type/status, workshop, parts used, labour + parts cost, invoice, warranty-until.
- Fuel tracking — refills with derived efficiency (average / best / worst consumption, cost per km, price per litre). Consumption is measured between full tanks, so partial fills roll into the next full one.
- Documents — insurance, registration, road tax and the rest, each with a server-computed renewal/expiry state.
- Reminders — date- and/or odometer-triggered, one-off or recurring, plus read-only reminders the server derives from documents and service records.
- Attachments — one optional file (PDF or image) per service record, technical check, maintenance entry, refill, document and part; fetched back through the API Server, never a public URL.
- Accounts — PocketBase-token login, profile + appearance preferences (theme/locale/date format/currency/font), avatar, email verification, data export/import, and an account-deletion state machine.
- Organizations & roles — multi-tenant
user/admin/superadminroles; admins manage users within their own organization, superadmins span all. Any user without an organization can create one and becomes its admin. - Per-user ownership & sharing — each car has an owner and can be shared with other users as read or write; the UI mirrors the server's access checks.
- Integrations — per-user connectors under a superadmin → org-admin → user cascade. Built-in today: Toyota Connected (read-only vehicle data) and the Anker Solix V1 EV charger.
- Cars from the manufacturer's own service — import a car straight off a connected account (MyToyota today), choosing what to pull in, and read everything that service knows about it from a dedicated first tab on the car. Generic over providers: the next manufacturer is one adapter in the API Server.
- EV charging control — for Anker Solix chargers the API Server runs an OCPP 1.6J Central System; in own/proxy mode the charger dials back in and the owner can start/stop and set limits from the Charging screen.
- Translated UI — the interface reads its text from per-language files (English, Polish, Danish today), with English as the fallback for any untranslated string. See TRANSLATIONS.md.
- Phone biometric login & app lock — fingerprint / face sign-in with an app-lock that requires an unlock on relaunch. See the Phone App README.
Auth model
All apps share one auth model: authentication is PocketBase's own.
POST /api/auth/login is proxied to the PocketBase users collection and the
client keeps the token PocketBase minted — the API Server does not issue its
own JWT. Every protected request carries Authorization: <token> (both
Bearer <token> and a raw token are accepted) and the server re-resolves it
against PocketBase on each call, so a role change or a deletion takes effect
immediately. Tokens are stateless, so there is no per-device session list;
changing an account's password rotates its token key and invalidates every token
already issued. Access to cars/records is gated by per-user ownership and shares;
user management requires the admin or superadmin role. Creating an organization
is the one management action open to a plain user — it promotes them to admin of
the organization they just created.
Domain (from Car Service.xlsx)
- Cars — one per vehicle (was: one spreadsheet sheet), with spec fields (engine / transmission / differential oil, brake fluid, coolant, VIN, fuel type, build / first-registration dates, …) and configurable service intervals.
- Service records — date + odometer per service, plus which parts were changed (oil & oil filter, engine air filter, cabin air filter).
- Parts — per-car catalog of part numbers.
Key spreadsheet formulas, reproduced by the API Server on read:
Next Service Date = service date + serviceIntervalDays (default 365; Excel: =A+365)
Next Service Km = service km + serviceIntervalKm (default 15 000; Excel: =B+15000)
Intervals are configurable per car.
Getting started
Bring up the stack in this order — each app's README has the details:
- API Server — configure
.env, runsetup-pocketbase.mjs, start the server. This must be running for either app. - Web App —
npm install && npm run dev(proxies/apito the server). - Phone App —
flutter build apk/flutter runwith--dart-define=API_BASE=http://<server-ip>:8080/api.
Or skip all of that and bring the whole stack up with Docker, which runs the schema setup itself — see Docker (PocketBase + API Server + Web App as three containers) or Docker-AIO (all three in a single image).
Layout
DriverVault/
├── API Server/ # Go gateway to PocketBase (the only DB client)
├── Web App/ # Vue 3 + Vite + Tailwind v4 SPA + Go BFF
├── Phone App/ # Flutter (Android)
├── Car Agent Device/ # ESP32 + SIM7600 firmware (LILYGO TTGO T-SIM7600)
├── Home Assistant Plugin/ # later phase
├── Docker/ # Compose deployment (API Server + Web App)
└── Docker-AIO/ # single all-in-one image