Add fuel, maintenance, document and reminder tracking

Four features layered onto cars, each following the existing parts/services
pattern: a Go handler gated on requireCarAccess, snake_case PocketBase
mappers, a Vue form modal, and a tab on CarDetail (now driven by an array
rather than repeated markup).

Fuel: refills logged with odometer, litres and cost. Consumption is derived
on read from the whole history rather than stored, so correcting an old fill
re-derives every window it touches with no rows to migrate. Efficiency uses
the full-tank method — two consecutive full tanks are the same known level,
so the fuel burned between them is exactly what was poured in. Partial fills
roll into the window that closes them; a missed-fill flag leaves that window
uncomputed rather than reporting an implausibly good figure. Averages in the
stats rollup are distance-weighted, so a long motorway run counts for more
than a trip across town — which is what actually happened to the fuel.

Maintenance: workshop visits and repairs, deliberately separate from
service_records. That collection is the routine interval schedule and drives
next-service-due; this one is unplanned garage work with a workshop, an
invoice and a labour bill, and no bearing on the interval.

Documents: insurance, pollution certificates and registration papers. The
renewal date is the point of the record, so expiry is assessed live on every
read instead of stored and left to go stale. Scans are proxied through the
API — PocketBase's collections have no public read rule, so an attachment is
never a public URL and car access is re-checked per fetch.

Reminders: fire on a date, an odometer reading, or both (whichever comes
first). Stored reminders sit alongside read-only ones derived from document
expiry and next-service-due, so a renewal date is never typed twice and can
never drift from the document it came from. Derived ids are namespaced
"auto:" and every write endpoint rejects them.

A refill or a completed visit also writes the car's odometer forward, since
it is the freshest reading there is — never backwards, so backfilling old
history can't rewind the car.

Adds fuel_entries, maintenance_entries, car_documents and reminders to the
idempotent schema script, plus a file-field builder for attachments.

Verified end-to-end against a live PocketBase with a throwaway account: 39
checks covering the efficiency maths, expiry states, the derived reminders,
the upload/download round-trip, and that a stranger can reach none of it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-17 09:50:11 +02:00
co-authored by Claude Opus 4.8
parent ae6ed4ac1e
commit e64c89a564
15 changed files with 3358 additions and 39 deletions
+374
View File
@@ -84,6 +84,168 @@ type Part struct {
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"`
// 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"`
}
// 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"`
// 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"`
// FileName is the stored attachment (scan/PDF), served via
// GET /api/car-documents/{id}/file. Empty when nothing is attached.
FileName string `json:"fileName,omitempty"`
HasFile bool `json:"hasFile"`
// 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 {
@@ -138,3 +300,215 @@ func (r *ServiceRecord) ComputeDerived(c *Car) {
r.NextServiceKm = &n
}
}
// 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
}
// 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)
}