Service history: 0 km is a reading, not a blank
A car collected new sits at 0 km, and every km calculation in the app
quietly refused to work for it. ComputeDerived only filled NextServiceKm
when Km > 0, so a service record entered at 0 produced no next-due
distance at all — the date side worked, because it guards on IsZero(),
which is genuine absence rather than a number that happens to be low.
The same conflation had been copied outward from there. The reminder's
km signal wanted currentKm > 0 before it would count anything down, the
web badge and the service-life ring tested the odometer for truthiness,
formatKm printed an em dash for zero, and fuel and charging rejected a
0 km entry as "odometer (km) is required" — which is the first charge
of an EV on the driveway on delivery day. The phone app carried its own
copy of each. Editing such a car offered an empty odometer box, since
the forms only prefilled a reading above zero.
Everywhere the odometer is a measurement, absence is now tested as
absence: null in the clients, negative on the server, and the required
fields check that the box was filled rather than that the number cleared
zero. Fuel and charging validate Km < 0 instead, and their inputs drop
min="1". Completing a repeating km reminder rolls from the car's actual
reading in every case; the old fallback to the previous target existed
to keep an untracked car off a due date in the past, but CurrentKm +
RepeatKm is ahead of the car by construction, so it could not have
happened.
Left as it was: dueKm, repeatKm and the service intervals, where zero
really does encode "no trigger" and "use the default", and the liters
and kwh checks, since a zero fill is not a fill.
Maintenance is the exception. Its odometer is the one that is genuinely
optional, so zero there still has to mean "not recorded" and those three
sites keep the truthiness test, commented. Fixing that properly wants a
nullable field rather than an int, which is a schema change and its own
commit — the same shape of problem as the latency em dash in 3c4eba8.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f521d2b220
commit
9abb03ee4f
@@ -29,7 +29,7 @@ const form = ref({
|
||||
serviceIntervalDays: props.car?.serviceIntervalDays || 365,
|
||||
serviceIntervalKm: props.car?.serviceIntervalKm || 15000,
|
||||
technicalCheckIntervalDays: props.car?.technicalCheckIntervalDays || 365,
|
||||
currentKm: props.car?.currentKm || "",
|
||||
currentKm: props.car?.currentKm ?? "",
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
@@ -55,7 +55,7 @@ async function submit() {
|
||||
serviceIntervalDays: Number(form.value.serviceIntervalDays) || 365,
|
||||
serviceIntervalKm: Number(form.value.serviceIntervalKm) || 15000,
|
||||
technicalCheckIntervalDays: Number(form.value.technicalCheckIntervalDays) || 365,
|
||||
currentKm: form.value.currentKm ? Number(form.value.currentKm) : 0,
|
||||
currentKm: form.value.currentKm === "" ? 0 : Number(form.value.currentKm),
|
||||
};
|
||||
const saved = isEdit
|
||||
? await api.updateCar(props.car.id, payload)
|
||||
|
||||
@@ -92,7 +92,7 @@ function payload() {
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">{{ t("forms.charging.odometer") }}</label>
|
||||
<input v-model="form.km" type="number" min="1" required placeholder="16138" class="dh-input data" />
|
||||
<input v-model="form.km" type="number" min="0" required placeholder="16138" class="dh-input data" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ function payload() {
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">{{ t("forms.fuel.odometer") }}</label>
|
||||
<input v-model="form.km" type="number" min="1" required placeholder="16138" class="dh-input data" />
|
||||
<input v-model="form.km" type="number" min="0" required placeholder="16138" class="dh-input data" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ function payload() {
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-muted">
|
||||
{{ t("forms.reminder.triggerHint") }}
|
||||
<span v-if="car?.currentKm"> {{ t("forms.reminder.currentKm", { km: formatKm(car.currentKm) }) }}</span>
|
||||
<span v-if="car?.currentKm != null"> {{ t("forms.reminder.currentKm", { km: formatKm(car.currentKm) }) }}</span>
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
|
||||
@@ -41,7 +41,9 @@ async function submit() {
|
||||
const payload = {
|
||||
car: props.carId,
|
||||
date: new Date(form.value.date).toISOString(),
|
||||
km: form.value.km ? Number(form.value.km) : 0,
|
||||
// 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,
|
||||
@@ -73,7 +75,7 @@ async function submit() {
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">{{ t("forms.service.odometer") }}</label>
|
||||
<input v-model="form.km" type="number" placeholder="16138" class="dh-input data" />
|
||||
<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">
|
||||
|
||||
@@ -48,8 +48,12 @@ function num(value) {
|
||||
return Number(value).toLocaleString(prefs.locale || undefined);
|
||||
}
|
||||
|
||||
// 0 prints as "0 km", not "—": a car picked up new has an odometer reading, and
|
||||
// blanking it hides the very number the first service interval counts from.
|
||||
// Nothing stores a placeholder 0 for the intervals — the server defaults those
|
||||
// (applyCarDefaults) — so a zero reaching here is a real reading.
|
||||
export function formatKm(value) {
|
||||
if (value == null || value === "" || value === 0) return "—";
|
||||
if (value == null || value === "") return "—";
|
||||
return num(value) + " km";
|
||||
}
|
||||
|
||||
@@ -88,9 +92,11 @@ function dateSignal(nextServiceDate) {
|
||||
return { key: "ok", label: t("status.okDays", { days }) };
|
||||
}
|
||||
|
||||
// kmSignal classifies the current odometer against the next-due km.
|
||||
// kmSignal classifies the current odometer against the next-due km. Both values
|
||||
// are tested for absence rather than truthiness: 0 km is where a new car starts,
|
||||
// and reading that as "no data" strands the km signal until the first drive.
|
||||
function kmSignal(currentKm, nextServiceKm) {
|
||||
if (!currentKm || !nextServiceKm) return { key: "unknown", label: t("status.noKm") };
|
||||
if (currentKm == null || nextServiceKm == null) return { key: "unknown", label: t("status.noKm") };
|
||||
const remaining = nextServiceKm - currentKm;
|
||||
if (remaining < 0) return { key: "overdue", label: t("status.serviceOverdueKm", { km: num(Math.abs(remaining)) }) };
|
||||
if (remaining <= KM_SOON) return { key: "soon", label: t("status.inKm", { km: num(remaining) }) };
|
||||
|
||||
@@ -129,7 +129,8 @@ function serviceLife(car) {
|
||||
const interval = Number(car.serviceIntervalKm);
|
||||
const nextKm = Number(car.latest?.nextServiceKm);
|
||||
const currentKm = Number(car.currentKm);
|
||||
if (!interval || !nextKm || !currentKm) return null;
|
||||
// A new car reads 0 and belongs at 0% of its interval, not hidden entirely.
|
||||
if (!interval || !nextKm || !Number.isFinite(currentKm)) return null;
|
||||
const remaining = nextKm - currentKm;
|
||||
const pct = Math.max(0, Math.min(100, Math.round((1 - remaining / interval) * 100)));
|
||||
return { pct, tone: TONE_COLOR[serviceStatus(car.latest, car).key] || TONE_COLOR.unknown };
|
||||
|
||||
Reference in New Issue
Block a user