The charger's own cloud schedule is one window inside one box: charge between these hours, every day, and that is the whole vocabulary. A third tab on Charging holds a list instead — each line an action, a time, the days it repeats on and the chargers it acts on — and one list covers the whole account rather than each charger hiding its own. The clock is the server's. A schedule that only fires while a tab is open is a reminder, so a ticker sweeps every enabled task and fires whichever minute has come. It sends by handing a synthesised request to the same control endpoint the page's buttons use, so a scheduled command goes through the same cascade, ownership gate, rate limit and audit trail — what the owner cannot press by hand, the scheduler cannot send for them. A task names its chargers, or names none, which means all of them and keeps meaning that for a charger imported next year. Times are stored as a wall clock plus the zone they were written in, so 23:00 stays 23:00 wherever the server sits. One action per task: a charging window is the two tasks that open and close it, which is how it is read back, edited and switched off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
973 lines
38 KiB
Go
973 lines
38 KiB
Go
// Package models defines the domain types for the car maintenance tracker.
|
|
//
|
|
// The shapes mirror the original "Car Service.xlsx": one Car per sheet, a log
|
|
// of ServiceRecords (date + km, plus which parts were changed), and a per-car
|
|
// catalog of Parts (cols M/N). Derived fields follow the spreadsheet formulas:
|
|
//
|
|
// Next Service Date = Service Date + ServiceIntervalDays (Excel: A + 365)
|
|
// Next Service Km = Service Km + ServiceIntervalKm (Excel: B + 15000)
|
|
package models
|
|
|
|
import "time"
|
|
|
|
// Attachment is the single optional file a record carries — a scan, a receipt, a
|
|
// workshop invoice, a photo of a part's box. Embedded by every type that can
|
|
// hold one.
|
|
//
|
|
// FileName is the name PocketBase stored it under. The bytes are not in here:
|
|
// they are served from GET /api/{records}/{id}/file, which re-checks access on
|
|
// every request, so an attachment is never a public URL.
|
|
type Attachment struct {
|
|
FileName string `json:"fileName,omitempty"`
|
|
HasFile bool `json:"hasFile"`
|
|
}
|
|
|
|
// Car corresponds to one worksheet in the original spreadsheet.
|
|
type Car struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"` // e.g. "Toyota Yaris"
|
|
Make string `json:"make"` // e.g. "Toyota"
|
|
Model string `json:"model"` // e.g. "Yaris"
|
|
Year int `json:"year"` // optional
|
|
Registration string `json:"registration"` // optional plate
|
|
RegistrationCountry string `json:"registrationCountry"` // optional (country of registration)
|
|
VIN string `json:"vin"` // optional
|
|
|
|
// Maintenance intervals, configurable per car. The spreadsheet hard-coded
|
|
// 365 days and 15000 km; here they are stored so each car can differ.
|
|
ServiceIntervalDays int `json:"serviceIntervalDays"`
|
|
ServiceIntervalKm int `json:"serviceIntervalKm"`
|
|
|
|
// TechnicalCheckIntervalDays is the roadworthiness inspection cycle. It only
|
|
// prefills the next date — the interval is set by law, not by the car, and
|
|
// changes as the car ages, so any check can override it with the date its
|
|
// certificate actually carries.
|
|
TechnicalCheckIntervalDays int `json:"technicalCheckIntervalDays"`
|
|
|
|
// CurrentKm is the car's present odometer reading, updated by the user. Used
|
|
// to flag km-based overdue service (current_km >= last service km + interval).
|
|
CurrentKm int `json:"currentKm"`
|
|
|
|
OilSpec string `json:"oilSpec"` // e.g. "Toyota Advanced Fuel Economy 0W20"
|
|
TransmissionOilSpec string `json:"transmissionOilSpec"` // e.g. "Toyota WS"
|
|
DifferentialOilSpec string `json:"differentialOilSpec"` // e.g. "SAE 75W-90 GL-5"
|
|
BrakeFluidSpec string `json:"brakeFluidSpec"` // e.g. "DOT 4"
|
|
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 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
|
|
// of the plugin ("toyota"), plus that plugin's own id for the vehicle
|
|
// (ProviderVehicleID; the VIN, for Toyota). Both are blank for a hand-entered
|
|
// car. They are set by the import/link endpoints only, never by an ordinary
|
|
// car edit, so saving the form cannot silently break the link.
|
|
Provider string `json:"provider,omitempty"`
|
|
ProviderVehicleID string `json:"providerVehicleId,omitempty"`
|
|
|
|
// HiddenTabs and HiddenFields are what this car's page does not show: tabs
|
|
// (["fuel"] on an EV) and Information fields (["differentialOil"] on a car
|
|
// without one). Properties of the car, so everyone it is shared with sees the
|
|
// same page. The hidden sets, not the visible ones, so anything added in a
|
|
// later release is on by default. Set through the view endpoint only, never by
|
|
// an ordinary car edit, so saving the form cannot silently reveal them again.
|
|
HiddenTabs []string `json:"hiddenTabs"`
|
|
HiddenFields []string `json:"hiddenFields"`
|
|
|
|
// FieldOrder is the arrangement of the Information rows, as the field keys in
|
|
// the order they are laid out. Also a property of the car, and it covers the
|
|
// hidden rows too, so a row switched back on returns to where it was. Empty
|
|
// on a car nobody has rearranged, which means the page's own default order;
|
|
// a key it doesn't mention — a row added in a later release — follows the
|
|
// arranged ones rather than appearing in the middle.
|
|
FieldOrder []string `json:"fieldOrder"`
|
|
|
|
// HiddenServiceColumns is what the Service history table does not show, as
|
|
// column keys (["parts", "file"] for somebody who keeps only dates and
|
|
// distances). The hidden set like the two above, so a column added later is
|
|
// on by default, and Date is not among the keys it may name: a service
|
|
// record is its date, and a table of them without it reads as a list of
|
|
// nothing.
|
|
HiddenServiceColumns []string `json:"hiddenServiceColumns"`
|
|
|
|
// HiddenServiceParts is the parts this car's services never change, as part
|
|
// keys (["oil"] on an EV, which has none to change). They come off the
|
|
// Changed parts section of the service form and out of the history's parts
|
|
// column together: a part nobody records is one nobody wants offered either.
|
|
// The hidden set like the ones above, so a part added later is on by
|
|
// default, and there is no order beside it — the parts are a checkbox list
|
|
// inside one column, and their position says nothing a tab's or a column's
|
|
// does.
|
|
HiddenServiceParts []string `json:"hiddenServiceParts"`
|
|
|
|
// ServiceColumnOrder is the arrangement of those columns, covering the
|
|
// hidden ones so a column switched back on returns to where it was. It does
|
|
// include "date", which cannot be switched off but can be moved off the
|
|
// front — the same rule TabOrder applies to Information.
|
|
ServiceColumnOrder []string `json:"serviceColumnOrder"`
|
|
|
|
// MetricOrder is the same thing for the headline readings on the connected
|
|
// service's tab, as the reading keys ("odometer", "evRange", …). A reading
|
|
// the provider didn't report at the time it was arranged simply isn't in the
|
|
// list, and joins the end when it does turn up.
|
|
MetricOrder []string `json:"metricOrder"`
|
|
|
|
// TabOrder is the order of the tabs themselves, as tab keys. It covers the
|
|
// hidden tabs too, like FieldOrder, so a tab switched back on returns to
|
|
// where it was, and it includes "info" — Information cannot be switched off
|
|
// but it can be moved off the front.
|
|
TabOrder []string `json:"tabOrder"`
|
|
|
|
// Owner is the user id that owns this car. Access is the requesting user's
|
|
// permission on it — "owner", "write", or "read" — computed by the API at
|
|
// read time and never persisted (omitempty; not part of the write payload).
|
|
Owner string `json:"owner,omitempty"`
|
|
Access string `json:"access,omitempty"`
|
|
|
|
Created string `json:"created,omitempty"`
|
|
Updated string `json:"updated,omitempty"`
|
|
}
|
|
|
|
// HomeCharger is a charger the user owns — their own wallbox, not a station on
|
|
// the public network. It belongs to the person rather than to a car: it charges
|
|
// whichever car is plugged into it, and it outlives any of them.
|
|
type HomeCharger struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Serial string `json:"serial,omitempty"`
|
|
Vendor string `json:"vendor,omitempty"` // "Anker Solix", "Greencell"
|
|
Model string `json:"model,omitempty"` // "A5191"
|
|
SiteName string `json:"siteName,omitempty"` // the system it belongs to, where it has one
|
|
PowerKw float64 `json:"powerKw,omitempty"`
|
|
Connector string `json:"connector,omitempty"`
|
|
|
|
// Provider links this charger to the service it was imported from — the
|
|
// charger-provider name ("anker-solix") plus that provider's own id for it
|
|
// (the serial). Both blank for one added by hand. Set by the import endpoint
|
|
// only, never by an ordinary edit, so a rename cannot break the link.
|
|
Provider string `json:"provider,omitempty"`
|
|
ProviderChargerID string `json:"providerChargerId,omitempty"`
|
|
|
|
Created string `json:"created,omitempty"`
|
|
}
|
|
|
|
// ChargingTask is one line of the home-charger scheduler: an action, a time of
|
|
// day, the days it repeats on, and the chargers it acts on. It belongs to the
|
|
// person, like the chargers themselves — one list covering every charger they
|
|
// own, rather than a separate schedule inside each one.
|
|
//
|
|
// The charger's own cloud schedule can only say "charge between these hours,
|
|
// every day, on this one box". This says "at 23:00 on weeknights, cap these two
|
|
// chargers at 10 A" — several tasks, each naming its own chargers.
|
|
type ChargingTask struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
|
|
// The home-charger records this task acts on. Empty means every charger the
|
|
// owner has, including ones imported after the task was written — "all of
|
|
// them" is a standing wish, not the list that happened to exist that day.
|
|
Chargers []string `json:"chargers"`
|
|
|
|
// What to do: start, stop, limit (to Amps) or boost. One action per task; a
|
|
// charging window is the two tasks that open and close it, which is also how
|
|
// it is edited and how it is switched off.
|
|
Action string `json:"action"`
|
|
Amps float64 `json:"amps,omitempty"`
|
|
|
|
// When, as a 24-hour "HH:MM" read in Zone — the IANA zone the browser was in
|
|
// when the task was written. The server's own clock is not the one the user
|
|
// set the time by, and a laptop that travels must not move the schedule.
|
|
Time string `json:"time"`
|
|
Zone string `json:"zone,omitempty"`
|
|
|
|
// The weekdays it repeats on, 0=Sunday … 6=Saturday. Empty means every day.
|
|
Days []int `json:"days"`
|
|
|
|
Enabled bool `json:"enabled"`
|
|
|
|
// What happened the last time it fired, so a task that has been failing
|
|
// quietly for a week says so in the list rather than in a log nobody reads.
|
|
LastRun string `json:"lastRun,omitempty"` // RFC3339, UTC
|
|
LastResult string `json:"lastResult,omitempty"`
|
|
|
|
Created string `json:"created,omitempty"`
|
|
}
|
|
|
|
// ServiceRecord is one row of the Service log for a car.
|
|
type ServiceRecord struct {
|
|
ID string `json:"id"`
|
|
Car string `json:"car"` // relation -> Car.ID
|
|
Date time.Time `json:"date"` // service date (Excel col A)
|
|
Km int `json:"km"` // odometer at service (Excel col B)
|
|
|
|
// "Changed Parts" checkboxes (Excel cols E/F/G).
|
|
ChangedOil bool `json:"changedOil"` // Oil & Oil Filter
|
|
ChangedEngineAirFilter bool `json:"changedEngineAirFilter"` // Engine Air Filter
|
|
ChangedCabinAirFilter bool `json:"changedCabinAirFilter"` // Cabin Air Filter
|
|
|
|
Notes string `json:"notes,omitempty"`
|
|
|
|
// The workshop receipt or stamped service-book page for this visit.
|
|
Attachment
|
|
|
|
// Derived (not stored): filled in by the API on read.
|
|
NextServiceDate *time.Time `json:"nextServiceDate,omitempty"` // Excel col C
|
|
NextServiceKm *int `json:"nextServiceKm,omitempty"` // Excel col D
|
|
|
|
Created string `json:"created,omitempty"`
|
|
Updated string `json:"updated,omitempty"`
|
|
}
|
|
|
|
// TechnicalCheck is one mandatory roadworthiness inspection in a car's history —
|
|
// przegląd techniczny, MOT, TÜV, contrôle technique, depending on where the car
|
|
// is registered.
|
|
//
|
|
// It is shaped like a ServiceRecord, but recurs on time alone: an inspection
|
|
// falls due on a date whatever the odometer says. The cycle is a legal one
|
|
// rather than a property of the car, and it is not constant — a new car's first
|
|
// check falls due years later than its third — so the car's interval is only a
|
|
// default, and ValidUntil overrides it per record.
|
|
type TechnicalCheck struct {
|
|
ID string `json:"id"`
|
|
Car string `json:"car"` // relation -> Car.ID
|
|
Date time.Time `json:"date"` // date of the inspection
|
|
|
|
Result string `json:"result"` // passed | failed
|
|
Cost float64 `json:"cost"`
|
|
Station string `json:"station,omitempty"` // inspection station / inspector
|
|
Notes string `json:"notes,omitempty"`
|
|
|
|
// ValidUntil is the expiry printed on the certificate. When set it wins over
|
|
// the car's interval, because it is the date that actually governs.
|
|
ValidUntil *time.Time `json:"validUntil,omitempty"`
|
|
|
|
// The certificate itself.
|
|
Attachment
|
|
|
|
// Derived (not stored): filled in by the API on read.
|
|
NextCheckDate *time.Time `json:"nextCheckDate,omitempty"`
|
|
Expiry ExpiryAssessment `json:"expiry"`
|
|
|
|
Created string `json:"created,omitempty"`
|
|
Updated string `json:"updated,omitempty"`
|
|
}
|
|
|
|
// Part is one entry in a car's parts catalog (Excel cols M/N).
|
|
type Part struct {
|
|
ID string `json:"id"`
|
|
Car string `json:"car"` // relation -> Car.ID
|
|
Name string `json:"name"` // e.g. "Oil Filter"
|
|
PartNumber string `json:"partNumber"` // e.g. "04152-YZZA7"
|
|
Category string `json:"category"` // optional: oil|filter|wiper|other
|
|
Notes string `json:"notes,omitempty"`
|
|
|
|
// A photo of the box, or the spec sheet for the part.
|
|
Attachment
|
|
|
|
Created string `json:"created,omitempty"`
|
|
Updated string `json:"updated,omitempty"`
|
|
}
|
|
|
|
// FuelEntry is one refuelling stop for a car.
|
|
//
|
|
// Efficiency is derived by the full-tank method rather than stored: a tank
|
|
// filled to the brim is a known reference point, so the fuel burned between two
|
|
// consecutive full tanks is exactly what was poured in over that span. Partial
|
|
// fills in between are not measurable on their own — they roll into the next
|
|
// full tank's window. See ComputeFuelDerived.
|
|
type FuelEntry struct {
|
|
ID string `json:"id"`
|
|
Car string `json:"car"` // relation -> Car.ID
|
|
Date time.Time `json:"date"` // date of the refill
|
|
Km int `json:"km"` // odometer at the pump
|
|
Liters float64 `json:"liters"`
|
|
Cost float64 `json:"cost"` // total paid for this fill
|
|
|
|
// FullTank marks a fill to the brim — the reference point efficiency windows
|
|
// are measured between.
|
|
FullTank bool `json:"fullTank"`
|
|
|
|
// MissedFill records that a refill happened before this one without being
|
|
// logged. The odometer span is then not accounted for by the litres on
|
|
// record, so any window containing it is left uncomputed rather than
|
|
// reported as an implausibly good figure.
|
|
MissedFill bool `json:"missedFill"`
|
|
|
|
Station string `json:"station,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
|
|
// The pump receipt.
|
|
Attachment
|
|
|
|
// Derived (not stored): filled in by the API on read.
|
|
PricePerLiter *float64 `json:"pricePerLiter,omitempty"`
|
|
DistanceKm *int `json:"distanceKm,omitempty"` // since the previous full tank
|
|
LitersUsed *float64 `json:"litersUsed,omitempty"` // litres burned over that distance
|
|
ConsumptionL100 *float64 `json:"consumptionL100,omitempty"` // litres per 100 km
|
|
KmPerLiter *float64 `json:"kmPerLiter,omitempty"`
|
|
CostPerKm *float64 `json:"costPerKm,omitempty"`
|
|
|
|
Created string `json:"created,omitempty"`
|
|
Updated string `json:"updated,omitempty"`
|
|
}
|
|
|
|
// FuelStats summarises a car's whole refill history.
|
|
type FuelStats struct {
|
|
Entries int `json:"entries"`
|
|
TotalLiters float64 `json:"totalLiters"`
|
|
TotalCost float64 `json:"totalCost"`
|
|
|
|
// TrackedDistanceKm is the distance covered by computable full-tank windows,
|
|
// which is less than the odometer span whenever the history starts or ends
|
|
// on a partial fill. The averages below describe exactly this distance.
|
|
TrackedDistanceKm int `json:"trackedDistanceKm"`
|
|
|
|
AvgConsumptionL100 *float64 `json:"avgConsumptionL100,omitempty"`
|
|
BestConsumptionL100 *float64 `json:"bestConsumptionL100,omitempty"`
|
|
WorstConsumptionL100 *float64 `json:"worstConsumptionL100,omitempty"`
|
|
AvgKmPerLiter *float64 `json:"avgKmPerLiter,omitempty"`
|
|
AvgPricePerLiter *float64 `json:"avgPricePerLiter,omitempty"`
|
|
CostPerKm *float64 `json:"costPerKm,omitempty"`
|
|
|
|
FirstDate *time.Time `json:"firstDate,omitempty"`
|
|
LastDate *time.Time `json:"lastDate,omitempty"`
|
|
}
|
|
|
|
// ChargingSession is one charge of an electric car — the EV counterpart of
|
|
// FuelEntry, and it works the same way: energy and money are recorded, and the
|
|
// efficiency is derived by the same reference-point method (see
|
|
// ComputeChargingDerived).
|
|
//
|
|
// A charge to the car's usual full point plays the role of the full tank. It has
|
|
// to, for the same reason: a session only says how much energy went in, not how
|
|
// much was left in the battery, so the distance a given number of kWh covered is
|
|
// only knowable between two charges that ended at the same state.
|
|
type ChargingSession struct {
|
|
ID string `json:"id"`
|
|
Car string `json:"car"` // relation -> Car.ID
|
|
Date time.Time `json:"date"` // date of the charge
|
|
Km int `json:"km"` // odometer when plugging in
|
|
Kwh float64 `json:"kwh"` // energy delivered
|
|
Cost float64 `json:"cost"` // total paid for this charge
|
|
|
|
// FullCharge marks a charge taken to the car's usual full point — the
|
|
// reference the efficiency windows are measured between.
|
|
FullCharge bool `json:"fullCharge"`
|
|
|
|
// MissedSession records that the car was charged before this without being
|
|
// logged — a top-up at a friend's socket, say. The odometer span is then not
|
|
// accounted for by the kWh on record, so any window containing it is left
|
|
// uncomputed rather than reported as implausibly efficient.
|
|
MissedSession bool `json:"missedSession"`
|
|
|
|
// Location is where it was charged ("Home", "Ionity Køge"). Free text: an
|
|
// operator list would go stale and this is only ever read by a person.
|
|
Location string `json:"location,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
|
|
// The charge point's receipt.
|
|
Attachment
|
|
|
|
// Derived (not stored): filled in by the API on read.
|
|
PricePerKwh *float64 `json:"pricePerKwh,omitempty"`
|
|
DistanceKm *int `json:"distanceKm,omitempty"` // since the previous full charge
|
|
KwhUsed *float64 `json:"kwhUsed,omitempty"` // energy used over that distance
|
|
ConsumptionKwh100 *float64 `json:"consumptionKwh100,omitempty"` // kWh per 100 km
|
|
KmPerKwh *float64 `json:"kmPerKwh,omitempty"`
|
|
CostPerKm *float64 `json:"costPerKm,omitempty"`
|
|
|
|
Created string `json:"created,omitempty"`
|
|
Updated string `json:"updated,omitempty"`
|
|
}
|
|
|
|
// ChargingStats summarises a car's whole charging history.
|
|
type ChargingStats struct {
|
|
Entries int `json:"entries"`
|
|
TotalKwh float64 `json:"totalKwh"`
|
|
TotalCost float64 `json:"totalCost"`
|
|
|
|
// TrackedDistanceKm is the distance covered by computable windows, which is
|
|
// less than the odometer span whenever the history starts or ends on a
|
|
// partial charge. The averages below describe exactly this distance.
|
|
TrackedDistanceKm int `json:"trackedDistanceKm"`
|
|
|
|
AvgConsumptionKwh100 *float64 `json:"avgConsumptionKwh100,omitempty"`
|
|
BestConsumptionKwh100 *float64 `json:"bestConsumptionKwh100,omitempty"`
|
|
WorstConsumptionKwh100 *float64 `json:"worstConsumptionKwh100,omitempty"`
|
|
AvgKmPerKwh *float64 `json:"avgKmPerKwh,omitempty"`
|
|
AvgPricePerKwh *float64 `json:"avgPricePerKwh,omitempty"`
|
|
CostPerKm *float64 `json:"costPerKm,omitempty"`
|
|
|
|
FirstDate *time.Time `json:"firstDate,omitempty"`
|
|
LastDate *time.Time `json:"lastDate,omitempty"`
|
|
}
|
|
|
|
// MaintenanceEntry is one workshop visit or repair — work done on the car
|
|
// outside the routine service schedule (which lives in ServiceRecord). A broken
|
|
// alternator replaced at a garage belongs here; the annual oil change does not.
|
|
type MaintenanceEntry struct {
|
|
ID string `json:"id"`
|
|
Car string `json:"car"` // relation -> Car.ID
|
|
Date time.Time `json:"date"` // date of the visit
|
|
Km int `json:"km"` // odometer at the visit
|
|
|
|
Type string `json:"type"` // repair|inspection|bodywork|tyres|diagnostics|recall|warranty|other
|
|
Status string `json:"status"` // scheduled|in_progress|completed
|
|
Workshop string `json:"workshop"` // garage/workshop name
|
|
Location string `json:"location"` // optional: city or address
|
|
Description string `json:"description"` // what was done
|
|
PartsUsed string `json:"partsUsed"` // free-text list of parts replaced
|
|
|
|
LaborCost float64 `json:"laborCost"`
|
|
PartsCost float64 `json:"partsCost"`
|
|
|
|
InvoiceNumber string `json:"invoiceNumber,omitempty"`
|
|
WarrantyUntil *time.Time `json:"warrantyUntil,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
|
|
// The workshop's invoice.
|
|
Attachment
|
|
|
|
// Derived (not stored): filled in by the API on read.
|
|
TotalCost float64 `json:"totalCost"`
|
|
WarrantyActive *bool `json:"warrantyActive,omitempty"`
|
|
WarrantyDaysLeft *int `json:"warrantyDaysLeft,omitempty"`
|
|
|
|
Created string `json:"created,omitempty"`
|
|
Updated string `json:"updated,omitempty"`
|
|
}
|
|
|
|
// CarDocument is a piece of paperwork tied to a car — insurance policies,
|
|
// pollution/emissions certificates, registration papers, and so on. The renewal
|
|
// date is the point of the whole record: an expired policy is a car that cannot
|
|
// legally be driven, so Expiry is computed live on every read.
|
|
type CarDocument struct {
|
|
ID string `json:"id"`
|
|
Car string `json:"car"` // relation -> Car.ID
|
|
Type string `json:"type"` // insurance|pollution|registration|inspection|roadTax|warranty|other
|
|
Title string `json:"title"` // e.g. "Third-party liability 2026"
|
|
|
|
Provider string `json:"provider,omitempty"` // insurer / issuing authority
|
|
Reference string `json:"reference,omitempty"` // policy or certificate number
|
|
|
|
IssueDate *time.Time `json:"issueDate,omitempty"`
|
|
ExpiryDate *time.Time `json:"expiryDate,omitempty"` // blank = never expires
|
|
Cost float64 `json:"cost"`
|
|
Notes string `json:"notes,omitempty"`
|
|
|
|
// The scan or photo of the paperwork itself.
|
|
Attachment
|
|
|
|
// Derived (not stored): filled in by the API on read.
|
|
Expiry ExpiryAssessment `json:"expiry"`
|
|
|
|
Created string `json:"created,omitempty"`
|
|
Updated string `json:"updated,omitempty"`
|
|
}
|
|
|
|
// ExpiryAssessment is the server-computed lifecycle state of a dated document.
|
|
type ExpiryAssessment struct {
|
|
State string `json:"state"` // no_expiry | valid | expiring_soon | expired
|
|
Days *int `json:"daysUntilExpiry"` // nil when there is no expiry date
|
|
}
|
|
|
|
// Reminder is something the user wants to be told about: a booked workshop slot,
|
|
// an insurance renewal, a tyre swap. A reminder fires on a date, an odometer
|
|
// reading, or both — whichever comes first.
|
|
type Reminder struct {
|
|
ID string `json:"id"`
|
|
Car string `json:"car"` // relation -> Car.ID
|
|
Title string `json:"title"`
|
|
Type string `json:"type"` // maintenance|document|service|inspection|other
|
|
|
|
DueDate *time.Time `json:"dueDate,omitempty"`
|
|
DueKm int `json:"dueKm,omitempty"`
|
|
|
|
// RepeatDays/RepeatKm turn a reminder into a recurring one: completing it
|
|
// rolls the trigger forward by this much instead of closing it out.
|
|
RepeatDays int `json:"repeatDays,omitempty"`
|
|
RepeatKm int `json:"repeatKm,omitempty"`
|
|
|
|
Done bool `json:"done"`
|
|
DoneAt *time.Time `json:"doneAt,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
|
|
// Derived (not stored): filled in by the API on read.
|
|
Status string `json:"status"` // done | overdue | due_soon | upcoming | no_trigger
|
|
DaysLeft *int `json:"daysLeft,omitempty"` // nil when there is no due date
|
|
KmLeft *int `json:"kmLeft,omitempty"` // nil when there is no due km / no odometer
|
|
Auto bool `json:"auto"` // true = derived from a document/service, read-only
|
|
SourceRef string `json:"sourceRef,omitempty"` // id of the record an auto reminder came from
|
|
|
|
Created string `json:"created,omitempty"`
|
|
Updated string `json:"updated,omitempty"`
|
|
}
|
|
|
|
// User is the authenticated account's profile, covering the Settings panel's
|
|
// Account/Profile/Appearance sections.
|
|
type User struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Verified bool `json:"verified"`
|
|
Name string `json:"name"`
|
|
Bio string `json:"bio"`
|
|
|
|
HasAvatar bool `json:"hasAvatar"`
|
|
|
|
Theme string `json:"theme"` // light | dark | system
|
|
Locale string `json:"locale"` // e.g. "en-US"
|
|
DateFormat string `json:"dateFormat"` // YMD | DMY | MDY
|
|
TimeFormat string `json:"timeFormat"` // auto (the region's own) | 24 | 12
|
|
Currency string `json:"currency"` // ISO 4217 code, e.g. "EUR"
|
|
FontSize string `json:"fontSize"` // small | medium | large
|
|
Role string `json:"role"` // user | admin
|
|
|
|
// DragLocked holds every arrangement on this account's pages still: the
|
|
// garage, a car's tabs, its Information rows, the provider's readings. A
|
|
// guard against nudging a layout while reading it, not a permission — it is
|
|
// the user's own setting and says nothing about what they may edit. False
|
|
// (draggable) on an account that has never set it.
|
|
DragLocked bool `json:"dragLocked"`
|
|
|
|
// Organization membership. Empty when the user belongs to no organization —
|
|
// the clients use that to offer creating one (which makes them its admin).
|
|
Organization string `json:"organization"`
|
|
OrganizationName string `json:"organizationName,omitempty"`
|
|
|
|
// CarOrder is the garage arrangement: car ids in the order this user dragged
|
|
// them into. The car list is already returned in this order, so a client only
|
|
// needs it to send an updated arrangement back.
|
|
CarOrder []string `json:"carOrder"`
|
|
ChargerTabOrder []string `json:"chargerTabOrder"`
|
|
ChargerCardOrder []string `json:"chargerCardOrder"`
|
|
|
|
// DefaultTabs is which tab each tabbed page opens on, keyed by page:
|
|
// {"charging": "home", "car": "info", "settings": "personal"}. A page absent
|
|
// from the map opens on whichever tab leads its bar — the arrangement above —
|
|
// so this only carries the cases where landing tab and reading order differ.
|
|
DefaultTabs map[string]string `json:"defaultTabs"`
|
|
|
|
// Non-empty while an account-deletion request is pending its cooldown.
|
|
DeletionRequestedAt *time.Time `json:"deletionRequestedAt,omitempty"`
|
|
|
|
Created string `json:"created,omitempty"`
|
|
}
|
|
|
|
// Session is one active login (device) for the current user.
|
|
type Session struct {
|
|
ID string `json:"id"`
|
|
DeviceLabel string `json:"deviceLabel"`
|
|
IP string `json:"ip"`
|
|
Current bool `json:"current"`
|
|
Created time.Time `json:"created"`
|
|
ExpiresAt time.Time `json:"expiresAt"`
|
|
}
|
|
|
|
// ComputeDerived fills NextServiceDate / NextServiceKm from the car's intervals,
|
|
// reproducing the spreadsheet formulas. Intervals of 0 fall back to the
|
|
// spreadsheet defaults (365 days, 15000 km).
|
|
//
|
|
// Note the asymmetry in what counts as "no reading": a zero date is genuinely
|
|
// absent, but a zero odometer is a reading. A car collected new sits at 0 km
|
|
// and its first service is still due 15000 km later, so 0 has to produce a
|
|
// next-due figure like any other number would.
|
|
func (r *ServiceRecord) ComputeDerived(c *Car) {
|
|
days := c.ServiceIntervalDays
|
|
if days <= 0 {
|
|
days = 365
|
|
}
|
|
km := c.ServiceIntervalKm
|
|
if km <= 0 {
|
|
km = 15000
|
|
}
|
|
if !r.Date.IsZero() {
|
|
d := r.Date.AddDate(0, 0, days)
|
|
r.NextServiceDate = &d
|
|
}
|
|
if r.Km >= 0 {
|
|
n := r.Km + km
|
|
r.NextServiceKm = &n
|
|
}
|
|
}
|
|
|
|
// DefaultTechnicalCheckIntervalDays is the annual cycle most of Europe settles
|
|
// into once a car is a few years old. It is only a starting point: see
|
|
// Car.TechnicalCheckIntervalDays.
|
|
const DefaultTechnicalCheckIntervalDays = 365
|
|
|
|
// ComputeTechnicalCheckDerived fills NextCheckDate and the expiry assessment.
|
|
//
|
|
// The date on the certificate wins over the car's interval when present; the
|
|
// interval only covers records entered without one. A failed inspection
|
|
// certifies nothing, so it yields no next date at all — reading one off a
|
|
// failure would put a reassuring "valid until" on a car that just flunked.
|
|
func (t *TechnicalCheck) ComputeTechnicalCheckDerived(c *Car, now time.Time) {
|
|
t.NextCheckDate = nil
|
|
t.Expiry = ExpiryAssessment{State: "no_expiry"}
|
|
if t.Result == "failed" {
|
|
return
|
|
}
|
|
|
|
switch {
|
|
case t.ValidUntil != nil && !t.ValidUntil.IsZero():
|
|
d := *t.ValidUntil
|
|
t.NextCheckDate = &d
|
|
case !t.Date.IsZero():
|
|
days := DefaultTechnicalCheckIntervalDays
|
|
if c != nil && c.TechnicalCheckIntervalDays > 0 {
|
|
days = c.TechnicalCheckIntervalDays
|
|
}
|
|
d := t.Date.AddDate(0, 0, days)
|
|
t.NextCheckDate = &d
|
|
default:
|
|
return
|
|
}
|
|
|
|
days := daysBetween(now, *t.NextCheckDate)
|
|
state := "valid"
|
|
switch {
|
|
case days < 0:
|
|
state = "expired"
|
|
case days <= SoonDays:
|
|
state = "expiring_soon"
|
|
}
|
|
t.Expiry = ExpiryAssessment{State: state, Days: &days}
|
|
}
|
|
|
|
// SoonDays is the window within which an upcoming expiry or reminder is
|
|
// surfaced as "due soon" rather than merely upcoming.
|
|
const SoonDays = 30
|
|
|
|
// soonKm mirrors SoonDays for odometer-triggered reminders.
|
|
const soonKm = 1000
|
|
|
|
// ComputeFuelDerived fills the derived efficiency fields on a car's refill
|
|
// history. `entries` must be ordered oldest-first by odometer.
|
|
//
|
|
// The full-tank method: between two consecutive full tanks the car burned
|
|
// exactly the fuel added over that span, because both endpoints are the same
|
|
// known level. Everything poured in after the earlier full tank up to and
|
|
// including the later one counts, which is what folds partial fills into the
|
|
// window that closes them. A window is left uncomputed when a fill inside it is
|
|
// flagged MissedFill, when the odometer did not advance, or when no litres were
|
|
// recorded — reporting a figure there would be fiction.
|
|
func ComputeFuelDerived(entries []FuelEntry) {
|
|
for i := range entries {
|
|
if entries[i].Liters > 0 && entries[i].Cost > 0 {
|
|
p := entries[i].Cost / entries[i].Liters
|
|
entries[i].PricePerLiter = &p
|
|
}
|
|
}
|
|
|
|
lastFull := -1
|
|
for i := range entries {
|
|
if !entries[i].FullTank {
|
|
continue
|
|
}
|
|
if lastFull < 0 {
|
|
// First full tank: nothing before it to measure against.
|
|
lastFull = i
|
|
continue
|
|
}
|
|
|
|
dist := entries[i].Km - entries[lastFull].Km
|
|
liters, cost := 0.0, 0.0
|
|
usable := true
|
|
for j := lastFull + 1; j <= i; j++ {
|
|
if entries[j].MissedFill {
|
|
usable = false
|
|
}
|
|
liters += entries[j].Liters
|
|
cost += entries[j].Cost
|
|
}
|
|
|
|
if usable && dist > 0 && liters > 0 {
|
|
d, l := dist, liters
|
|
entries[i].DistanceKm = &d
|
|
entries[i].LitersUsed = &l
|
|
|
|
l100 := liters / float64(dist) * 100
|
|
entries[i].ConsumptionL100 = &l100
|
|
|
|
kmpl := float64(dist) / liters
|
|
entries[i].KmPerLiter = &kmpl
|
|
|
|
if cost > 0 {
|
|
cpk := cost / float64(dist)
|
|
entries[i].CostPerKm = &cpk
|
|
}
|
|
}
|
|
lastFull = i
|
|
}
|
|
}
|
|
|
|
// ComputeFuelStats summarises a refill history whose derived fields have already
|
|
// been filled in by ComputeFuelDerived. `entries` must be ordered oldest-first.
|
|
//
|
|
// Averages are distance-weighted — total litres over total distance across every
|
|
// computable window — rather than a mean of the per-window figures, so a long
|
|
// motorway run counts for more than a short trip across town, which is what
|
|
// actually happened to the fuel.
|
|
func ComputeFuelStats(entries []FuelEntry) FuelStats {
|
|
st := FuelStats{Entries: len(entries)}
|
|
if len(entries) == 0 {
|
|
return st
|
|
}
|
|
|
|
var windowLiters, windowCost float64
|
|
for i := range entries {
|
|
e := &entries[i]
|
|
st.TotalLiters += e.Liters
|
|
st.TotalCost += e.Cost
|
|
|
|
if e.ConsumptionL100 == nil {
|
|
continue
|
|
}
|
|
st.TrackedDistanceKm += *e.DistanceKm
|
|
windowLiters += *e.LitersUsed
|
|
if e.CostPerKm != nil {
|
|
windowCost += *e.CostPerKm * float64(*e.DistanceKm)
|
|
}
|
|
if st.BestConsumptionL100 == nil || *e.ConsumptionL100 < *st.BestConsumptionL100 {
|
|
v := *e.ConsumptionL100
|
|
st.BestConsumptionL100 = &v
|
|
}
|
|
if st.WorstConsumptionL100 == nil || *e.ConsumptionL100 > *st.WorstConsumptionL100 {
|
|
v := *e.ConsumptionL100
|
|
st.WorstConsumptionL100 = &v
|
|
}
|
|
}
|
|
|
|
if st.TrackedDistanceKm > 0 && windowLiters > 0 {
|
|
avg := windowLiters / float64(st.TrackedDistanceKm) * 100
|
|
st.AvgConsumptionL100 = &avg
|
|
kmpl := float64(st.TrackedDistanceKm) / windowLiters
|
|
st.AvgKmPerLiter = &kmpl
|
|
if windowCost > 0 {
|
|
cpk := windowCost / float64(st.TrackedDistanceKm)
|
|
st.CostPerKm = &cpk
|
|
}
|
|
}
|
|
if st.TotalLiters > 0 && st.TotalCost > 0 {
|
|
ppl := st.TotalCost / st.TotalLiters
|
|
st.AvgPricePerLiter = &ppl
|
|
}
|
|
|
|
first, last := entries[0].Date, entries[len(entries)-1].Date
|
|
if !first.IsZero() {
|
|
st.FirstDate = &first
|
|
}
|
|
if !last.IsZero() {
|
|
st.LastDate = &last
|
|
}
|
|
return st
|
|
}
|
|
|
|
// ComputeChargingDerived fills the derived efficiency fields on a car's charging
|
|
// history. `entries` must be ordered oldest-first by odometer.
|
|
//
|
|
// The same window method as ComputeFuelDerived, with charges to the usual full
|
|
// point as the endpoints: between two of them the car used exactly the energy
|
|
// put in over that span, since both ends are the same battery state. Partial
|
|
// charges in between fold into the window that closes them. A window is left
|
|
// uncomputed when a session inside it is flagged MissedSession, when the
|
|
// odometer did not advance, or when no energy was recorded.
|
|
func ComputeChargingDerived(entries []ChargingSession) {
|
|
for i := range entries {
|
|
if entries[i].Kwh > 0 && entries[i].Cost > 0 {
|
|
p := entries[i].Cost / entries[i].Kwh
|
|
entries[i].PricePerKwh = &p
|
|
}
|
|
}
|
|
|
|
lastFull := -1
|
|
for i := range entries {
|
|
if !entries[i].FullCharge {
|
|
continue
|
|
}
|
|
if lastFull < 0 {
|
|
// First full charge: nothing before it to measure against.
|
|
lastFull = i
|
|
continue
|
|
}
|
|
|
|
dist := entries[i].Km - entries[lastFull].Km
|
|
kwh, cost := 0.0, 0.0
|
|
usable := true
|
|
for j := lastFull + 1; j <= i; j++ {
|
|
if entries[j].MissedSession {
|
|
usable = false
|
|
}
|
|
kwh += entries[j].Kwh
|
|
cost += entries[j].Cost
|
|
}
|
|
|
|
if usable && dist > 0 && kwh > 0 {
|
|
d, k := dist, kwh
|
|
entries[i].DistanceKm = &d
|
|
entries[i].KwhUsed = &k
|
|
|
|
kwh100 := kwh / float64(dist) * 100
|
|
entries[i].ConsumptionKwh100 = &kwh100
|
|
|
|
kmpkwh := float64(dist) / kwh
|
|
entries[i].KmPerKwh = &kmpkwh
|
|
|
|
if cost > 0 {
|
|
cpk := cost / float64(dist)
|
|
entries[i].CostPerKm = &cpk
|
|
}
|
|
}
|
|
lastFull = i
|
|
}
|
|
}
|
|
|
|
// ComputeChargingStats summarises a charging history whose derived fields have
|
|
// already been filled in by ComputeChargingDerived. `entries` must be ordered
|
|
// oldest-first.
|
|
//
|
|
// Averages are distance-weighted, as with fuel: total energy over total distance
|
|
// across every computable window, so a long motorway run counts for more than a
|
|
// short trip across town — which is what actually happened to the battery.
|
|
func ComputeChargingStats(entries []ChargingSession) ChargingStats {
|
|
st := ChargingStats{Entries: len(entries)}
|
|
if len(entries) == 0 {
|
|
return st
|
|
}
|
|
|
|
var windowKwh, windowCost float64
|
|
for i := range entries {
|
|
e := &entries[i]
|
|
st.TotalKwh += e.Kwh
|
|
st.TotalCost += e.Cost
|
|
|
|
if e.ConsumptionKwh100 == nil {
|
|
continue
|
|
}
|
|
st.TrackedDistanceKm += *e.DistanceKm
|
|
windowKwh += *e.KwhUsed
|
|
if e.CostPerKm != nil {
|
|
windowCost += *e.CostPerKm * float64(*e.DistanceKm)
|
|
}
|
|
if st.BestConsumptionKwh100 == nil || *e.ConsumptionKwh100 < *st.BestConsumptionKwh100 {
|
|
v := *e.ConsumptionKwh100
|
|
st.BestConsumptionKwh100 = &v
|
|
}
|
|
if st.WorstConsumptionKwh100 == nil || *e.ConsumptionKwh100 > *st.WorstConsumptionKwh100 {
|
|
v := *e.ConsumptionKwh100
|
|
st.WorstConsumptionKwh100 = &v
|
|
}
|
|
}
|
|
|
|
if st.TrackedDistanceKm > 0 && windowKwh > 0 {
|
|
avg := windowKwh / float64(st.TrackedDistanceKm) * 100
|
|
st.AvgConsumptionKwh100 = &avg
|
|
kmpkwh := float64(st.TrackedDistanceKm) / windowKwh
|
|
st.AvgKmPerKwh = &kmpkwh
|
|
if windowCost > 0 {
|
|
cpk := windowCost / float64(st.TrackedDistanceKm)
|
|
st.CostPerKm = &cpk
|
|
}
|
|
}
|
|
if st.TotalKwh > 0 && st.TotalCost > 0 {
|
|
ppk := st.TotalCost / st.TotalKwh
|
|
st.AvgPricePerKwh = &ppk
|
|
}
|
|
|
|
first, last := entries[0].Date, entries[len(entries)-1].Date
|
|
if !first.IsZero() {
|
|
st.FirstDate = &first
|
|
}
|
|
if !last.IsZero() {
|
|
st.LastDate = &last
|
|
}
|
|
return st
|
|
}
|
|
|
|
// ComputeMaintenanceDerived fills the derived cost and warranty fields.
|
|
func (m *MaintenanceEntry) ComputeMaintenanceDerived(now time.Time) {
|
|
m.TotalCost = m.LaborCost + m.PartsCost
|
|
if m.WarrantyUntil == nil || m.WarrantyUntil.IsZero() {
|
|
return
|
|
}
|
|
days := daysBetween(now, *m.WarrantyUntil)
|
|
active := days >= 0
|
|
m.WarrantyActive = &active
|
|
m.WarrantyDaysLeft = &days
|
|
}
|
|
|
|
// ComputeExpiry classifies a document by its expiry date relative to `now`.
|
|
func (d *CarDocument) ComputeExpiry(now time.Time) {
|
|
if d.ExpiryDate == nil || d.ExpiryDate.IsZero() {
|
|
d.Expiry = ExpiryAssessment{State: "no_expiry"}
|
|
return
|
|
}
|
|
days := daysBetween(now, *d.ExpiryDate)
|
|
state := "valid"
|
|
switch {
|
|
case days < 0:
|
|
state = "expired"
|
|
case days <= SoonDays:
|
|
state = "expiring_soon"
|
|
}
|
|
d.Expiry = ExpiryAssessment{State: state, Days: &days}
|
|
}
|
|
|
|
// ComputeReminderDerived resolves a reminder's status against today's date and
|
|
// the car's current odometer. A reminder with both triggers fires on whichever
|
|
// arrives first, so the worse of the two signals wins.
|
|
func (r *Reminder) ComputeReminderDerived(now time.Time, currentKm int) {
|
|
if r.Done {
|
|
r.Status = "done"
|
|
return
|
|
}
|
|
|
|
rank := map[string]int{"no_trigger": 0, "upcoming": 1, "due_soon": 2, "overdue": 3}
|
|
status := "no_trigger"
|
|
worsen := func(s string) {
|
|
if rank[s] > rank[status] {
|
|
status = s
|
|
}
|
|
}
|
|
|
|
if r.DueDate != nil && !r.DueDate.IsZero() {
|
|
days := daysBetween(now, *r.DueDate)
|
|
r.DaysLeft = &days
|
|
switch {
|
|
case days < 0:
|
|
worsen("overdue")
|
|
case days <= SoonDays:
|
|
worsen("due_soon")
|
|
default:
|
|
worsen("upcoming")
|
|
}
|
|
}
|
|
|
|
// currentKm == 0 is a real odometer, not a missing one — see ComputeDerived.
|
|
// Without this a brand-new car's first service reminder shows a due date but
|
|
// never the distance left to run.
|
|
if r.DueKm > 0 && currentKm >= 0 {
|
|
left := r.DueKm - currentKm
|
|
r.KmLeft = &left
|
|
switch {
|
|
case left < 0:
|
|
worsen("overdue")
|
|
case left <= soonKm:
|
|
worsen("due_soon")
|
|
default:
|
|
worsen("upcoming")
|
|
}
|
|
}
|
|
|
|
r.Status = status
|
|
}
|
|
|
|
// daysBetween returns whole days from `now` to `target`, both truncated to the
|
|
// day, so a deadline later today reads as 0 rather than a fraction.
|
|
func daysBetween(now, target time.Time) int {
|
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
|
t := time.Date(target.Year(), target.Month(), target.Day(), 0, 0, 0, 0, time.UTC)
|
|
return int(t.Sub(today).Hours() / 24)
|
|
}
|