From c5d431c560d3c0dade09c15e8c418d7bcc7f0442 Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:16:20 +0200 Subject: [PATCH] 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 --- API Server/internal/api/cars.go | 9 +- API Server/internal/api/cartabs_test.go | 26 ++-- API Server/internal/bootstrap/schema.go | 5 +- API Server/internal/models/models.go | 9 +- Web App/README.md | 12 +- .../web/src/components/ServiceFormModal.vue | 26 ++-- Web App/web/src/lib/serviceParts.js | 24 +++ Web App/web/src/views/CarDetail.vue | 142 ++++++++++++++---- 8 files changed, 195 insertions(+), 58 deletions(-) create mode 100644 Web App/web/src/lib/serviceParts.js diff --git a/API Server/internal/api/cars.go b/API Server/internal/api/cars.go index e7b1a63..eccfad5 100644 --- a/API Server/internal/api/cars.go +++ b/API Server/internal/api/cars.go @@ -274,11 +274,12 @@ var hideableCarFields = map[string]bool{ // 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. +// stops being a history. "parts" is the one column covering every part a service +// can have changed — they are a growing list, and one column per part would +// widen the table indefinitely — so the set does not grow when a part is added. var hideableServiceColumns = map[string]bool{ - "km": true, "nextDate": true, "nextKm": true, "oil": true, - "engineFilter": true, "cabinFilter": true, "notes": true, "file": true, + "km": true, "nextDate": true, "nextKm": true, "parts": true, + "notes": true, "file": true, } // arrangeableServiceColumns are the columns that table can be rearranged into: diff --git a/API Server/internal/api/cartabs_test.go b/API Server/internal/api/cartabs_test.go index 50ad0dc..a66a298 100644 --- a/API Server/internal/api/cartabs_test.go +++ b/API Server/internal/api/cartabs_test.go @@ -162,11 +162,11 @@ func TestNormalizeMetricOrder(t *testing.T) { } func TestNormalizeHiddenServiceColumns(t *testing.T) { - got, err := normalizeKeys([]string{" oil ", "notes", "oil", ""}, hideableServiceColumns, "service column") + got, err := normalizeKeys([]string{" parts ", "notes", "parts", ""}, hideableServiceColumns, "service column") if err != nil { t.Fatalf("normalizeKeys: %v", err) } - assertKeys(t, got, []string{"oil", "notes"}) // trimmed, blanks dropped, deduped + assertKeys(t, got, []string{"parts", "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. @@ -177,33 +177,37 @@ func TestNormalizeHiddenServiceColumns(t *testing.T) { 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 { + if _, err := normalizeKeys([]string{"parts", "nonsense"}, hideableServiceColumns, "service column"); err == nil { t.Error("normalizeKeys accepted an unknown service column, want an error") } + // The parts are one column, not one each: a key per part would put the table + // back where it started, and these three were columns of their own once. + for _, key := range []string{"oil", "engineFilter", "cabinFilter"} { + if hideableServiceColumns[key] { + t.Errorf("%q should not be a column of its own — the parts share one", key) + } + } // 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", - } { + for _, key := range []string{"km", "nextDate", "nextKm", "parts", "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)) + if len(hideableServiceColumns) != 6 { + t.Errorf("hideableServiceColumns has %d entries, want the 6 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") + got, err := normalizeKeys([]string{"notes", "date", "parts"}, arrangeableServiceColumns, "service column") if err != nil { t.Fatalf("normalizeKeys: %v", err) } - assertKeys(t, got, []string{"notes", "date", "km"}) + assertKeys(t, got, []string{"notes", "date", "parts"}) for key := range hideableServiceColumns { if !arrangeableServiceColumns[key] { diff --git a/API Server/internal/bootstrap/schema.go b/API Server/internal/bootstrap/schema.go index 7fd2e5d..b46ecab 100644 --- a/API Server/internal/bootstrap/schema.go +++ b/API Server/internal/bootstrap/schema.go @@ -46,8 +46,9 @@ var collectionsSchema = map[string][]fieldDef{ // release is on by default. Keys are validated in internal/api/cars.go. fJSON("hidden_tabs", 2000), fJSON("hidden_fields", 2000), - // And the columns of the Service history table (["oil"] on an EV, which - // has no oil to change). Date is not hideable and so never appears here. + // And the columns of the Service history table (["parts"] for a reader who + // never records what was changed). Date is not hideable and so never + // appears here. fJSON("hidden_service_columns", 2000), // The order the tabs are laid out in, as tab keys, the same for the // Information rows, the Service history columns, and the connected diff --git a/API Server/internal/models/models.go b/API Server/internal/models/models.go index 2b8b90e..02d2722 100644 --- a/API Server/internal/models/models.go +++ b/API Server/internal/models/models.go @@ -84,10 +84,11 @@ type Car struct { FieldOrder []string `json:"fieldOrder"` // HiddenServiceColumns is what the Service history table does not show, as - // column keys (["oil", "engineFilter"] on an EV, whose service is neither). - // The hidden set like the two above, so a column added later is on by - // default, and Date is not among the keys it may name: a service record is - // its date, and a table of them without it reads as a list of nothing. + // column keys (["parts", "file"] for somebody who keeps only dates and + // distances). The hidden set like the two above, so a column added later is + // on by default, and Date is not among the keys it may name: a service + // record is its date, and a table of them without it reads as a list of + // nothing. HiddenServiceColumns []string `json:"hiddenServiceColumns"` // ServiceColumnOrder is the arrangement of those columns, covering the diff --git a/Web App/README.md b/Web App/README.md index 275e441..7a26fbb 100644 --- a/Web App/README.md +++ b/Web App/README.md @@ -102,8 +102,8 @@ Config (`server/.env`, copy from `.env.example`): checks, maintenance, fuel cost, charging cost, documents, parts, reminders — Fuel cost off on an EV and Charging cost off on a petrol car) and which of the 14 Information rows it lists (no Differential oil on a car without one) and - which columns the Service history table shows (no Oil, no Engine air filter on - an EV). It belongs to the car, so everyone it is shared with sees + which columns the Service history table shows (no File column for somebody who + keeps no receipts). It belongs to the car, so everyone it is shared with sees the same page; setting it needs write access. Stored as the *hidden* sets, so anything added in a later release is on by default. Two things can't be switched off: the Information tab, and the service Date — a history with the @@ -125,6 +125,14 @@ Config (`server/.env`, copy from `.env.example`): into any order, saved on drop. Also a property of the car, and it covers the hidden rows too, so switching one back on returns it to where it was. Same native drag events as the garage, so also pointer-only. +- **Changed parts** — every part a service can record sits in one column, not one + column each: they are a growing list and a column apiece would widen the table + without end. The cell names what was changed (past two, the first and a tally) + and opens a panel listing every part with a yes or a no — a dropdown rather + than a dialog, so the rows you are comparing it against stay on screen. Both + the column and the form's Changed parts section are driven by one list in + `lib/serviceParts.js`, so adding a part is one entry there plus its boolean on + the API's `service_records` collection. - **Arranging the Service history columns** — the column headings on that tab drag into any order, saved on drop, and it covers the hidden columns too. Date is arrangeable although it can't be switched off, the same rule Information diff --git a/Web App/web/src/components/ServiceFormModal.vue b/Web App/web/src/components/ServiceFormModal.vue index 02a2be3..dbed732 100644 --- a/Web App/web/src/components/ServiceFormModal.vue +++ b/Web App/web/src/components/ServiceFormModal.vue @@ -3,6 +3,7 @@ import { ref } from "vue"; import { api } from "../api"; import { formatKm } from "../lib/format.js"; import { applyAttachment } from "../lib/attachment.js"; +import { SERVICE_PARTS } from "../lib/serviceParts.js"; import { t } from "../i18n"; import AttachmentField from "./AttachmentField.vue"; import Modal from "./Modal.vue"; @@ -20,9 +21,13 @@ const error = ref(""); const form = ref({ date: props.service ? toDateInput(props.service.date) : new Date().toISOString().slice(0, 10), km: props.service?.km ?? "", - changedOil: props.service ? props.service.changedOil : true, - changedEngineAirFilter: props.service ? props.service.changedEngineAirFilter : false, - changedCabinAirFilter: props.service ? props.service.changedCabinAirFilter : false, + // One entry per part, built from the shared list rather than named here, so a + // part added to it turns up in this dialog without a second edit. An existing + // record written before a part existed has no field for it, which reads as + // unchecked. + ...Object.fromEntries( + SERVICE_PARTS.map((part) => [part.field, props.service ? !!props.service[part.field] : part.initial]) + ), notes: props.service?.notes ?? "", }); @@ -44,9 +49,7 @@ async function submit() { // Blank-tested, not truthiness-tested: a service logged at 0 km on a car // collected new is a real entry, and the field is required anyway. km: form.value.km === "" ? 0 : Number(form.value.km), - changedOil: form.value.changedOil, - changedEngineAirFilter: form.value.changedEngineAirFilter, - changedCabinAirFilter: form.value.changedCabinAirFilter, + ...Object.fromEntries(SERVICE_PARTS.map((part) => [part.field, form.value[part.field]])), notes: form.value.notes.trim(), }; const saved = isEdit @@ -80,9 +83,14 @@ async function submit() {
{{ t("forms.service.changedParts") }} - - - +
!!service?.[part.field]); +} diff --git a/Web App/web/src/views/CarDetail.vue b/Web App/web/src/views/CarDetail.vue index 350d709..0a44998 100644 --- a/Web App/web/src/views/CarDetail.vue +++ b/Web App/web/src/views/CarDetail.vue @@ -1,5 +1,5 @@