Files
DriverVault/API Server/internal/models/models.go
T
tajniak81andClaude Opus 4.8 21c99ad762 Add technical check history
A car's mandatory roadworthiness inspections — przegląd techniczny, MOT,
TÜV — had nowhere to live. Log them behind Service history, which this
mirrors minus the odometer: an inspection falls due on a date whatever
the mileage reads.

The cycle is legal rather than mechanical, and it is not constant — a new
car's first check falls due years later than its third. So the car's
technical_check_interval_days only prefills the next date, and any check
overrides it with the valid-until printed on its own certificate. A
failed check derives no next date at all: reading one off a failure would
stamp a reassuring "valid until" on a car that just flunked.

The next-due date and its status are derived on every read rather than
stored, for the same reason documents are — a stored verdict goes stale
as the date passes. The response carries the same expiry shape documents
use, so the web app reuses the existing badge rather than growing a
second one.

Each check records result, cost, station and notes, and holds the
certificate as its single attachment on the same terms as every other
record.

Needs `node scripts/setup-pocketbase.mjs` to create technical_checks and
add the interval to cars. The reformatting in records.go is gofmt
realigning the car struct around a longer field name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 11:54:26 +02:00

623 lines
22 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 | diesel | hybrid | electric
BuildDate string `json:"buildDate"` // ISO YYYY-MM-DD (date-only)
FirstRegistrationDate string `json:"firstRegistrationDate"` // ISO YYYY-MM-DD (date-only)
// 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"`
}
// 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
// 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
}
// 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)
}