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>
283 lines
11 KiB
Go
283 lines
11 KiB
Go
package bootstrap
|
|
|
|
// This file is the Go mirror of the DESIRED schema, create order, reconcile
|
|
// order and INDEXES in scripts/setup-pocketbase.mjs. Keep the two in sync: edit
|
|
// here and re-run the server (bootstrap applies on startup), or run the script.
|
|
//
|
|
// Access rules are left null on every collection on purpose — every client goes
|
|
// through the API Server, which authenticates as a superuser, so the database is
|
|
// never exposed directly (including attachments, proxied by the API).
|
|
|
|
// collectionsSchema is the desired field set per collection.
|
|
var collectionsSchema = map[string][]fieldDef{
|
|
"cars": {
|
|
fText("name", true),
|
|
fText("make", false),
|
|
fText("model", false),
|
|
fNumber("year"),
|
|
fText("registration", false),
|
|
fText("registration_country", false),
|
|
fText("vin", false),
|
|
fNumber("service_interval_days"),
|
|
fNumber("service_interval_km"),
|
|
// Roadworthiness inspection cycle. Only prefills a check's next-due date.
|
|
fNumber("technical_check_interval_days"),
|
|
fText("oil_spec", false),
|
|
fText("transmission_oil_spec", false),
|
|
fText("differential_oil_spec", false),
|
|
fText("brake_fluid_spec", false),
|
|
fText("coolant_spec", false),
|
|
fNumber("current_km"),
|
|
// Bi-fuel LPG conversions are their own choice: the car runs on either tank.
|
|
fSelect("fuel_type", []string{
|
|
"petrol", "petrol_lpg", "diesel", "diesel_lpg", "hybrid", "electric", "hydrogen",
|
|
}, false),
|
|
// 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
|
|
// internal/api/vehicleproviders.go. Blank for a hand-entered car.
|
|
fText("provider", false),
|
|
fText("provider_vehicle_id", false),
|
|
// What this car's page shows: the tabs switched off (["fuel"] on an EV)
|
|
// and the Information fields switched off (["differentialOil"] on a car
|
|
// without one). Properties of the car, so everyone it is shared with sees
|
|
// the same page. Stored as the hidden sets, so anything added in a later
|
|
// release is on by default. Keys are validated in internal/api/cars.go.
|
|
fJSON("hidden_tabs", 2000),
|
|
fJSON("hidden_fields", 2000),
|
|
// And the columns of the Service history table (["parts"] for a reader who
|
|
// never records what was changed). Date is not hideable and so never
|
|
// appears here.
|
|
fJSON("hidden_service_columns", 2000),
|
|
// The order the tabs are laid out in, as tab keys, the same for the
|
|
// Information rows, the Service history columns, and the connected
|
|
// service's headline readings. Empty means the page's own default order.
|
|
fJSON("tab_order", 2000),
|
|
fJSON("field_order", 2000),
|
|
fJSON("service_column_order", 2000),
|
|
fJSON("metric_order", 2000),
|
|
// Owner of this car. Non-cascading: deleting a user must not wipe their cars.
|
|
fRelation("owner", "users", false, false),
|
|
},
|
|
"service_records": {
|
|
fRelation("car", "cars", true, true),
|
|
fDate("date", true),
|
|
fNumber("km"),
|
|
fBool("changed_oil"),
|
|
fBool("changed_engine_air_filter"),
|
|
fBool("changed_cabin_air_filter"),
|
|
fText("notes", false),
|
|
attachment(), // the workshop receipt / stamped service-book page
|
|
},
|
|
// Mandatory roadworthiness inspections (przegląd techniczny / MOT / TÜV).
|
|
"technical_checks": {
|
|
fRelation("car", "cars", true, true),
|
|
fDate("date", true),
|
|
fSelect("result", []string{"passed", "failed"}, false),
|
|
fNumber("cost"),
|
|
fText("station", false),
|
|
fDate("valid_until", false),
|
|
fText("notes", false),
|
|
attachment(), // the certificate
|
|
},
|
|
"parts": {
|
|
fRelation("car", "cars", true, true),
|
|
fText("name", true),
|
|
fText("part_number", false),
|
|
fText("category", false),
|
|
fText("notes", false),
|
|
attachment(), // a photo of the box, or the part's spec sheet
|
|
},
|
|
// Fuel refills. Consumption is derived on read, not stored.
|
|
"fuel_entries": {
|
|
fRelation("car", "cars", true, true),
|
|
fDate("date", true),
|
|
fNumber("km"), // odometer at the pump
|
|
fNumber("liters"),
|
|
fNumber("cost"),
|
|
fBool("full_tank"),
|
|
fBool("missed_fill"),
|
|
fText("station", false),
|
|
fText("notes", false),
|
|
attachment(), // the pump receipt
|
|
},
|
|
// Charging sessions for an electric car — the EV counterpart of fuel_entries,
|
|
// same shape so the two logs behave alike. Consumption is derived on read.
|
|
"charging_sessions": {
|
|
fRelation("car", "cars", true, true),
|
|
fDate("date", true),
|
|
fNumber("km"), // odometer when plugging in
|
|
fNumber("kwh"),
|
|
fNumber("cost"),
|
|
fBool("full_charge"),
|
|
fBool("missed_session"),
|
|
fText("location", false), // "Home", "Ionity Køge"
|
|
fText("notes", false),
|
|
attachment(), // the charge point's receipt
|
|
},
|
|
// Workshop visits and repairs (unplanned/one-off garage work with a labour bill).
|
|
"maintenance_entries": {
|
|
fRelation("car", "cars", true, true),
|
|
fDate("date", true),
|
|
fNumber("km"),
|
|
fSelect("type", []string{"repair", "inspection", "bodywork", "tyres", "diagnostics", "recall", "warranty", "other"}, false),
|
|
fSelect("status", []string{"scheduled", "in_progress", "completed"}, false),
|
|
fText("workshop", false),
|
|
fText("location", false),
|
|
fText("description", false),
|
|
fText("parts_used", false),
|
|
fNumber("labor_cost"),
|
|
fNumber("parts_cost"),
|
|
fText("invoice_number", false),
|
|
fDate("warranty_until", false),
|
|
fText("notes", false),
|
|
attachment(), // the workshop's invoice
|
|
},
|
|
// Insurance, pollution certificates, registration papers … expiry drives reminders.
|
|
"car_documents": {
|
|
fRelation("car", "cars", true, true),
|
|
fSelect("type", []string{"insurance", "pollution", "registration", "inspection", "roadTax", "warranty", "other"}, false),
|
|
fText("title", true),
|
|
fText("provider", false),
|
|
fText("reference", false),
|
|
fDate("issue_date", false),
|
|
fDate("expiry_date", false),
|
|
fNumber("cost"),
|
|
fText("notes", false),
|
|
attachment(), // the scan/PDF of the paperwork
|
|
},
|
|
// User-set reminders (derived document/service ones are computed on read).
|
|
"reminders": {
|
|
fRelation("car", "cars", true, true),
|
|
fText("title", true),
|
|
fSelect("type", []string{"maintenance", "document", "service", "inspection", "other"}, false),
|
|
fDate("due_date", false),
|
|
fNumber("due_km"),
|
|
fNumber("repeat_days"),
|
|
fNumber("repeat_km"),
|
|
fBool("done"),
|
|
fDate("done_at", false),
|
|
fText("notes", false),
|
|
},
|
|
// Per-car sharing grants. Cascades on both relations.
|
|
"car_shares": {
|
|
fRelation("car", "cars", true, true),
|
|
fRelation("user", "users", true, true),
|
|
fSelect("permission", []string{"read", "write"}, true),
|
|
fAutodate("created", true, false),
|
|
},
|
|
// Append-only audit trail for OCPP charger control. Actor/org stored as plain
|
|
// text ids (not relations) so the trail survives user or org deletion.
|
|
"control_audit": {
|
|
fText("user_id", false),
|
|
fText("org_id", false),
|
|
fText("serial", false),
|
|
fText("action", true),
|
|
fText("result", false),
|
|
fJSON("params", 10000),
|
|
fAutodate("created", true, false),
|
|
},
|
|
// Server-wide settings as a single record, keyed "global". Today it holds
|
|
// pluginSettings: the top (L1) layer of the integration cascade — every
|
|
// plugin's enable state, its global config, and the registration of any
|
|
// external HTTP plugin. The org (L2) and user (L3) layers keep their own
|
|
// plugin config in a field of the same name below, so the global layer is
|
|
// stored the way they are instead of in a file beside the binary.
|
|
"app_settings": {
|
|
fText("key", true),
|
|
fJSON("pluginSettings", 200000),
|
|
},
|
|
// Tenants that users belong to.
|
|
"organizations": {
|
|
fText("name", true),
|
|
fAutodate("created", true, false),
|
|
// Per-organization plugin/integration config (middle layer of the cascade).
|
|
fJSON("pluginSettings", 100000),
|
|
},
|
|
// Custom fields layered onto the built-in "users" auth collection.
|
|
"users": {
|
|
fText("bio", false),
|
|
fSelect("theme", []string{"light", "dark", "system"}, false),
|
|
fText("locale", false),
|
|
fSelect("date_format", []string{"YMD", "DMY_NUM", "DMY", "MDY"}, false),
|
|
fSelect("currency", []string{
|
|
"EUR", "GBP", "CHF", "PLN", "CZK", "HUF", "RON", "BGN", "DKK", "SEK", "NOK",
|
|
"ISK", "ALL", "AMD", "AZN", "BAM", "BYN", "GEL", "MDL", "MKD", "RSD", "RUB",
|
|
"TRY", "UAH", "USD", "CAD", "AUD", "JPY",
|
|
}, false),
|
|
fSelect("font_size", []string{"small", "medium", "large"}, false),
|
|
// Holds every arrangement on this user's pages still — the garage, a
|
|
// car's tabs and Information rows, the provider's readings — so reading a
|
|
// page cannot nudge its layout. Per user, like the garage order.
|
|
fBool("drag_locked"),
|
|
fDate("deletion_requested_at", false),
|
|
// Access role. Empty value is treated as "user" by the API.
|
|
fSelect("role", []string{"user", "admin", "superadmin"}, false),
|
|
// Organization membership. Non-cascading: deleting an org keeps its people.
|
|
fRelation("organization", "organizations", false, false),
|
|
// Per-user plugin/integration config (bottom layer of the cascade).
|
|
fJSON("pluginSettings", 100000),
|
|
// The garage order: car ids in the order this user arranged them. Per
|
|
// user rather than per car, so it also covers cars shared with them and
|
|
// never reorders somebody else's garage.
|
|
fJSON("car_order", 20000),
|
|
},
|
|
}
|
|
|
|
// createOrder is the dependency order for creating missing collections.
|
|
// "users" is PocketBase's built-in auth collection and is never created here.
|
|
var createOrder = []string{
|
|
"app_settings",
|
|
"organizations",
|
|
"cars",
|
|
"service_records",
|
|
"technical_checks",
|
|
"parts",
|
|
"car_shares",
|
|
"fuel_entries",
|
|
"charging_sessions",
|
|
"maintenance_entries",
|
|
"car_documents",
|
|
"reminders",
|
|
"control_audit",
|
|
}
|
|
|
|
// reconcileOrder additionally includes "users" so its custom fields (role,
|
|
// organization, preferences) are added to the built-in collection.
|
|
var reconcileOrder = []string{
|
|
"app_settings",
|
|
"organizations",
|
|
"users",
|
|
"cars",
|
|
"service_records",
|
|
"technical_checks",
|
|
"parts",
|
|
"car_shares",
|
|
"fuel_entries",
|
|
"charging_sessions",
|
|
"maintenance_entries",
|
|
"car_documents",
|
|
"reminders",
|
|
"control_audit",
|
|
}
|
|
|
|
// indexes are extra SQL indexes applied at collection-create time.
|
|
var indexes = map[string][]string{
|
|
// One settings record per key, so the global singleton cannot be duplicated.
|
|
"app_settings": {"CREATE UNIQUE INDEX `idx_app_settings_key` ON `app_settings` (`key`)"},
|
|
"organizations": {"CREATE UNIQUE INDEX `idx_organizations_name` ON `organizations` (`name`)"},
|
|
"fuel_entries": {"CREATE INDEX `idx_fuel_entries_car_km` ON `fuel_entries` (`car`, `km`)"},
|
|
"charging_sessions": {"CREATE INDEX `idx_charging_sessions_car_km` ON `charging_sessions` (`car`, `km`)"},
|
|
"maintenance_entries": {"CREATE INDEX `idx_maintenance_entries_car_date` ON `maintenance_entries` (`car`, `date`)"},
|
|
"car_documents": {"CREATE INDEX `idx_car_documents_car_expiry` ON `car_documents` (`car`, `expiry_date`)"},
|
|
"reminders": {"CREATE INDEX `idx_reminders_car_due` ON `reminders` (`car`, `due_date`)"},
|
|
"technical_checks": {"CREATE INDEX `idx_technical_checks_car_date` ON `technical_checks` (`car`, `date`)"},
|
|
"control_audit": {
|
|
"CREATE INDEX `idx_control_audit_serial_created` ON `control_audit` (`serial`, `created`)",
|
|
"CREATE INDEX `idx_control_audit_user_created` ON `control_audit` (`user_id`, `created`)",
|
|
},
|
|
}
|