A build date you may only half know; one look for an empty cell

Two changes, both about showing what is actually known rather than a tidier
version of it.

The build date asked for a day. A car's build date is often only a year, or a
month and a year - the VIN plate is stamped with a month, the papers carry a
day, a grey import neither - so a field insisting on all three is answered
either with an invented day or with nothing, and both throw away what the owner
did know. The field now picks its own precision: a full date, a month and year,
or a year, each with the control that suits it. A year is typed rather than
picked, because a date picker that makes you walk back to 1998 is worse than
four keystrokes.

Stored as the ISO prefix - "2015", "2015-03", "2015-03-10" - which is ISO 8601
reduced precision, and printed back at exactly that precision. The three shapes
sort and compare as strings in date order, which is why the prefix is stored
rather than a date with a precision field beside it. The formatter takes the
string apart rather than parsing it: "2015-03" read as a UTC instant and printed
in local time hands back February west of Greenwich.

Narrowing the precision keeps what is still true, so a day dropped from
"2015-03-10" leaves "2015-03". Widening clears the field. That is the awkward
half of the control and it is deliberate: there is nothing to widen a year with,
and leaving "2015" behind an empty month box would store a date the screen is
not showing.

The column was free text with no validation at all, which was tolerable while
only a date picker could write it and is not now that three shapes are legal.
normalizeBuildDate parses rather than pattern-matches, so "2015-13" and
"2015-02-31" are refused instead of stored as something no reader can print.

The phone needed changing to avoid destroying this. It parsed buildDate with
DateTime.tryParse, which returns null for "2015" - so a half-known date would
have shown as a dash, and saving the car from the phone would have written ""
back over it. It holds both date fields as the string they arrived as now,
prints them at their own precision, and hands back anything it cannot set. Its
picker still only makes full dates; a precision control there is a separate job.

Separately: an empty cell of the service table had three different looks in one
row. The dash under Notes was body-coloured, as though it were content; the one
under File was 12px, having borrowed the size of the Download button that would
otherwise be there; the one under Changed parts was muted at 14px. They are one
constant now, muted at the row's own size, which is what Next date and Next km
already did for a missing value. The Download link keeps its own styling - it is
an action, not a value.

Verified in a browser: a stored "2015-03" loads as month precision in a month
picker, month to year narrows to "2015", year to day clears, "19x98abc" typed
into the year box sanitises to "1998", saving sends buildDate:"1998" and the
Information tab then reads "1998" - while a full first-registration date beside
it still reads 06-08-2026. All five empty cells across the three columns now
compute to the same size, colour and weight, with the filled ones unchanged. go
vet and go test ./... pass with a new test over the three valid shapes and six
rejects; flutter analyze is clean and 22 tests pass, one new, covering a
half-known date in two date formats and the time zone that could shift it; npm
run build is clean.

