A car's page has let you choose and arrange two things for a while - which tabs
it shows, and which rows the Information tab lists, both dragged into whatever
order you like. The Service history table was left out of that: nine columns,
hardcoded, in one order, for every car. An EV shows Oil & Oil filter and Engine
air filter on every row of a history that will never record either, and a reader
who mostly wants Notes has to look past four columns of dates and distances to
reach it.
It works the way the other two do, because a third mechanism for the same idea
would be one to keep in step. Both lists are properties of the car, so everyone
it is shared with sees the same table, and both need write access to set. The
columns are stored as the hidden set rather than the visible one, so a column
added in a later release is on by default. The arrangement covers the hidden
columns too, which is what makes a column switched back on return to where it
was instead of reappearing at the end - verified below, since that is the part
of this shape that is easy to get wrong and invisible until somebody hits it.
Date cannot be switched off. Every row of that table is work done on a day, and
a history with the day taken out stops being a history; it can still be dragged
anywhere, which is exactly the rule Information already follows in the tab bar.
That is a judgment call and the annotation that prompted this only circled the
other eight columns - moving "date" into hideableServiceColumns and dropping the
filter in the picker would reverse it in two lines if it turns out to be wrong.
Server: hidden_service_columns and service_column_order on the car, validated
against their own key sets by the endpoint that already does this for tabs,
fields and readings. The arrangeable set is derived from the hideable one plus
the date rather than written out again, so the two cannot drift as columns are
added. Bootstrap appends missing fields to existing collections, so the two
columns appear on the next server start with no migration to run.
Web: the table stopped being nine hardcoded th/td pairs and is now driven by one
list of columns, head and body from the same source, which is what stops a moved
or hidden column from shifting the headings out of line with the cells. The
cells are built a row at a time rather than a call per cell, so a long history
doesn't rebuild every cell three times to read its text, its classes and whether
it is the file column. The column headings kept their existing car.services.col*
translations - the keys are mapped rather than derived, because renaming a dozen
strings in three languages to save a lookup table would be the wrong trade. Four
new strings in all three languages.
Verified: go vet and go test ./... pass, with new tests covering both key sets -
that hiding the date is refused, that a field key is not a column key, and that
the arrangeable set is the hideable one plus the date. npm run build is clean.
The page itself was driven in a browser against a throwaway stub API: the
rewritten table renders identically to the hardcoded one, switching two columns
off removed exactly those two from head and body with the rest still aligned and
sent {"hiddenServiceColumns":["oil","engineFilter"]}, dragging Notes onto Km
reordered head and body live and saved an order with the hidden columns still
holding their places, switching Oil back on returned it between Next km and
Cabin air filter rather than to the end, and a read-only share gets no gear
button, no draggable headings and no drag hint.
Not verified: the drag was exercised by dispatching drag events at the
component's own handlers, not by a pointer - the browser pane was not
compositing, which rules out both screenshots and a real drag - so the native
drag image and cursor are unchecked. No automated test guards any of the web
behaviour; the web app still has no test runner. The API rejects unknown JSON
fields, so this web build against an older API Server would take a 400 when
saving the picker: they deploy together from this repo, but one must not ship
without the other. The phone app is deliberately untouched, having no column
table to arrange, and ignores both new fields.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
888 lines
33 KiB
Go
888 lines
33 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 YYYY-MM-DD (date-only)
|
|
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 (["oil", "engineFilter"] on an EV, whose service is neither).
|
|
// 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"`
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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
|
|
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"`
|
|
|
|
// 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)
|
|
}
|