Files
DriverVault/API Server/internal/models/models.go
T
tajniak81andClaude Opus 5 6191160f14 Cars: log what charging costs, and drag the provider readings
Three things, all on a car's page.

A Charging cost tab, which is the Fuel cost tab written for an electric
car: charges in kWh, consumption in kWh/100km beside km per kWh, cost per
km and price per kWh, and the same summary panel over the whole history.
It keeps the reference-point method too, and has to — a session records
the energy that went in, not what was left in the battery, so a given
number of kWh only maps to a distance between two charges that ended at
the same state. A charge to the car's usual full point plays the part of
the full tank; partial charges still count towards the cost and roll into
the next full one; and a charge taken without logging it leaves its
window uncomputed rather than reporting an implausibly good figure.
Sessions live in their own collection, the figures are derived on read
like the fuel ones, and logging a charge advances the odometer exactly as
a refill does. The tab switches off from the gear like every other, so a
petrol car need never see it.

The headline readings on the connected-service tab now drag into any
order, saved on drop. Stored on the car as metricOrder, like the
Information rows, rather than per device the way the collapsed cards are:
an arrangement is something everyone the car is shared with should see,
where a folded card is one browser's reading habit. Only what the
provider reported can be arranged, so a reading that turns up later joins
the end rather than displacing the arrangement.

Fuel is now Fuel cost, tab and heading, which is what the tab has always
been about and what pairs it with Charging cost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:17:52 +02:00

854 lines
32 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"`
// 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"`
// 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
// 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).
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")
}
}
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)
}