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
+2 -1
View File
@@ -85,8 +85,9 @@ other users `read` or `write` access. Every car/service/part handler is gated by
| Collection | Purpose | Key fields |
|---|---|---|
| `cars` | one per car | name, make, model, year, registration, vin, `currentKm`, `serviceIntervalDays` (365), `serviceIntervalKm` (15000), `oilSpec`, `transmissionOilSpec`, `differentialOilSpec`, `brakeFluidSpec`, `coolantSpec`, `owner` |
| `cars` | one per car | name, make, model, year, registration, vin, `currentKm`, `serviceIntervalDays` (365), `serviceIntervalKm` (15000), `technicalCheckIntervalDays` (365), `oilSpec`, `transmissionOilSpec`, `differentialOilSpec`, `brakeFluidSpec`, `coolantSpec`, `owner` |
| `service_records` | the service log | car, date, km, changed_oil, changed_engine_air_filter, changed_cabin_air_filter, notes |
| `technical_checks` | roadworthiness inspections (przegląd techniczny / MOT / TÜV) | car, date, `result` (passed \| failed), cost, station, `valid_until`, notes |
| `parts` | per-car parts catalog | car, name, part_number, category |
| `car_shares` | grants another user access to a car | car, user, `permission` (read \| write) |
| `organizations` | tenants | name (unique) |
+3
View File
@@ -232,4 +232,7 @@ func applyCarDefaults(c *models.Car) {
if c.ServiceIntervalKm <= 0 {
c.ServiceIntervalKm = 15000
}
if c.TechnicalCheckIntervalDays <= 0 {
c.TechnicalCheckIntervalDays = models.DefaultTechnicalCheckIntervalDays
}
}
+110 -62
View File
@@ -41,78 +41,81 @@ func formatPBDate(t time.Time) string {
// carRecord is the PocketBase-facing shape of a car (snake_case fields).
type carRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Make string `json:"make"`
Model string `json:"model"`
Year int `json:"year"`
Registration string `json:"registration"`
RegistrationCountry string `json:"registration_country"`
VIN string `json:"vin"`
ServiceIntervalDays int `json:"service_interval_days"`
ServiceIntervalKm int `json:"service_interval_km"`
OilSpec string `json:"oil_spec"`
TransmissionOilSpec string `json:"transmission_oil_spec"`
DifferentialOilSpec string `json:"differential_oil_spec"`
BrakeFluidSpec string `json:"brake_fluid_spec"`
CoolantSpec string `json:"coolant_spec"`
CurrentKm int `json:"current_km"`
FuelType string `json:"fuel_type"`
BuildDate string `json:"build_date"`
FirstRegistrationDate string `json:"first_registration_date"`
Owner string `json:"owner"`
Created string `json:"created"`
Updated string `json:"updated"`
ID string `json:"id"`
Name string `json:"name"`
Make string `json:"make"`
Model string `json:"model"`
Year int `json:"year"`
Registration string `json:"registration"`
RegistrationCountry string `json:"registration_country"`
VIN string `json:"vin"`
ServiceIntervalDays int `json:"service_interval_days"`
ServiceIntervalKm int `json:"service_interval_km"`
TechnicalCheckIntervalDays int `json:"technical_check_interval_days"`
OilSpec string `json:"oil_spec"`
TransmissionOilSpec string `json:"transmission_oil_spec"`
DifferentialOilSpec string `json:"differential_oil_spec"`
BrakeFluidSpec string `json:"brake_fluid_spec"`
CoolantSpec string `json:"coolant_spec"`
CurrentKm int `json:"current_km"`
FuelType string `json:"fuel_type"`
BuildDate string `json:"build_date"`
FirstRegistrationDate string `json:"first_registration_date"`
Owner string `json:"owner"`
Created string `json:"created"`
Updated string `json:"updated"`
}
func (rec carRecord) toModel() models.Car {
return models.Car{
ID: rec.ID,
Name: rec.Name,
Make: rec.Make,
Model: rec.Model,
Year: rec.Year,
Registration: rec.Registration,
RegistrationCountry: rec.RegistrationCountry,
VIN: rec.VIN,
ServiceIntervalDays: rec.ServiceIntervalDays,
ServiceIntervalKm: rec.ServiceIntervalKm,
OilSpec: rec.OilSpec,
TransmissionOilSpec: rec.TransmissionOilSpec,
DifferentialOilSpec: rec.DifferentialOilSpec,
BrakeFluidSpec: rec.BrakeFluidSpec,
CoolantSpec: rec.CoolantSpec,
CurrentKm: rec.CurrentKm,
FuelType: rec.FuelType,
BuildDate: rec.BuildDate,
FirstRegistrationDate: rec.FirstRegistrationDate,
Owner: rec.Owner,
Created: rec.Created,
Updated: rec.Updated,
ID: rec.ID,
Name: rec.Name,
Make: rec.Make,
Model: rec.Model,
Year: rec.Year,
Registration: rec.Registration,
RegistrationCountry: rec.RegistrationCountry,
VIN: rec.VIN,
ServiceIntervalDays: rec.ServiceIntervalDays,
ServiceIntervalKm: rec.ServiceIntervalKm,
TechnicalCheckIntervalDays: rec.TechnicalCheckIntervalDays,
OilSpec: rec.OilSpec,
TransmissionOilSpec: rec.TransmissionOilSpec,
DifferentialOilSpec: rec.DifferentialOilSpec,
BrakeFluidSpec: rec.BrakeFluidSpec,
CoolantSpec: rec.CoolantSpec,
CurrentKm: rec.CurrentKm,
FuelType: rec.FuelType,
BuildDate: rec.BuildDate,
FirstRegistrationDate: rec.FirstRegistrationDate,
Owner: rec.Owner,
Created: rec.Created,
Updated: rec.Updated,
}
}
// carPayload builds the write payload for create/update from a domain Car.
func carPayload(c models.Car) map[string]any {
return map[string]any{
"name": c.Name,
"make": c.Make,
"model": c.Model,
"year": c.Year,
"registration": c.Registration,
"registration_country": c.RegistrationCountry,
"vin": c.VIN,
"service_interval_days": c.ServiceIntervalDays,
"service_interval_km": c.ServiceIntervalKm,
"oil_spec": c.OilSpec,
"transmission_oil_spec": c.TransmissionOilSpec,
"differential_oil_spec": c.DifferentialOilSpec,
"brake_fluid_spec": c.BrakeFluidSpec,
"coolant_spec": c.CoolantSpec,
"current_km": c.CurrentKm,
"fuel_type": c.FuelType,
"build_date": c.BuildDate,
"first_registration_date": c.FirstRegistrationDate,
"name": c.Name,
"make": c.Make,
"model": c.Model,
"year": c.Year,
"registration": c.Registration,
"registration_country": c.RegistrationCountry,
"vin": c.VIN,
"service_interval_days": c.ServiceIntervalDays,
"service_interval_km": c.ServiceIntervalKm,
"technical_check_interval_days": c.TechnicalCheckIntervalDays,
"oil_spec": c.OilSpec,
"transmission_oil_spec": c.TransmissionOilSpec,
"differential_oil_spec": c.DifferentialOilSpec,
"brake_fluid_spec": c.BrakeFluidSpec,
"coolant_spec": c.CoolantSpec,
"current_km": c.CurrentKm,
"fuel_type": c.FuelType,
"build_date": c.BuildDate,
"first_registration_date": c.FirstRegistrationDate,
}
}
@@ -161,6 +164,51 @@ func servicePayload(r models.ServiceRecord) map[string]any {
}
}
// --- technical checks ---
type technicalCheckRecord struct {
ID string `json:"id"`
Car string `json:"car"`
Date string `json:"date"`
Result string `json:"result"`
Cost float64 `json:"cost"`
Station string `json:"station"`
ValidUntil string `json:"valid_until"`
Notes string `json:"notes"`
File string `json:"file"`
Created string `json:"created"`
Updated string `json:"updated"`
}
func (rec technicalCheckRecord) toModel() models.TechnicalCheck {
return models.TechnicalCheck{
ID: rec.ID,
Car: rec.Car,
Date: parsePBDate(rec.Date),
Result: rec.Result,
Cost: rec.Cost,
Station: rec.Station,
ValidUntil: parsePBDatePtr(rec.ValidUntil),
Notes: rec.Notes,
Attachment: attachmentOf(rec.File),
Created: rec.Created,
Updated: rec.Updated,
}
}
// technicalCheckPayload omits the file field — see attachmentOf.
func technicalCheckPayload(t models.TechnicalCheck) map[string]any {
return map[string]any{
"car": t.Car,
"date": formatPBDate(t.Date),
"result": t.Result,
"cost": t.Cost,
"station": t.Station,
"valid_until": formatPBDatePtr(t.ValidUntil),
"notes": t.Notes,
}
}
// --- attachments ---
// attachmentOf renders a PocketBase file field into the model's attachment pair.
+27 -10
View File
@@ -54,6 +54,12 @@
// GET /api/parts POST /api/parts
// GET /api/parts/{id} PATCH /api/parts/{id} DELETE /api/parts/{id}
//
// # technical checks (roadworthiness inspections; time-only, next date derived)
// GET /api/cars/{id}/technical-checks
// GET /api/technical-checks POST /api/technical-checks
// GET /api/technical-checks/{id} PATCH /api/technical-checks/{id}
// DELETE /api/technical-checks/{id}
//
// # fuel tracking (efficiency is derived on read, never stored)
// GET /api/cars/{id}/fuel-entries
// GET /api/cars/{id}/fuel-stats
@@ -74,7 +80,8 @@
// DELETE /api/car-documents/{id}
//
// # attachments — one optional file per record, same three verbs everywhere.
// # {records} is car-documents | service-records | maintenance | fuel-entries | parts
// # {records} is car-documents | service-records | technical-checks | maintenance
// # | fuel-entries | parts
// POST /api/{records}/{id}/file
// GET /api/{records}/{id}/file
// DELETE /api/{records}/{id}/file
@@ -101,15 +108,16 @@ import (
// PocketBase collection names.
const (
colCars = "cars"
colServices = "service_records"
colParts = "parts"
colShares = "car_shares"
colOrgs = "organizations"
colFuel = "fuel_entries"
colMaintenance = "maintenance_entries"
colDocuments = "car_documents"
colReminders = "reminders"
colCars = "cars"
colServices = "service_records"
colTechnicalChecks = "technical_checks"
colParts = "parts"
colShares = "car_shares"
colOrgs = "organizations"
colFuel = "fuel_entries"
colMaintenance = "maintenance_entries"
colDocuments = "car_documents"
colReminders = "reminders"
)
// Server wires together the HTTP handlers and their dependencies.
@@ -241,6 +249,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("PATCH /api/cars/{id}", s.updateCar)
mux.HandleFunc("DELETE /api/cars/{id}", s.deleteCar)
mux.HandleFunc("GET /api/cars/{id}/service-records", s.listCarServiceRecords)
mux.HandleFunc("GET /api/cars/{id}/technical-checks", s.listCarTechnicalChecks)
mux.HandleFunc("GET /api/cars/{id}/parts", s.listCarParts)
mux.HandleFunc("GET /api/cars/{id}/fuel-entries", s.listCarFuelEntries)
mux.HandleFunc("GET /api/cars/{id}/fuel-stats", s.listCarFuelStats)
@@ -258,6 +267,13 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("PATCH /api/service-records/{id}", s.updateServiceRecord)
mux.HandleFunc("DELETE /api/service-records/{id}", s.deleteServiceRecord)
// Technical checks (roadworthiness inspections).
mux.HandleFunc("GET /api/technical-checks", s.listTechnicalChecks)
mux.HandleFunc("POST /api/technical-checks", s.createTechnicalCheck)
mux.HandleFunc("GET /api/technical-checks/{id}", s.getTechnicalCheck)
mux.HandleFunc("PATCH /api/technical-checks/{id}", s.updateTechnicalCheck)
mux.HandleFunc("DELETE /api/technical-checks/{id}", s.deleteTechnicalCheck)
// Parts.
mux.HandleFunc("GET /api/parts", s.listParts)
mux.HandleFunc("POST /api/parts", s.createPart)
@@ -300,6 +316,7 @@ func (s *Server) Handler() http.Handler {
// alongside is what renders the record after an upload.
s.attachmentRoutes(mux, "/api/car-documents", colDocuments, s.getDocument)
s.attachmentRoutes(mux, "/api/service-records", colServices, s.getServiceRecord)
s.attachmentRoutes(mux, "/api/technical-checks", colTechnicalChecks, s.getTechnicalCheck)
s.attachmentRoutes(mux, "/api/maintenance", colMaintenance, s.getMaintenance)
s.attachmentRoutes(mux, "/api/fuel-entries", colFuel, s.getFuelEntry)
s.attachmentRoutes(mux, "/api/parts", colParts, s.getPart)
+193
View File
@@ -0,0 +1,193 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"drivervault/apiserver/internal/models"
)
// Technical check history: the mandatory roadworthiness inspections a car has
// been through — przegląd techniczny, MOT, TÜV.
//
// Shaped like the service log next to it, but time-only: an inspection falls due
// on a date regardless of the odometer. The next-due date is assessed live on
// every read (models.TechnicalCheck.ComputeTechnicalCheckDerived) rather than
// stored, for the same reason documents are — a lapsed certificate is a car that
// cannot legally be driven, and a stored verdict would quietly go stale.
//
// The certificate scan is handled by attachments.go, on the same terms as every
// other record's attachment.
var technicalCheckResults = map[string]bool{"passed": true, "failed": true}
// listCarTechnicalChecks serves GET /api/cars/{id}/technical-checks.
func (s *Server) listCarTechnicalChecks(w http.ResponseWriter, r *http.Request) {
carID := r.PathValue("id")
if !s.requireCarAccess(w, r, carID, accessRead) {
return
}
s.respondTechnicalCheckList(w, r, carID)
}
// listTechnicalChecks serves GET /api/technical-checks?car={id}.
func (s *Server) listTechnicalChecks(w http.ResponseWriter, r *http.Request) {
carID := r.URL.Query().Get("car")
if !s.requireCarAccess(w, r, carID, accessRead) {
return
}
s.respondTechnicalCheckList(w, r, carID)
}
func (s *Server) respondTechnicalCheckList(w http.ResponseWriter, r *http.Request, carID string) {
q := url.Values{}
q.Set("sort", "-date") // most-recent check first, like the service log
q.Set("perPage", "500")
q.Set("filter", fmt.Sprintf("car='%s'", carID))
res, err := s.pb.List(r.Context(), colTechnicalChecks, q)
if err != nil {
writePBError(w, err)
return
}
var recs []technicalCheckRecord
if err := json.Unmarshal(res.Items, &recs); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
cars := newCarCache(s)
now := time.Now()
out := make([]models.TechnicalCheck, 0, len(recs))
for _, rec := range recs {
m := rec.toModel()
car, err := cars.get(r.Context(), m.Car)
if err != nil {
car = nil // fall back to the default interval rather than dropping the row
}
m.ComputeTechnicalCheckDerived(car, now)
out = append(out, m)
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) getTechnicalCheck(w http.ResponseWriter, r *http.Request) {
var rec technicalCheckRecord
if err := s.pb.GetOne(r.Context(), colTechnicalChecks, r.PathValue("id"), &rec); err != nil {
writePBError(w, err)
return
}
m := rec.toModel()
if !s.requireCarAccess(w, r, m.Car, accessRead) {
return
}
s.writeTechnicalCheck(w, r, m, http.StatusOK)
}
func (s *Server) createTechnicalCheck(w http.ResponseWriter, r *http.Request) {
var in models.TechnicalCheck
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if in.Result == "" {
in.Result = "passed"
}
if msg := validateTechnicalCheck(in); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
if !s.requireCarAccess(w, r, in.Car, accessWrite) {
return
}
var rec technicalCheckRecord
if err := s.pb.Create(r.Context(), colTechnicalChecks, technicalCheckPayload(in), &rec); err != nil {
writePBError(w, err)
return
}
s.writeTechnicalCheck(w, r, rec.toModel(), http.StatusCreated)
}
func (s *Server) updateTechnicalCheck(w http.ResponseWriter, r *http.Request) {
var in models.TechnicalCheck
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Enforce write access via the record's existing parent car, so the body's
// car field cannot be used to escape into another user's car.
var existing technicalCheckRecord
if err := s.pb.GetOne(r.Context(), colTechnicalChecks, r.PathValue("id"), &existing); err != nil {
writePBError(w, err)
return
}
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
return
}
in.Car = existing.Car // a check's car is not reassignable via PATCH
if in.Result == "" {
in.Result = "passed"
}
if msg := validateTechnicalCheck(in); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
var rec technicalCheckRecord
if err := s.pb.Update(r.Context(), colTechnicalChecks, r.PathValue("id"), technicalCheckPayload(in), &rec); err != nil {
writePBError(w, err)
return
}
s.writeTechnicalCheck(w, r, rec.toModel(), http.StatusOK)
}
func (s *Server) deleteTechnicalCheck(w http.ResponseWriter, r *http.Request) {
var existing technicalCheckRecord
if err := s.pb.GetOne(r.Context(), colTechnicalChecks, r.PathValue("id"), &existing); err != nil {
writePBError(w, err)
return
}
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
return
}
if err := s.pb.Delete(r.Context(), colTechnicalChecks, r.PathValue("id")); err != nil {
writePBError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// writeTechnicalCheck derives the next-due date against the parent car and
// responds. Shared by the three handlers that return a single check.
func (s *Server) writeTechnicalCheck(w http.ResponseWriter, r *http.Request, m models.TechnicalCheck, status int) {
car, err := s.carModel(r.Context(), m.Car)
if err != nil {
car = nil
}
m.ComputeTechnicalCheckDerived(car, time.Now())
writeJSON(w, status, m)
}
func validateTechnicalCheck(t models.TechnicalCheck) string {
switch {
case t.Car == "":
return "car is required"
case t.Date.IsZero():
return "date is required"
case !technicalCheckResults[t.Result]:
return "result must be passed or failed"
case t.Cost < 0:
return "cost cannot be negative"
case len(strings.TrimSpace(t.Station)) > 200:
return "station name is too long"
}
if t.ValidUntil != nil && !t.ValidUntil.IsZero() && t.ValidUntil.Before(t.Date) {
return "valid-until date cannot be before the check date"
}
return ""
}
+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
+24 -2
View File
@@ -236,6 +236,9 @@ const DESIRED = {
F.text("vin"),
F.number("service_interval_days"),
F.number("service_interval_km"),
// Roadworthiness inspection cycle. Only prefills a check's next-due date —
// the legal interval changes as the car ages, so each check can override it.
F.number("technical_check_interval_days"),
F.text("oil_spec"),
F.text("transmission_oil_spec"),
F.text("differential_oil_spec"),
@@ -261,6 +264,20 @@ const DESIRED = {
F.text("notes"),
attachment(), // the workshop receipt / stamped service-book page
],
// Mandatory roadworthiness inspections (przegląd techniczny / MOT / TÜV).
// Like service_records but time-only: a check falls due on a date whatever the
// odometer reads. valid_until is the expiry printed on the certificate; when
// blank the API derives it from the car's interval.
technical_checks: [
F.relation("car", "cars", true),
F.date("date", true),
F.select("result", ["passed", "failed"]),
F.number("cost"),
F.text("station"),
F.date("valid_until"),
F.text("notes"),
attachment(), // the certificate
],
parts: [
F.relation("car", "cars", true),
F.text("name", true),
@@ -390,6 +407,8 @@ const INDEXES = {
maintenance_entries: ["CREATE INDEX `idx_maintenance_entries_car_date` ON `maintenance_entries` (`car`, `date`)"],
car_documents: ["CREATE INDEX `idx_car_documents_car_expiry` ON `car_documents` (`car`, `expiry_date`)"],
reminders: ["CREATE INDEX `idx_reminders_car_due` ON `reminders` (`car`, `due_date`)"],
// Read as "this car's checks, newest first" every time.
technical_checks: ["CREATE INDEX `idx_technical_checks_car_date` ON `technical_checks` (`car`, `date`)"],
};
async function main() {
@@ -411,6 +430,7 @@ async function main() {
"organizations",
"cars",
"service_records",
"technical_checks",
"parts",
"car_shares",
"fuel_entries",
@@ -434,6 +454,7 @@ async function main() {
"users",
"cars",
"service_records",
"technical_checks",
"parts",
"car_shares",
"fuel_entries",
@@ -445,8 +466,9 @@ async function main() {
}
console.log(
"\nDone. Collections ready: organizations, users, cars, service_records, parts,\n" +
"car_shares, fuel_entries, maintenance_entries, car_documents, reminders.",
"\nDone. Collections ready: organizations, users, cars, service_records,\n" +
"technical_checks, parts, car_shares, fuel_entries, maintenance_entries,\n" +
"car_documents, reminders.",
);
console.log(
"Note: the legacy `sessions` collection is no longer used (auth moved to PocketBase\n" +