The scheduler's day picker began on Sunday because that is where Intl numbers the days from, which is a fact about the API and not about anybody's week. Monday leads it across most of Europe. A row of seven buttons in the wrong order is not just odd to read — it is easy to misclick, and a misclicked day in a schedule is a car charging on the wrong night. So Settings › Appearance asks, beneath the date and the clock, as the third question a region gets: first day of the week, following the region unless it is answered outright. The same shape the time format already had, and the same "auto" default, so nothing changes for an account that never opens it. The rule lives in lib/format.js beside the clock's, with the ordering, the day names and the sort all coming from there. The two places that lay weekdays out — the picker and the line each task is summarised on — read it rather than each keeping an opinion, so a day set is written and read back in the same order. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
361 lines
15 KiB
Go
361 lines
15 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),
|
|
// And the parts its services never change (["oil"] on an EV), which come
|
|
// off the service form and out of that column together.
|
|
fJSON("hidden_service_parts", 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),
|
|
},
|
|
// The chargers a user owns — their own wallbox, not the public network. A
|
|
// charger belongs to a person rather than to a car: it charges whichever car
|
|
// is plugged into it, and it outlives any of them.
|
|
"home_chargers": {
|
|
fText("name", true),
|
|
fText("serial", false),
|
|
fText("vendor", false), // "Anker Solix", "Greencell" — who makes it
|
|
fText("model", false), // "A5191"
|
|
fText("site_name", false),
|
|
fNumber("power_kw"),
|
|
fText("connector", false),
|
|
// Where this charger came from: the charger-provider id plus that
|
|
// provider's own id for it (the serial, for both providers we speak to).
|
|
// Blank for one added by hand. See internal/api/chargerproviders.go.
|
|
fText("provider", false),
|
|
fText("provider_charger_id", false),
|
|
// Owner. Non-cascading, like a car's: deleting a user must not silently
|
|
// wipe the records they own.
|
|
fRelation("owner", "users", false, false),
|
|
// A charger carries no date of its own, so the import order is the only
|
|
// order there is to list them in. PocketBase adds no created field to a
|
|
// collection defined through the API, so it is declared here like the
|
|
// audit trail's.
|
|
fAutodate("created", true, false),
|
|
},
|
|
// The home-charger scheduler: the user's own list of charging tasks, one list
|
|
// covering every charger they own. The charger's own cloud schedule holds one
|
|
// window per box; this holds as many tasks as they like, each naming its own
|
|
// chargers, days and action. Run by the ticker in internal/api/chargingtasks_run.go.
|
|
"charging_tasks": {
|
|
fText("name", true),
|
|
// The home_chargers rows this task acts on. Stored as a list of ids rather
|
|
// than a relation because empty has to mean "every charger I own" — a
|
|
// standing wish that keeps covering chargers imported later — and a
|
|
// multi-relation would have to be rewritten on every import to say it.
|
|
fJSON("chargers", 2000),
|
|
fSelect("action", []string{"start", "stop", "limit", "boost"}, true),
|
|
fNumber("amps"), // the ceiling, for the "limit" action
|
|
// 24-hour "HH:MM", read in the IANA zone the task was written in. The
|
|
// server's clock is not the one the user set the time by.
|
|
fText("time", true),
|
|
fText("zone", false),
|
|
fJSON("days", 200), // 0=Sunday … 6=Saturday; empty means every day
|
|
fBool("enabled"),
|
|
// The outcome of the last firing, so a task that has been failing quietly
|
|
// says so in the list. last_run is also the guard against firing twice in
|
|
// the same minute.
|
|
fText("last_run", false), // RFC3339, UTC
|
|
fText("last_result", false),
|
|
// Owner. Non-cascading, like a charger's.
|
|
fRelation("owner", "users", false, false),
|
|
fAutodate("created", true, false),
|
|
},
|
|
// 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),
|
|
// "auto" is the region's own convention, which is what every clock in the
|
|
// app read before this field existed.
|
|
fSelect("time_format", []string{"auto", "24", "12"}, false),
|
|
// The day a week is drawn as starting on, wherever weekdays are laid out
|
|
// in a row. "auto" is the region's own convention, which is what the
|
|
// scheduler's day picker read before this field existed.
|
|
fSelect("week_start", []string{"auto", "monday", "sunday"}, 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),
|
|
// The charging page's tab order. Per user like the garage order, and for
|
|
// the same reason: it is this person's arrangement of their own page.
|
|
fJSON("charger_tab_order", 2000),
|
|
// The charging page's card order, stored the same way as its tab order.
|
|
fJSON("charger_card_order", 2000),
|
|
},
|
|
}
|
|
|
|
// 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",
|
|
"home_chargers",
|
|
"charging_tasks",
|
|
}
|
|
|
|
// 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",
|
|
"home_chargers",
|
|
"charging_tasks",
|
|
}
|
|
|
|
// 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`)"},
|
|
// A charger is looked up by its owner, and by serial when checking whether
|
|
// the account it came from has already been imported.
|
|
"home_chargers": {"CREATE INDEX `idx_home_chargers_owner_serial` ON `home_chargers` (`owner`, `serial`)"},
|
|
// The runner sweeps every enabled task on every tick, and the page reads one
|
|
// owner's; both go through these two columns.
|
|
"charging_tasks": {"CREATE INDEX `idx_charging_tasks_owner_enabled` ON `charging_tasks` (`owner`, `enabled`)"},
|
|
"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`)",
|
|
},
|
|
}
|