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>
This commit is contained in:
tajniak81
2026-08-22 10:31:52 +02:00
co-authored by Claude Opus 5
parent caf4d2996d
commit b60d929ed6
14 changed files with 411 additions and 58 deletions
+59 -10
View File
@@ -271,6 +271,30 @@ var hideableCarFields = map[string]bool{
"vin": true, "fuelType": true, "buildDate": true, "firstRegistration": true,
}
// hideableServiceColumns are the columns of the Service history table that can
// be switched off. Date is deliberately not among them: every row of that table
// is a service that happened on a day, and a history with the day taken out
// stops being a history. Mirrors the car.services.col* labels the web app
// renders.
var hideableServiceColumns = map[string]bool{
"km": true, "nextDate": true, "nextKm": true, "oil": true,
"engineFilter": true, "cabinFilter": true, "notes": true, "file": true,
}
// arrangeableServiceColumns are the columns that table can be rearranged into:
// the hideable ones plus Date, which cannot be switched off but has no reason to
// be stuck at the left. Derived from hideableServiceColumns so the two sets
// cannot drift as columns are added — the same construction arrangeableCarTabs
// uses for Information.
var arrangeableServiceColumns = func() map[string]bool {
out := make(map[string]bool, len(hideableServiceColumns)+1)
for key := range hideableServiceColumns {
out[key] = true
}
out["date"] = true
return out
}()
// arrangeableCarMetrics are the headline readings on the connected-service tab,
// and so the keys a car's arrangement of them may name. Derived from the reading
// specs in vehicleproviders.go rather than written out again, so the set cannot
@@ -308,21 +332,25 @@ func normalizeKeys(in []string, allowed map[string]bool, what string) ([]string,
}
// PUT /api/cars/{id}/view — choose what this car's page shows: which tabs, which
// rows of the Information tab, and the order the tabs, the Information rows and
// the connected service's headline readings are laid out in. Body: {hiddenTabs?:
// [...], hiddenFields?: [...], tabOrder?: [...], fieldOrder?: [...],
// metricOrder?: [...]}; only the lists present are written, so a client can
// rearrange one group without resending the others. Its own endpoint rather than fields on the car edit, so an ordinary
// rows of the Information tab, which columns of the Service history table, and
// the order the tabs, the Information rows, those columns and the connected
// service's headline readings are laid out in. Body: {hiddenTabs?: [...],
// hiddenFields?: [...], hiddenServiceColumns?: [...], tabOrder?: [...],
// fieldOrder?: [...], serviceColumnOrder?: [...], metricOrder?: [...]}; only the
// lists present are written, so a client can rearrange one group without
// resending the others. Its own endpoint rather than fields on the car edit, so an ordinary
// save of the car form — which sends every other field — can never reveal
// something somebody deliberately switched off. Needs write access: the choice
// belongs to the car, so it is the same permission as editing it.
func (s *Server) updateCarView(w http.ResponseWriter, r *http.Request) {
var in struct {
HiddenTabs *[]string `json:"hiddenTabs"`
HiddenFields *[]string `json:"hiddenFields"`
TabOrder *[]string `json:"tabOrder"`
FieldOrder *[]string `json:"fieldOrder"`
MetricOrder *[]string `json:"metricOrder"`
HiddenTabs *[]string `json:"hiddenTabs"`
HiddenFields *[]string `json:"hiddenFields"`
HiddenServiceColumns *[]string `json:"hiddenServiceColumns"`
TabOrder *[]string `json:"tabOrder"`
FieldOrder *[]string `json:"fieldOrder"`
ServiceColumnOrder *[]string `json:"serviceColumnOrder"`
MetricOrder *[]string `json:"metricOrder"`
}
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
@@ -355,6 +383,14 @@ func (s *Server) updateCarView(w http.ResponseWriter, r *http.Request) {
}
payload["hidden_fields"] = fields
}
if in.HiddenServiceColumns != nil {
columns, err := normalizeKeys(*in.HiddenServiceColumns, hideableServiceColumns, "service column")
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
payload["hidden_service_columns"] = columns
}
if in.TabOrder != nil {
// A wider set than the hidden tabs: Information is arrangeable although it
// cannot be switched off. A partial list is accepted, and the tabs it
@@ -379,6 +415,19 @@ func (s *Server) updateCarView(w http.ResponseWriter, r *http.Request) {
}
payload["field_order"] = order
}
if in.ServiceColumnOrder != nil {
// A wider set than the hidden columns, for the same reason the tab order
// is: Date is arrangeable although it cannot be switched off. A partial
// list is accepted, and the columns it leaves out follow the arranged
// ones, so a column added in a later release lands at the right-hand end
// rather than in the middle of somebody's table.
order, err := normalizeKeys(*in.ServiceColumnOrder, arrangeableServiceColumns, "service column")
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
payload["service_column_order"] = order
}
if in.MetricOrder != nil {
// A partial list again, and here it is the normal case: the client can
// only arrange the readings the provider actually reported, so one it
+69 -5
View File
@@ -2,11 +2,12 @@ package api
import "testing"
// What a car's page shows — which tabs, which rows of the Information tab, and
// the order those rows are laid out in — is stored on the car as key lists, so
// the validation has to keep them to keys the page actually renders.
// Information itself stays out of the hideable tabs: a car with no tabs left
// would be a dead end.
// What a car's page shows — which tabs, which rows of the Information tab, which
// columns of the Service history table, and the order each of those is laid out
// in — is stored on the car as key lists, so the validation has to keep them to
// keys the page actually renders. Two keys stay out of their hideable set:
// Information, because a car with no tabs left would be a dead end, and the
// service Date, because a history with the day taken out is not one.
func TestNormalizeHiddenTabs(t *testing.T) {
got, err := normalizeKeys([]string{" fuel ", "parts", "fuel", ""}, hideableCarTabs, "tab")
@@ -160,6 +161,69 @@ func TestNormalizeMetricOrder(t *testing.T) {
}
}
func TestNormalizeHiddenServiceColumns(t *testing.T) {
got, err := normalizeKeys([]string{" oil ", "notes", "oil", ""}, hideableServiceColumns, "service column")
if err != nil {
t.Fatalf("normalizeKeys: %v", err)
}
assertKeys(t, got, []string{"oil", "notes"}) // trimmed, blanks dropped, deduped
// The date is what a service record is; a table of them without it would be
// a list of unattributed work.
if _, err := normalizeKeys([]string{"date"}, hideableServiceColumns, "service column"); err == nil {
t.Error("normalizeKeys allowed hiding the date column, want an error")
}
// Neither a field key nor an invented one passes: each set is its own.
if _, err := normalizeKeys([]string{"vin"}, hideableServiceColumns, "service column"); err == nil {
t.Error("normalizeKeys accepted a field key as a service column, want an error")
}
if _, err := normalizeKeys([]string{"oil", "nonsense"}, hideableServiceColumns, "service column"); err == nil {
t.Error("normalizeKeys accepted an unknown service column, want an error")
}
// The hideable set is the contract the web app's HIDEABLE_SERVICE_COLUMNS
// mirrors: every column that table renders beside the date.
for _, key := range []string{
"km", "nextDate", "nextKm", "oil", "engineFilter", "cabinFilter",
"notes", "file",
} {
if !hideableServiceColumns[key] {
t.Errorf("service column %q should be hideable", key)
}
}
if len(hideableServiceColumns) != 8 {
t.Errorf("hideableServiceColumns has %d entries, want the 8 columns beside the date", len(hideableServiceColumns))
}
}
// The columns arrange against a wider set than they hide against, the way the
// tabs do: the date cannot be switched off, but it can be moved off the left.
func TestNormalizeServiceColumnOrder(t *testing.T) {
got, err := normalizeKeys([]string{"notes", "date", "km"}, arrangeableServiceColumns, "service column")
if err != nil {
t.Fatalf("normalizeKeys: %v", err)
}
assertKeys(t, got, []string{"notes", "date", "km"})
for key := range hideableServiceColumns {
if !arrangeableServiceColumns[key] {
t.Errorf("service column %q should be arrangeable", key)
}
}
if !arrangeableServiceColumns["date"] {
t.Error("the date column should be arrangeable even though it cannot be hidden")
}
if len(arrangeableServiceColumns) != len(hideableServiceColumns)+1 {
t.Errorf("arrangeableServiceColumns has %d entries, want the hideable columns plus the date", len(arrangeableServiceColumns))
}
// A partial arrangement is fine — the columns it leaves out follow the
// arranged ones — but an invented column is still an error.
if _, err := normalizeKeys([]string{"date", "nonsense"}, arrangeableServiceColumns, "service column"); err == nil {
t.Error("normalizeKeys accepted an unknown column in an arrangement, want an error")
}
}
func assertKeys(t *testing.T, got, want []string) {
t.Helper()
if len(got) != len(want) {
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#2563eb" />
<title>DriverVault · API Server</title>
<script type="module" crossorigin src="/assets/index-D2BCqgpA.js"></script>
<script type="module" crossorigin src="/assets/index-E4ifC_ff.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CGuUVjJH.css">
</head>
<body>
+12 -7
View File
@@ -68,15 +68,18 @@ type carRecord struct {
Created string `json:"created"`
Updated string `json:"updated"`
// Switched-off tabs and Information fields, plus the arrangements of the
// tabs, the Information rows and the connected service's readings. Raw
// Switched-off tabs, Information fields and Service history columns, plus
// the arrangements of the tabs, the Information rows, those columns and the
// connected service's readings. Raw
// because PocketBase hands back whatever a json field holds — null on a car
// nobody has configured — which is not a []string.
HiddenTabs json.RawMessage `json:"hidden_tabs"`
HiddenFields json.RawMessage `json:"hidden_fields"`
TabOrder json.RawMessage `json:"tab_order"`
FieldOrder json.RawMessage `json:"field_order"`
MetricOrder json.RawMessage `json:"metric_order"`
HiddenTabs json.RawMessage `json:"hidden_tabs"`
HiddenFields json.RawMessage `json:"hidden_fields"`
HiddenServiceColumns json.RawMessage `json:"hidden_service_columns"`
TabOrder json.RawMessage `json:"tab_order"`
FieldOrder json.RawMessage `json:"field_order"`
ServiceColumnOrder json.RawMessage `json:"service_column_order"`
MetricOrder json.RawMessage `json:"metric_order"`
}
func (rec carRecord) toModel() models.Car {
@@ -105,8 +108,10 @@ func (rec carRecord) toModel() models.Car {
ProviderVehicleID: rec.ProviderVehicleID,
HiddenTabs: decodeStringList(rec.HiddenTabs),
HiddenFields: decodeStringList(rec.HiddenFields),
HiddenServiceColumns: decodeStringList(rec.HiddenServiceColumns),
TabOrder: decodeStringList(rec.TabOrder),
FieldOrder: decodeStringList(rec.FieldOrder),
ServiceColumnOrder: decodeStringList(rec.ServiceColumnOrder),
MetricOrder: decodeStringList(rec.MetricOrder),
Owner: rec.Owner,
Created: rec.Created,