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() {