Not verified: First registration still demands a full date. The same argument
applies to it and the field is now a reusable component, but it was not asked
for and is one line away. The web formatter's month-name paths - the DMY and MDY
formats, which spell the month out - are covered only by the phone's mirror of
the logic, the web app still having no test runner. A car created through the
Toyota import bypasses the new validation; it only ever produces full dates, so
nothing invalid gets in that way, but it is not guarded. Both apps need
redeploying before any of this is visible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-08-22 12:11:39 +02:00
co-authored by Claude Opus 5
parent c5d431c560
commit d4033dbcef
17 changed files with 358 additions and 28 deletions
+2 -1
View File
@@ -3,6 +3,7 @@ import { ref } from "vue";
import { api } from "../api";
import { t } from "../i18n";
import Modal from "./Modal.vue";
import PartialDateField from "./PartialDateField.vue";
const props = defineProps({ car: { type: Object, default: null } });
const emit = defineEmits(["saved", "close"]);
@@ -121,7 +122,7 @@ async function submit() {
</div>
<div>
<label class="dh-label">{{ t("forms.car.buildDate") }}</label>
<input v-model="form.buildDate" type="date" class="dh-input data" />
<PartialDateField v-model="form.buildDate" />
</div>
<div>
<label class="dh-label">{{ t("forms.car.firstRegistration") }}</label>
@@ -0,0 +1,103 @@
<script setup>
// A date the owner may only half know.
//
// A car's build date is often "2015", or "March 2015", and no more: the VIN
// plate carries a month, the registration papers a day, a grey import neither.
// A field that insists on all three makes the owner either invent a day or
// leave the whole thing blank, and both lose what they actually knew.
//
// The value is an ISO 8601 reduced-precision date — "2015", "2015-03" or
// "2015-03-10" — which is what the API stores and what formatPartialDate
// prints back at exactly the precision it was given.
import { ref, watch } from "vue";
import { t } from "../i18n";
const props = defineProps({ modelValue: { type: String, default: "" } });
const emit = defineEmits(["update:modelValue"]);
const LENGTH = { year: 4, month: 7, day: 10 };
// How much of a date a value carries. An empty field reads as a full date,
// which is what this field was before and what most people will still enter.
function precisionOf(value) {
const v = value || "";
if (v.length >= LENGTH.day) return "day";
if (v.length >= LENGTH.month) return "month";
if (v.length >= LENGTH.year) return "year";
return "day";
}
// Kept beside the value rather than derived from it: an empty field has no
// precision to read, and choosing "Year only" before typing anything would
// otherwise snap straight back to a full date. A value arriving from outside
// (the form loading a car) does set it, since then there is something to read.
const precision = ref(precisionOf(props.modelValue));
const year = ref(precisionOf(props.modelValue) === "year" ? props.modelValue : "");
watch(
() => props.modelValue,
(value) => {
if (!value) return;
precision.value = precisionOf(value);
if (precision.value === "year") year.value = value;
}
);
// Narrowing keeps what is still true: the day dropped from "2015-03-10" leaves
// "2015-03", which is the point of the control. Widening clears, because there
// is nothing to widen it with — and leaving "2015" behind an empty month box
// would store a date the field on screen isn't showing.
function setPrecision(next) {
const kept = LENGTH[next] < LENGTH[precision.value] ? (props.modelValue || "").slice(0, LENGTH[next]) : "";
precision.value = next;
year.value = next === "year" ? kept : "";
emit("update:modelValue", kept);
}
// A year is typed, not picked — a date picker asking you to walk back to 1998 is
// worse than four keystrokes. Digits only, and nothing is emitted until all four
// are there, so a half-typed "20" is never saved as a year.
watch(year, (value) => {
const digits = String(value).replace(/\D/g, "").slice(0, 4);
if (digits !== value) {
year.value = digits; // re-enters here with the cleaned value
return;
}
emit("update:modelValue", digits.length === LENGTH.year ? digits : "");
});
</script>
<template>
<div class="space-y-1.5">
<select :value="precision" class="dh-input" @change="setPrecision($event.target.value)">
<option value="day">{{ t("forms.car.precision.day") }}</option>
<option value="month">{{ t("forms.car.precision.month") }}</option>
<option value="year">{{ t("forms.car.precision.year") }}</option>
</select>
<!-- One control per precision, each the browser's own: a date picker, a
month picker, and a plain box for the year. -->
<input
v-if="precision === 'day'"
type="date"
class="dh-input data"
:value="modelValue"
@input="emit('update:modelValue', $event.target.value)"
/>
<input
v-else-if="precision === 'month'"
type="month"
class="dh-input data"
:value="modelValue"
@input="emit('update:modelValue', $event.target.value)"
/>
<input
v-else
v-model="year"
type="text"
inputmode="numeric"
maxlength="4"
placeholder="2015"
class="dh-input data"
/>
</div>
</template>
+5
View File
@@ -657,6 +657,11 @@
"vinPlaceholder": "Køretøjets stelnummer",
"fuelType": "Brændstoftype",
"buildDate": "Produktionsdato",
"precision": {
"day": "Fuld dato",
"month": "Måned og år",
"year": "Kun år"
},
"firstRegistration": "Første registrering",
"oilSpec": "Motorolie-specifikation",
"currentKm": "Nuværende kilometerstand (km)",
+5
View File
@@ -656,6 +656,11 @@
"vinPlaceholder": "Vehicle Identification Number",
"fuelType": "Fuel type",
"buildDate": "Build date",
"precision": {
"day": "Full date",
"month": "Month & year",
"year": "Year only"
},
"firstRegistration": "First registration",
"oilSpec": "Engine oil spec",
"currentKm": "Current odometer (km)",
+5
View File
@@ -671,6 +671,11 @@
"vinPlaceholder": "Numer identyfikacyjny pojazdu",
"fuelType": "Rodzaj paliwa",
"buildDate": "Data produkcji",
"precision": {
"day": "Pełna data",
"month": "Miesiąc i rok",
"year": "Tylko rok"
},
"firstRegistration": "Pierwsza rejestracja",
"oilSpec": "Specyfikacja oleju silnikowego",
"currentKm": "Aktualny przebieg (km)",
+39
View File
@@ -29,6 +29,45 @@ export function formatDate(value) {
}
}
// A date somebody may only half know. A car's build date is often "2015", or
// "March 2015", and no more than that — the plate carries a month, the papers a
// day, a grey import neither. The value is an ISO 8601 reduced-precision date:
// "2015", "2015-03" or "2015-03-10", and each prints to exactly its own
// precision. Filling the missing parts in with 01 would show the reader two
// numbers nobody supplied.
//
// Split by string rather than run through a Date: "2015-03" parses as UTC
// midnight and reads back in local time, which west of Greenwich hands back
// February. Anything that isn't a reduced-precision date — a full timestamp
// from an older record — falls through to formatDate, which is where it was
// being rendered before.
export function formatPartialDate(value) {
if (!value) return "—";
const parts = /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/.exec(String(value).trim());
if (!parts) return formatDate(value);
const [, year, month, day] = parts;
if (day) return formatDate(value);
if (!month) return year;
// Month and year. The month's name where the user's format spells months out,
// its number where the format is numeric, and always in the order the rest of
// the app puts them in.
const monthName = new Date(Date.UTC(Number(year), Number(month) - 1, 1)).toLocaleDateString(
prefs.locale || undefined,
{ month: "short", timeZone: "UTC" }
);
switch (prefs.dateFormat) {
case "DMY_NUM":
return `${month}-${year}`;
case "DMY":
case "MDY":
return `${monthName} ${year}`;
case "YMD":
default:
return `${year}-${month}`;
}
}
// A timestamp rather than a date: the date in the user's chosen format plus the
// clock time in their region's convention. For the places where freshness is the
// whole point — a live reading pulled from a manufacturer service means little
+16 -4
View File
@@ -5,6 +5,7 @@ import { api } from "../api";
import { prefs } from "../prefs";
import {
formatDate,
formatPartialDate,
formatKm,
formatLiters,
formatMoney,
@@ -428,7 +429,9 @@ const infoFields = computed(() => {
registrationCountry: { text: c.registrationCountry || t("common.empty") },
vin: { text: c.vin || t("common.empty"), mono: true },
fuelType: { text: fuelLabel(c.fuelType) },
buildDate: { text: c.buildDate ? formatDate(c.buildDate) : t("common.empty"), mono: true },
// A build date may be a year or a month rather than a day — printed to
// whatever precision it was given, not padded out to a day nobody knew.
buildDate: { text: c.buildDate ? formatPartialDate(c.buildDate) : t("common.empty"), mono: true },
firstRegistration: {
text: c.firstRegistrationDate ? formatDate(c.firstRegistrationDate) : t("common.empty"),
mono: true,
@@ -477,6 +480,14 @@ const serviceColumns = computed(() =>
.map((key) => ({ key, label: serviceColumnLabel(key) }))
);
// What a cell with nothing in it looks like, in one place. An em dash is not
// content: it reads muted, and at the row's own size rather than at the size of
// whatever button would have stood there instead. The three cells that can be
// empty each used to do this their own way — a body-coloured dash under Notes,
// a smaller one under File, a muted one under Changed parts — so one row showed
// the same "nothing" three different ways.
const EMPTY_CELL = "text-muted";
// One cell of that table. Returns the text and the classes it carries beyond the
// shared padding; the file column is the one whose cell is a button, and says so
// rather than returning text the template would have to special-case by key.
@@ -493,11 +504,12 @@ function serviceCell(s, key) {
case "parts":
return { parts: partsSummary(s), classes: "whitespace-nowrap" };
case "notes":
return { text: s.notes || t("common.empty"), classes: "text-body" };
return { text: s.notes || t("common.empty"), classes: s.notes ? "text-body" : EMPTY_CELL };
default: // file
return { file: true, classes: "whitespace-nowrap" };
}
}
// What the Changed parts cell says before it is opened. Naming the parts beats a
// bare count — the point of a history is to be read down the page — but the list
// has to stay one line wide, and it is going to grow, so past two it becomes the
@@ -1175,12 +1187,12 @@ onMounted(load);
<button v-if="s.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('services', s)">
{{ t("common.download") }}
</button>
<span v-else class="text-xs text-muted">{{ t("common.empty") }}</span>
<span v-else :class="EMPTY_CELL">{{ t("common.empty") }}</span>
</template>
<template v-else-if="cell.parts">
<button
class="inline-flex items-center gap-1.5 text-left hover:underline"
:class="cell.parts.muted ? 'text-muted' : 'text-body'"
:class="cell.parts.muted ? EMPTY_CELL : 'text-body'"
:aria-expanded="openParts === s.id"
@click.stop="togglePartsPanel(s.id, $event)"
>