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>
This commit is contained in:
tajniak81
2026-07-17 11:54:26 +02:00
co-authored by Claude Opus 4.8
parent 03738f08dc
commit 21c99ad762
11 changed files with 713 additions and 81 deletions
+90 -6
View File
@@ -24,11 +24,11 @@ type Attachment struct {
// 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
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
@@ -38,6 +38,12 @@ type Car struct {
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"`
@@ -87,6 +93,40 @@ type ServiceRecord struct {
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"`
@@ -262,7 +302,7 @@ type Reminder struct {
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
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"`
@@ -325,6 +365,50 @@ func (r *ServiceRecord) ComputeDerived(c *Car) {
}
}
// 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