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>
This commit is contained in:
tajniak81
2026-08-22 23:30:08 +02:00
co-authored by Claude Opus 5
parent 7718b32013
commit 35e6c511b7
24 changed files with 356 additions and 50 deletions
@@ -3,7 +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 { SERVICE_PARTS, visibleParts } from "../lib/serviceParts.js";
import { t } from "../i18n";
import AttachmentField from "./AttachmentField.vue";
import DateField from "./DateField.vue";
@@ -19,6 +19,11 @@ const emit = defineEmits(["saved", "close"]);
const isEdit = !!props.service;
const saving = ref(false);
const error = ref("");
// The parts this car records. Read once rather than as a computed: the dialog is
// mounted per open, and a part switching off under an open form would rearrange
// it mid-edit.
const shown = visibleParts(props.car);
const hidden = SERVICE_PARTS.filter((part) => !shown.includes(part)).map((part) => part.key);
const form = ref({
date: props.service ? toDateInput(props.service.date) : new Date().toISOString().slice(0, 10),
km: props.service?.km ?? "",
@@ -26,8 +31,19 @@ const form = ref({
// 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.
//
// Every part, not only the shown ones: an edit has to send back what a hidden
// part already said, because the API rewrites all three booleans from the body
// and an omitted one would come back false. A *new* record starts a hidden
// part at false rather than its `initial`, since ticking a box nobody was
// shown is not a default, it's a guess.
...Object.fromEntries(
SERVICE_PARTS.map((part) => [part.field, props.service ? !!props.service[part.field] : part.initial])
SERVICE_PARTS.map((part) => [
part.field,
props.service
? !!props.service[part.field]
: part.initial && !hidden.includes(part.key),
])
),
notes: props.service?.notes ?? "",
});
@@ -82,10 +98,10 @@ async function submit() {
<input v-model="form.km" type="number" min="0" required placeholder="16138" class="dh-input data" />
</div>
</div>
<fieldset class="rounded-control border border-subtle p-3">
<fieldset v-if="shown.length" class="rounded-control border border-subtle p-3">
<legend class="eyebrow px-1">{{ t("forms.service.changedParts") }}</legend>
<label
v-for="part in SERVICE_PARTS"
v-for="part in shown"
:key="part.key"
class="flex items-center gap-2 py-1 text-sm text-body"
>
+3 -1
View File
@@ -384,7 +384,9 @@
"fieldsOrderHint": "Træk felterne på fanen Oplysninger for at ændre deres rækkefølge.",
"serviceColumnsHeading": "Kolonner i servicehistorik",
"columnAlwaysOn": "{column} vises altid.",
"serviceColumnsOrderHint": "Træk kolonneoverskrifterne på fanen Servicehistorik for at ændre deres rækkefølge."
"serviceColumnsOrderHint": "Træk kolonneoverskrifterne på fanen Servicehistorik for at ændre deres rækkefølge.",
"servicePartsHeading": "Udskiftede dele",
"servicePartsHint": "En fravalgt del forsvinder fra serviceformularen og fra historikken. Det, der allerede er registreret, bevares og kommer tilbage med den."
},
"provider": {
+3 -1
View File
@@ -383,7 +383,9 @@
"fieldsOrderHint": "Drag the fields on the Information tab to change the order they appear in.",
"serviceColumnsHeading": "Service history columns",
"columnAlwaysOn": "{column} is always shown.",
"serviceColumnsOrderHint": "Drag the column headings on the Service history tab to change the order they appear in."
"serviceColumnsOrderHint": "Drag the column headings on the Service history tab to change the order they appear in.",
"servicePartsHeading": "Changed parts",
"servicePartsHint": "A part switched off comes off the service form and out of the history. What is already recorded is kept, and comes back with it."
},
"provider": {
+3 -1
View File
@@ -388,7 +388,9 @@
"fieldsOrderHint": "Przeciągnij pola na zakładce Informacje, aby zmienić ich kolejność.",
"serviceColumnsHeading": "Kolumny historii serwisowej",
"columnAlwaysOn": "Kolumna {column} jest zawsze widoczna.",
"serviceColumnsOrderHint": "Przeciągnij nagłówki kolumn na zakładce Historia serwisowa, aby zmienić ich kolejność."
"serviceColumnsOrderHint": "Przeciągnij nagłówki kolumn na zakładce Historia serwisowa, aby zmienić ich kolejność.",
"servicePartsHeading": "Wymienione części",
"servicePartsHint": "Wyłączona część znika z formularza serwisu i z historii. Zapisane dane pozostają i wracają razem z nią."
},
"provider": {
+21 -5
View File
@@ -16,9 +16,25 @@ export const SERVICE_PARTS = [
{ key: "cabinFilter", field: "changedCabinAirFilter", label: "forms.service.cabinFilter", initial: false },
];
// The parts this record says were changed. A record written before a part
// existed simply doesn't carry its field, which reads as "not changed" rather
// than as a missing value — that service genuinely didn't change it.
export function changedParts(service) {
return SERVICE_PARTS.filter((part) => !!service?.[part.field]);
// The parts a car actually records, which is every one it hasn't switched off.
// An EV changes no oil, and a checkbox nobody will ever tick is one more thing
// to read past on every service. The hidden set rather than the visible one, so
// a part added in a later release is on by default.
export function visibleParts(car) {
const hidden = car?.hiddenServiceParts || [];
return SERVICE_PARTS.filter((part) => !hidden.includes(part.key));
}
// The parts this record says were changed, among the ones the car still shows.
// A record written before a part existed simply doesn't carry its field, which
// reads as "not changed" rather than as a missing value — that service genuinely
// didn't change it.
//
// A part switched off disappears from the history as well as from the form, the
// same way a hidden column does: what "I don't record this" means is that it
// stops taking up room, not that it takes up room saying nothing. Its stored
// boolean is left alone, so switching it back on brings the old records' chips
// back with it.
export function changedParts(service, car) {
return visibleParts(car).filter((part) => !!service?.[part.field]);
}
+27 -3
View File
@@ -18,7 +18,7 @@ import {
expiryStatus,
reminderStatus,
} from "../lib/format.js";
import { SERVICE_PARTS, changedParts } from "../lib/serviceParts.js";
import { SERVICE_PARTS, changedParts, visibleParts } from "../lib/serviceParts.js";
import { t, tSplit } from "../i18n";
import CarFormModal from "../components/CarFormModal.vue";
import ServiceFormModal from "../components/ServiceFormModal.vue";
@@ -255,6 +255,7 @@ const HIDEABLE_SERVICE_COLUMNS = ALL_SERVICE_COLUMN_KEYS.filter((key) => key !==
const tabDraft = ref([]); // tab keys that stay visible
const fieldDraft = ref([]); // Information keys that stay visible
const columnDraft = ref([]); // Service history columns that stay visible
const partDraft = ref([]); // parts the service form keeps offering
const viewSaving = ref(false);
const viewError = ref("");
@@ -264,6 +265,7 @@ function openViewPicker() {
columnDraft.value = serviceColumnKeys.value.filter(
(key) => key !== "date" && !hiddenServiceColumns.value.includes(key)
);
partDraft.value = visibleParts(car.value).map((part) => part.key);
viewError.value = "";
showViewPicker.value = true;
}
@@ -280,6 +282,9 @@ function toggleFieldDraft(key, on) {
function toggleColumnDraft(key, on) {
columnDraft.value = on ? [...columnDraft.value, key] : columnDraft.value.filter((k) => k !== key);
}
function togglePartDraft(key, on) {
partDraft.value = on ? [...partDraft.value, key] : partDraft.value.filter((k) => k !== key);
}
async function saveView() {
viewSaving.value = true;
@@ -289,6 +294,7 @@ async function saveView() {
hiddenTabs: HIDEABLE_TABS.filter((key) => !tabDraft.value.includes(key)),
hiddenFields: INFO_FIELD_KEYS.filter((key) => !fieldDraft.value.includes(key)),
hiddenServiceColumns: HIDEABLE_SERVICE_COLUMNS.filter((key) => !columnDraft.value.includes(key)),
hiddenServiceParts: SERVICE_PARTS.filter((part) => !partDraft.value.includes(part.key)).map((part) => part.key),
});
car.value = { ...updated, access: car.value.access };
showViewPicker.value = false;
@@ -515,7 +521,7 @@ function serviceCell(s, key) {
// has to stay one line wide, and it is going to grow, so past two it becomes the
// first part and a tally. The panel behind it has the full picture either way.
function partsSummary(s) {
const changed = changedParts(s);
const changed = changedParts(s, car.value);
if (!changed.length) return { text: t("common.empty"), muted: true, count: 0 };
const labels = changed.map((part) => t(part.label));
return {
@@ -1221,7 +1227,7 @@ onMounted(load);
>
<p class="eyebrow mb-2">{{ t("forms.service.changedParts") }}</p>
<div
v-for="part in SERVICE_PARTS"
v-for="part in visibleParts(car)"
:key="part.key"
class="flex items-center justify-between gap-4 py-0.5 text-sm"
>
@@ -1879,6 +1885,24 @@ onMounted(load);
{{ t("car.viewPicker.serviceColumnsOrderHint") }}
</p>
<p class="eyebrow mb-2 mt-5">{{ t("car.viewPicker.servicePartsHeading") }}</p>
<div class="grid gap-2 sm:grid-cols-2">
<label
v-for="part in SERVICE_PARTS"
:key="part.key"
class="flex items-center gap-2 text-sm font-medium text-body"
>
<input
type="checkbox"
class="h-4 w-4 rounded border-subtle text-accent focus:ring-accent"
:checked="partDraft.includes(part.key)"
@change="togglePartDraft(part.key, $event.target.checked)"
/>
<span>{{ t(part.label) }}</span>
</label>
</div>
<p class="mt-2 text-xs text-muted">{{ t("car.viewPicker.servicePartsHint") }}</p>
<p v-if="viewError" class="mt-3 text-sm text-danger">{{ viewError }}</p>
<div class="mt-5 flex justify-end gap-2">