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:
co-authored by
Claude Opus 5
parent
c5d431c560
commit
d4033dbcef
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -32,8 +32,14 @@ class CarFormSheet extends StatefulWidget {
|
||||
class _CarFormSheetState extends State<CarFormSheet> {
|
||||
late final Map<String, TextEditingController> _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<CarFormSheet> {
|
||||
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<CarFormSheet> {
|
||||
"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<CarFormSheet> {
|
||||
|
||||
/// 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<DateTime?> 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<String> 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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user