diff --git a/API Server/internal/api/cars.go b/API Server/internal/api/cars.go index eccfad5..6ecc9f2 100644 --- a/API Server/internal/api/cars.go +++ b/API Server/internal/api/cars.go @@ -8,6 +8,7 @@ import ( "net/url" "sort" "strings" + "time" "drivervault/apiserver/internal/models" ) @@ -198,6 +199,12 @@ func (s *Server) createCar(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "name is required") return } + buildDate, err := normalizeBuildDate(in.BuildDate) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + in.BuildDate = buildDate applyCarDefaults(&in) // Owner is always the authenticated user; ignore any client-supplied owner. @@ -229,6 +236,12 @@ func (s *Server) updateCar(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusForbidden, "you cannot edit this car") return } + buildDate, err := normalizeBuildDate(in.BuildDate) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + in.BuildDate = buildDate // carPayload deliberately omits owner, so a PATCH never reassigns ownership. var rec carRecord if err := s.pb.Update(r.Context(), colCars, r.PathValue("id"), carPayload(in), &rec); err != nil { @@ -475,6 +488,42 @@ func (s *Server) deleteCar(w http.ResponseWriter, r *http.Request) { // applyCarDefaults fills the spreadsheet's default maintenance intervals when // the client didn't specify them. +// normalizeBuildDate checks a build date and hands back the value to store. +// +// It is an ISO 8601 reduced-precision date: a year, a year and a month, or a +// full date. A car's build date is often only half known — the VIN plate +// carries a month, the papers a day, a grey import neither — and a field that +// insisted on all three would be answered either with an invented day or with +// nothing. The three shapes sort and compare as strings in date order, which is +// why the prefix is stored rather than a date plus a precision beside it. +// +// Validated rather than taken as typed, because the column is free text: the +// month has to be a month and the day has to exist, or the stored value is +// something no reader can print. +func normalizeBuildDate(v string) (string, error) { + v = strings.TrimSpace(v) + if v == "" { + return "", nil + } + var layout string + switch len(v) { + case len("2006"): + layout = "2006" + case len("2006-01"): + layout = "2006-01" + case len("2006-01-02"): + layout = "2006-01-02" + default: + return "", fmt.Errorf("build date %q must be a year, a year and month, or a full date", v) + } + // time.Parse rejects month 13 and 31 February for us, so the stored value is + // always a date that happened. + if _, err := time.Parse(layout, v); err != nil { + return "", fmt.Errorf("build date %q must be a year, a year and month, or a full date", v) + } + return v, nil +} + func applyCarDefaults(c *models.Car) { if c.ServiceIntervalDays <= 0 { c.ServiceIntervalDays = 365 diff --git a/API Server/internal/api/cars_test.go b/API Server/internal/api/cars_test.go new file mode 100644 index 0000000..4e956f0 --- /dev/null +++ b/API Server/internal/api/cars_test.go @@ -0,0 +1,45 @@ +package api + +import "testing" + +// A build date is stored as an ISO 8601 reduced-precision date, because it is +// often only half known: the VIN plate carries a month, the papers a day, a grey +// import neither. The column is free text, so the shapes have to be checked here +// or a value no reader can print gets stored. +func TestNormalizeBuildDate(t *testing.T) { + for _, tc := range []struct { + in string + want string + }{ + {"", ""}, // not set at all + {"2015", "2015"}, // the year off the plate + {"2015-03", "2015-03"}, // year and month + {"2015-03-10", "2015-03-10"}, // the papers' own date + {" 2015-03 ", "2015-03"}, // trimmed, like every other text field + {"2024-02-29", "2024-02-29"}, // a leap day is a day + } { + got, err := normalizeBuildDate(tc.in) + if err != nil { + t.Errorf("normalizeBuildDate(%q): %v", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("normalizeBuildDate(%q) = %q, want %q", tc.in, got, tc.want) + } + } + + // Rejected rather than stored and puzzled over later. The last two are the + // ones a plain length check would have let through. + for _, bad := range []string{ + "15", // two-digit year + "2015-3", // unpadded month + "2015/03/10", // not ISO + "March 2015", // words + "2015-13", // there is no thirteenth month + "2015-02-31", // there is no such day + } { + if got, err := normalizeBuildDate(bad); err == nil { + t.Errorf("normalizeBuildDate(%q) = %q, want an error", bad, got) + } + } +} diff --git a/API Server/internal/bootstrap/schema.go b/API Server/internal/bootstrap/schema.go index b46ecab..16e88e6 100644 --- a/API Server/internal/bootstrap/schema.go +++ b/API Server/internal/bootstrap/schema.go @@ -32,7 +32,9 @@ var collectionsSchema = map[string][]fieldDef{ fSelect("fuel_type", []string{ "petrol", "petrol_lpg", "diesel", "diesel_lpg", "hybrid", "electric", "hydrogen", }, false), - fText("build_date", false), // ISO YYYY-MM-DD (date-only) + // ISO 8601 reduced precision: "2015", "2015-03" or "2015-03-10". A build + // date is often only half known; see normalizeBuildDate in api/cars.go. + fText("build_date", false), fText("first_registration_date", false), // ISO YYYY-MM-DD // Link to the manufacturer service this car came from: the plugin name plus // that plugin's own id for the vehicle (the VIN, for Toyota). See diff --git a/API Server/internal/models/models.go b/API Server/internal/models/models.go index 02d2722..7465684 100644 --- a/API Server/internal/models/models.go +++ b/API Server/internal/models/models.go @@ -55,7 +55,7 @@ type Car struct { CoolantSpec string `json:"coolantSpec"` // e.g. "Toyota Super Long Life Coolant" FuelType string `json:"fuelType"` // petrol | petrol_lpg | diesel | diesel_lpg | hybrid | electric | hydrogen - BuildDate string `json:"buildDate"` // ISO YYYY-MM-DD (date-only) + BuildDate string `json:"buildDate"` // ISO 8601, precision as known: "2015" | "2015-03" | "2015-03-10" FirstRegistrationDate string `json:"firstRegistrationDate"` // ISO YYYY-MM-DD (date-only) // Provider links this car to the manufacturer service it came from — the name diff --git a/Phone App/lib/format.dart b/Phone App/lib/format.dart index b7a8d2e..2050528 100644 --- a/Phone App/lib/format.dart +++ b/Phone App/lib/format.dart @@ -37,6 +37,34 @@ String formatDate(DateTime? d) { return DateFormat(pattern, _locale).format(d); } +/// A date somebody may only half know, as an ISO 8601 reduced-precision date: +/// "2015", "2015-03" or "2015-03-10". Mirrors formatPartialDate in the web +/// app's format.js, which is where such a value is entered — a car's build date +/// is often only a year, and this app must not print a day nobody supplied. +/// +/// Taken apart as a string rather than parsed: DateTime.parse rejects "2015" +/// and "2015-03" outright, which is exactly why the value has to be handled +/// here at all. +String formatPartialDate(String iso) { + final value = iso.trim(); + if (value.isEmpty) return "—"; + final match = RegExp(r"^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$").firstMatch(value); + if (match == null) return formatDate(DateTime.tryParse(value)); + final year = match.group(1)!; + final month = match.group(2); + if (match.group(3) != null) return formatDate(DateTime.tryParse(value)); + if (month == null) return year; + + // Month and year: the month's name where the user's format spells months out, + // its number where the format is numeric, in the app's usual order either way. + final when = DateTime(int.parse(year), int.parse(month)); + return switch (appSettings.dateFormat) { + "DMY_NUM" => "$month-$year", + "DMY" || "MDY" => DateFormat("MMM yyyy", _locale).format(when), + _ => "$year-$month", + }; +} + /// A timestamp: the user's chosen date format with the wall clock beside it. /// Only the provider snapshot needs one — everything else in the app is /// date-only — but it follows the same settings as [formatDate] so the two never diff --git a/Phone App/lib/models.dart b/Phone App/lib/models.dart index 2024a22..5728237 100644 --- a/Phone App/lib/models.dart +++ b/Phone App/lib/models.dart @@ -61,7 +61,10 @@ class Car { final String brakeFluidSpec; final String coolantSpec; final String fuelType; // petrol | petrol_lpg | diesel | diesel_lpg | hybrid | electric | hydrogen - final String buildDate; // ISO YYYY-MM-DD (date-only) + // ISO 8601 reduced precision: "2015", "2015-03" or "2015-03-10". Entered on + // the web, which can say how much of it is known; this app's date picker only + // makes full ones, but it reads and preserves the rest. See formatPartialDate. + final String buildDate; final String firstRegistrationDate; // ISO YYYY-MM-DD (date-only) final int serviceIntervalDays; final int serviceIntervalKm; diff --git a/Phone App/lib/screens/car_detail_screen.dart b/Phone App/lib/screens/car_detail_screen.dart index acdaf3a..a34318d 100644 --- a/Phone App/lib/screens/car_detail_screen.dart +++ b/Phone App/lib/screens/car_detail_screen.dart @@ -727,8 +727,10 @@ class _InfoTab extends StatelessWidget { "registrationCountry": _orDash(car.registrationCountry), "vin": _orDash(car.vin), "fuelType": _fuelLabel(car.fuelType), - "buildDate": _dateOrDash(car.buildDate), - "firstRegistration": _dateOrDash(car.firstRegistrationDate), + // Half-known dates print at their own precision: a build date is often + // only a year, and _dateOrDash would show a dash for one. + "buildDate": formatPartialDate(car.buildDate), + "firstRegistration": formatPartialDate(car.firstRegistrationDate), }; @override @@ -787,11 +789,6 @@ class _InfoTab extends StatelessWidget { static String _fuelLabel(String v) => kFuelTypes.contains(v) ? t("enums.fuelType.$v") : "—"; - static String _dateOrDash(String iso) { - final d = DateTime.tryParse(iso); - return d == null ? "—" : formatDate(d); - } - Widget _kv(BuildContext context, String k, String v) => Padding( padding: const EdgeInsets.symmetric(vertical: 3), child: Row( diff --git a/Phone App/lib/screens/car_form_sheet.dart b/Phone App/lib/screens/car_form_sheet.dart index 62788f2..c8d0c29 100644 --- a/Phone App/lib/screens/car_form_sheet.dart +++ b/Phone App/lib/screens/car_form_sheet.dart @@ -32,8 +32,14 @@ class CarFormSheet extends StatefulWidget { class _CarFormSheetState extends State { late final Map _c; String _fuelType = ""; - DateTime? _buildDate; - DateTime? _firstRegistrationDate; + // Held as the ISO string they arrived as, not as a DateTime. A build date may + // be a year or a month rather than a day — the web app can enter one, this + // sheet's date picker cannot — and parsing it into a DateTime would hand back + // null, which on save would round somebody's "2015" down to nothing. Picking a + // date replaces the value; the clear button empties it; anything else is + // handed back exactly as it came. + String _buildDate = ""; + String _firstRegistrationDate = ""; bool _saving = false; String? _error; @@ -44,9 +50,8 @@ class _CarFormSheetState extends State { super.initState(); final car = widget.car; _fuelType = car?.fuelType ?? ""; - _buildDate = (car?.buildDate.isNotEmpty ?? false) ? DateTime.tryParse(car!.buildDate) : null; - _firstRegistrationDate = - (car?.firstRegistrationDate.isNotEmpty ?? false) ? DateTime.tryParse(car!.firstRegistrationDate) : null; + _buildDate = car?.buildDate ?? ""; + _firstRegistrationDate = car?.firstRegistrationDate ?? ""; _c = { "name": TextEditingController(text: car?.name ?? ""), "make": TextEditingController(text: car?.make ?? ""), @@ -98,8 +103,8 @@ class _CarFormSheetState extends State { "registrationCountry": _c["registrationCountry"]!.text.trim(), "vin": _c["vin"]!.text.trim(), "fuelType": _fuelType, - "buildDate": _isoOrEmpty(_buildDate), - "firstRegistrationDate": _isoOrEmpty(_firstRegistrationDate), + "buildDate": _buildDate, + "firstRegistrationDate": _firstRegistrationDate, "oilSpec": _c["oilSpec"]!.text.trim(), "transmissionOilSpec": _c["transmissionOilSpec"]!.text.trim(), "differentialOilSpec": _c["differentialOilSpec"]!.text.trim(), @@ -149,32 +154,37 @@ class _CarFormSheetState extends State { /// A tappable read-only field that opens a date picker. Shows a clear button /// when a date is set, otherwise a calendar icon. - Widget _dateField(String label, DateTime? value, ValueChanged onChanged) { + /// + /// The value is an ISO string rather than a DateTime so that a half-known date + /// entered on the web — "2015", "2015-03" — survives a save here: it is shown + /// at the precision it has and handed back untouched unless the owner picks a + /// new date, which this picker can only ever make a full one. + Widget _dateField(String label, String value, ValueChanged onChanged) { return Padding( padding: const EdgeInsets.only(bottom: 10), child: InkWell( onTap: () async { final picked = await showDatePicker( context: context, - initialDate: value ?? DateTime.now(), + initialDate: DateTime.tryParse(value) ?? DateTime.now(), firstDate: DateTime(1950), lastDate: DateTime.now().add(const Duration(days: 365)), ); - if (picked != null) setState(() => onChanged(picked)); + if (picked != null) setState(() => onChanged(_isoOrEmpty(picked))); }, child: InputDecorator( decoration: InputDecoration( labelText: label, border: const OutlineInputBorder(), isDense: true, - suffixIcon: value == null + suffixIcon: value.isEmpty ? const Icon(Icons.calendar_today, size: 18) : IconButton( icon: const Icon(Icons.clear, size: 18), - onPressed: () => setState(() => onChanged(null)), + onPressed: () => setState(() => onChanged("")), ), ), - child: Text(value == null ? "—" : formatDate(value)), + child: Text(formatPartialDate(value)), ), ), ); diff --git a/Phone App/test/models_format_test.dart b/Phone App/test/models_format_test.dart index 673b54a..d48c116 100644 --- a/Phone App/test/models_format_test.dart +++ b/Phone App/test/models_format_test.dart @@ -156,6 +156,24 @@ void main() { appSettings.locale = "pl-PL"; }); + test("a half-known date prints at its own precision, not padded to a day", () { + // What the web app can now enter for a build date. The phone's picker only + // makes full dates, but it has to read and keep these. + appSettings.dateFormat = "DMY_NUM"; + expect(formatPartialDate("2015"), "2015"); + expect(formatPartialDate("2015-03"), "03-2015"); + expect(formatPartialDate("2015-03-10"), "10-03-2015"); + expect(formatPartialDate(""), "—"); + + // The year and the month must not be shuffled by the local time zone, which + // is what parsing "2015-03" as a UTC instant would risk. + appSettings.dateFormat = "YMD"; + expect(formatPartialDate("2015-01"), "2015-01"); + expect(formatPartialDate("2015-12"), "2015-12"); + + appSettings.dateFormat = "DMY_NUM"; + }); + test("Car carries the technical check interval", () { final car = Car.fromJson({"id": "c", "name": "Yaris", "technicalCheckIntervalDays": 730}); expect(car.technicalCheckIntervalDays, 730); diff --git a/Web App/README.md b/Web App/README.md index 7a26fbb..c719be5 100644 --- a/Web App/README.md +++ b/Web App/README.md @@ -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. +- **A build date nobody fully knows** — the Build date field picks its own + precision: a full date, a month and year, or a year on its own. A car's build + date is often only half known — the VIN plate carries a month, the papers a + day, a grey import neither — and it is stored as the ISO prefix ("2015", + "2015-03") and printed back at exactly that precision rather than padded out + to a day nobody supplied. Narrowing the precision keeps what is still true; + widening clears the field, since there is nothing to widen it with. First + registration still asks for a full date. - **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) diff --git a/Web App/web/src/components/CarFormModal.vue b/Web App/web/src/components/CarFormModal.vue index adcd9ed..3c74515 100644 --- a/Web App/web/src/components/CarFormModal.vue +++ b/Web App/web/src/components/CarFormModal.vue @@ -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() {
- +
diff --git a/Web App/web/src/components/PartialDateField.vue b/Web App/web/src/components/PartialDateField.vue new file mode 100644 index 0000000..c7e00ae --- /dev/null +++ b/Web App/web/src/components/PartialDateField.vue @@ -0,0 +1,103 @@ + + + diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index 39db230..bccdfda 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -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)", diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index a779af8..02182e4 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -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)", diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index 0b7dd13..6609ceb 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -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)", diff --git a/Web App/web/src/lib/format.js b/Web App/web/src/lib/format.js index a81d3cd..b638798 100644 --- a/Web App/web/src/lib/format.js +++ b/Web App/web/src/lib/format.js @@ -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 diff --git a/Web App/web/src/views/CarDetail.vue b/Web App/web/src/views/CarDetail.vue index 0a44998..79546a2 100644 --- a/Web App/web/src/views/CarDetail.vue +++ b/Web App/web/src/views/CarDetail.vue @@ -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); - {{ t("common.empty") }} + {{ t("common.empty") }}