// 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"` // 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 // 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 } // 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) }