diff --git a/API Server/internal/api/documents.go b/API Server/internal/api/documents.go
new file mode 100644
index 0000000..7d10050
--- /dev/null
+++ b/API Server/internal/api/documents.go
@@ -0,0 +1,327 @@
+package api
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "drivervault/apiserver/internal/models"
+)
+
+// Document tracking: insurance policies, pollution/emissions certificates,
+// registration papers and the like, each with a renewal date.
+//
+// The expiry date is the reason the feature exists — a lapsed policy is a car
+// that cannot legally be driven — so it is assessed live on every read
+// (models.CarDocument.ComputeExpiry) rather than stored and left to go stale.
+// Documents also feed the auto-derived reminders in reminders.go.
+//
+// The scan/PDF lives in PocketBase's file storage and is reached only through
+// this server's superuser service account, so an attachment is never a public
+// URL: clients fetch it from GET /api/car-documents/{id}/file, which re-checks
+// car access on every request.
+
+// maxDocumentUpload caps an attachment at 10 MB — comfortably above a scanned
+// certificate, well below anything that would tie up the server.
+const maxDocumentUpload = 10 << 20
+
+var documentTypes = map[string]bool{
+ "insurance": true,
+ "pollution": true,
+ "registration": true,
+ "inspection": true,
+ "roadTax": true,
+ "warranty": true,
+ "other": true,
+}
+
+// listCarDocuments serves GET /api/cars/{id}/documents.
+func (s *Server) listCarDocuments(w http.ResponseWriter, r *http.Request) {
+ carID := r.PathValue("id")
+ if !s.requireCarAccess(w, r, carID, accessRead) {
+ return
+ }
+ s.respondDocumentList(w, r, carID)
+}
+
+// listDocuments serves GET /api/car-documents?car={id}.
+func (s *Server) listDocuments(w http.ResponseWriter, r *http.Request) {
+ carID := r.URL.Query().Get("car")
+ if !s.requireCarAccess(w, r, carID, accessRead) {
+ return
+ }
+ s.respondDocumentList(w, r, carID)
+}
+
+func (s *Server) respondDocumentList(w http.ResponseWriter, r *http.Request, carID string) {
+ docs, err := s.fetchDocuments(r, carID)
+ if err != nil {
+ writePBError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, docs)
+}
+
+// fetchDocuments loads a car's documents with the soonest renewal first — the
+// order in which they need attention.
+func (s *Server) fetchDocuments(r *http.Request, carID string) ([]models.CarDocument, error) {
+ res, err := s.pb.List(r.Context(), colDocuments, url.Values{
+ "filter": {fmt.Sprintf("car='%s'", carID)},
+ "perPage": {"500"},
+ })
+ if err != nil {
+ return nil, err
+ }
+ var recs []documentRecord
+ if err := json.Unmarshal(res.Items, &recs); err != nil {
+ return nil, err
+ }
+ now := time.Now()
+ out := make([]models.CarDocument, 0, len(recs))
+ for _, rec := range recs {
+ d := rec.toModel()
+ d.ComputeExpiry(now)
+ out = append(out, d)
+ }
+
+ // Sorted here rather than by PocketBase: it orders a blank expiry_date ahead
+ // of every real date, which would file the documents that never expire above
+ // the ones that have already lapsed — the exact inverse of what this list is
+ // for. Everything with a renewal date comes first, soonest at the top.
+ sort.SliceStable(out, func(i, j int) bool {
+ a, b := out[i], out[j]
+ ae, be := a.ExpiryDate != nil, b.ExpiryDate != nil
+ if ae != be {
+ return ae
+ }
+ if ae && be && !a.ExpiryDate.Equal(*b.ExpiryDate) {
+ return a.ExpiryDate.Before(*b.ExpiryDate)
+ }
+ return a.Title < b.Title
+ })
+ return out, nil
+}
+
+func (s *Server) getDocument(w http.ResponseWriter, r *http.Request) {
+ var rec documentRecord
+ if err := s.pb.GetOne(r.Context(), colDocuments, r.PathValue("id"), &rec); err != nil {
+ writePBError(w, err)
+ return
+ }
+ if !s.requireCarAccess(w, r, rec.Car, accessRead) {
+ return
+ }
+ d := rec.toModel()
+ d.ComputeExpiry(time.Now())
+ writeJSON(w, http.StatusOK, d)
+}
+
+func (s *Server) createDocument(w http.ResponseWriter, r *http.Request) {
+ var in models.CarDocument
+ if err := decodeJSON(r, &in); err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ if in.Type == "" {
+ in.Type = "other"
+ }
+ if msg := validateDocument(in); msg != "" {
+ writeError(w, http.StatusBadRequest, msg)
+ return
+ }
+ if !s.requireCarAccess(w, r, in.Car, accessWrite) {
+ return
+ }
+ var rec documentRecord
+ if err := s.pb.Create(r.Context(), colDocuments, documentPayload(in), &rec); err != nil {
+ writePBError(w, err)
+ return
+ }
+ d := rec.toModel()
+ d.ComputeExpiry(time.Now())
+ writeJSON(w, http.StatusCreated, d)
+}
+
+func (s *Server) updateDocument(w http.ResponseWriter, r *http.Request) {
+ var in models.CarDocument
+ if err := decodeJSON(r, &in); err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ var existing documentRecord
+ if err := s.pb.GetOne(r.Context(), colDocuments, r.PathValue("id"), &existing); err != nil {
+ writePBError(w, err)
+ return
+ }
+ if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
+ return
+ }
+ in.Car = existing.Car // the document's car is not reassignable via PATCH
+ if in.Type == "" {
+ in.Type = "other"
+ }
+ if msg := validateDocument(in); msg != "" {
+ writeError(w, http.StatusBadRequest, msg)
+ return
+ }
+ var rec documentRecord
+ if err := s.pb.Update(r.Context(), colDocuments, r.PathValue("id"), documentPayload(in), &rec); err != nil {
+ writePBError(w, err)
+ return
+ }
+ d := rec.toModel()
+ d.ComputeExpiry(time.Now())
+ writeJSON(w, http.StatusOK, d)
+}
+
+func (s *Server) deleteDocument(w http.ResponseWriter, r *http.Request) {
+ var existing documentRecord
+ if err := s.pb.GetOne(r.Context(), colDocuments, 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(), colDocuments, r.PathValue("id")); err != nil {
+ writePBError(w, err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// handleUploadDocumentFile serves POST /api/car-documents/{id}/file — the
+// scan/PDF for an existing document. Replaces whatever was attached before.
+func (s *Server) handleUploadDocumentFile(w http.ResponseWriter, r *http.Request) {
+ var existing documentRecord
+ if err := s.pb.GetOne(r.Context(), colDocuments, r.PathValue("id"), &existing); err != nil {
+ writePBError(w, err)
+ return
+ }
+ if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
+ return
+ }
+ if err := r.ParseMultipartForm(maxDocumentUpload); err != nil {
+ writeError(w, http.StatusBadRequest, "document upload must be under 10MB")
+ return
+ }
+ file, header, err := r.FormFile("file")
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "missing file")
+ return
+ }
+ defer file.Close()
+
+ data, err := io.ReadAll(io.LimitReader(file, maxDocumentUpload+1))
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "could not read upload")
+ return
+ }
+ if len(data) > maxDocumentUpload {
+ writeError(w, http.StatusRequestEntityTooLarge, "document upload must be under 10MB")
+ return
+ }
+ if msg := validateDocumentFile(header.Filename); msg != "" {
+ writeError(w, http.StatusBadRequest, msg)
+ return
+ }
+
+ if err := s.pb.UpdateMultipart(r.Context(), colDocuments, existing.ID, nil, "file", header.Filename, data); err != nil {
+ writePBError(w, err)
+ return
+ }
+ var rec documentRecord
+ if err := s.pb.GetOne(r.Context(), colDocuments, existing.ID, &rec); err != nil {
+ writePBError(w, err)
+ return
+ }
+ d := rec.toModel()
+ d.ComputeExpiry(time.Now())
+ writeJSON(w, http.StatusOK, d)
+}
+
+// handleGetDocumentFile serves GET /api/car-documents/{id}/file. The bytes are
+// proxied through this server because PocketBase's collections have no public
+// access rules — only the service account can read them.
+func (s *Server) handleGetDocumentFile(w http.ResponseWriter, r *http.Request) {
+ var rec documentRecord
+ if err := s.pb.GetOne(r.Context(), colDocuments, r.PathValue("id"), &rec); err != nil {
+ writePBError(w, err)
+ return
+ }
+ if !s.requireCarAccess(w, r, rec.Car, accessRead) {
+ return
+ }
+ if rec.File == "" {
+ writeError(w, http.StatusNotFound, "no file attached")
+ return
+ }
+ data, contentType, err := s.pb.GetFile(r.Context(), colDocuments, rec.ID, rec.File)
+ if err != nil {
+ writePBError(w, err)
+ return
+ }
+ w.Header().Set("Content-Type", contentType)
+ // The stored name is PocketBase's slugified one; quotes are stripped so a
+ // crafted filename can't break out of the header.
+ name := strings.ReplaceAll(rec.File, `"`, "")
+ w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name))
+ w.Header().Set("Cache-Control", "private, max-age=300")
+ w.Write(data)
+}
+
+// handleDeleteDocumentFile serves DELETE /api/car-documents/{id}/file, detaching
+// the attachment but keeping the document's metadata.
+func (s *Server) handleDeleteDocumentFile(w http.ResponseWriter, r *http.Request) {
+ var existing documentRecord
+ if err := s.pb.GetOne(r.Context(), colDocuments, r.PathValue("id"), &existing); err != nil {
+ writePBError(w, err)
+ return
+ }
+ if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
+ return
+ }
+ if err := s.pb.Update(r.Context(), colDocuments, existing.ID, map[string]any{"file": nil}, nil); err != nil {
+ writePBError(w, err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func validateDocument(d models.CarDocument) string {
+ switch {
+ case d.Car == "":
+ return "car is required"
+ case strings.TrimSpace(d.Title) == "":
+ return "title is required"
+ case !documentTypes[d.Type]:
+ return "type must be one of: insurance, pollution, registration, inspection, roadTax, warranty, other"
+ case d.Cost < 0:
+ return "cost cannot be negative"
+ }
+ if d.IssueDate != nil && d.ExpiryDate != nil && d.ExpiryDate.Before(*d.IssueDate) {
+ return "expiry date cannot be before the issue date"
+ }
+ return ""
+}
+
+// documentFileTypes are the extensions an attachment may carry. The list is
+// restrictive on purpose: these documents are scans, and anything executable has
+// no business being stored and handed back out.
+var documentFileTypes = map[string]bool{
+ ".pdf": true, ".jpg": true, ".jpeg": true, ".png": true, ".webp": true, ".heic": true,
+}
+
+func validateDocumentFile(filename string) string {
+ ext := strings.ToLower(filepath.Ext(filename))
+ if !documentFileTypes[ext] {
+ return "file must be a PDF or an image (pdf, jpg, png, webp, heic)"
+ }
+ return ""
+}
diff --git a/API Server/internal/api/fuel.go b/API Server/internal/api/fuel.go
new file mode 100644
index 0000000..cafe234
--- /dev/null
+++ b/API Server/internal/api/fuel.go
@@ -0,0 +1,250 @@
+package api
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "sort"
+
+ "drivervault/apiserver/internal/models"
+)
+
+// Fuel tracking: a log of refills per car, plus the efficiency derived from it.
+//
+// Nothing about consumption is stored — it is recomputed from the whole history
+// on every read (models.ComputeFuelDerived). Editing a fill three months back
+// therefore corrects every window it touches, with no rows to migrate.
+
+// fetchFuelEntries loads a car's refills oldest-first and fills in the derived
+// efficiency fields. Ordering is by odometer rather than date because the
+// full-tank windows are spans of distance, and a fill logged with the wrong date
+// would otherwise scramble the chain.
+func (s *Server) fetchFuelEntries(r *http.Request, carID string) ([]models.FuelEntry, error) {
+ res, err := s.pb.List(r.Context(), colFuel, url.Values{
+ "filter": {fmt.Sprintf("car='%s'", carID)},
+ "sort": {"km"},
+ "perPage": {"1000"},
+ })
+ if err != nil {
+ return nil, err
+ }
+ var recs []fuelRecord
+ if err := json.Unmarshal(res.Items, &recs); err != nil {
+ return nil, err
+ }
+ out := make([]models.FuelEntry, 0, len(recs))
+ for _, rec := range recs {
+ out = append(out, rec.toModel())
+ }
+ // PocketBase sorts numerically here, but re-sorting locally keeps the
+ // invariant ComputeFuelDerived depends on explicit and cheap.
+ sort.SliceStable(out, func(i, j int) bool { return out[i].Km < out[j].Km })
+ models.ComputeFuelDerived(out)
+ return out, nil
+}
+
+// listCarFuelEntries serves GET /api/cars/{id}/fuel-entries, newest first.
+func (s *Server) listCarFuelEntries(w http.ResponseWriter, r *http.Request) {
+ carID := r.PathValue("id")
+ if !s.requireCarAccess(w, r, carID, accessRead) {
+ return
+ }
+ entries, err := s.fetchFuelEntries(r, carID)
+ if err != nil {
+ writePBError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, reverseFuel(entries))
+}
+
+// listFuelEntries serves GET /api/fuel-entries?car={id}.
+func (s *Server) listFuelEntries(w http.ResponseWriter, r *http.Request) {
+ carID := r.URL.Query().Get("car")
+ if !s.requireCarAccess(w, r, carID, accessRead) {
+ return
+ }
+ entries, err := s.fetchFuelEntries(r, carID)
+ if err != nil {
+ writePBError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, reverseFuel(entries))
+}
+
+// listCarFuelStats serves GET /api/cars/{id}/fuel-stats.
+func (s *Server) listCarFuelStats(w http.ResponseWriter, r *http.Request) {
+ carID := r.PathValue("id")
+ if !s.requireCarAccess(w, r, carID, accessRead) {
+ return
+ }
+ entries, err := s.fetchFuelEntries(r, carID)
+ if err != nil {
+ writePBError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, models.ComputeFuelStats(entries))
+}
+
+// reverseFuel flips the oldest-first working order into the newest-first order
+// clients display.
+func reverseFuel(in []models.FuelEntry) []models.FuelEntry {
+ out := make([]models.FuelEntry, len(in))
+ for i, e := range in {
+ out[len(in)-1-i] = e
+ }
+ return out
+}
+
+// withFuelDerived recomputes the whole history and returns the one entry the
+// caller just wrote, so a create/update response carries the same derived
+// figures the list would show.
+func (s *Server) withFuelDerived(r *http.Request, carID, id string) (models.FuelEntry, error) {
+ entries, err := s.fetchFuelEntries(r, carID)
+ if err != nil {
+ return models.FuelEntry{}, err
+ }
+ for _, e := range entries {
+ if e.ID == id {
+ return e, nil
+ }
+ }
+ return models.FuelEntry{}, fmt.Errorf("fuel entry %s not found after write", id)
+}
+
+func (s *Server) getFuelEntry(w http.ResponseWriter, r *http.Request) {
+ var rec fuelRecord
+ if err := s.pb.GetOne(r.Context(), colFuel, r.PathValue("id"), &rec); err != nil {
+ writePBError(w, err)
+ return
+ }
+ if !s.requireCarAccess(w, r, rec.Car, accessRead) {
+ return
+ }
+ entry, err := s.withFuelDerived(r, rec.Car, rec.ID)
+ if err != nil {
+ writePBError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, entry)
+}
+
+func (s *Server) createFuelEntry(w http.ResponseWriter, r *http.Request) {
+ var in models.FuelEntry
+ if err := decodeJSON(r, &in); err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ if err := validateFuelEntry(in); err != "" {
+ writeError(w, http.StatusBadRequest, err)
+ return
+ }
+ if !s.requireCarAccess(w, r, in.Car, accessWrite) {
+ return
+ }
+ var rec fuelRecord
+ if err := s.pb.Create(r.Context(), colFuel, fuelPayload(in), &rec); err != nil {
+ writePBError(w, err)
+ return
+ }
+ // A refill is also the freshest odometer reading there is; keeping the car in
+ // step means the km-based service and reminder status stay honest without the
+ // user retyping the number on the car itself.
+ s.advanceOdometer(r, in.Car, in.Km)
+
+ entry, err := s.withFuelDerived(r, rec.Car, rec.ID)
+ if err != nil {
+ writePBError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusCreated, entry)
+}
+
+func (s *Server) updateFuelEntry(w http.ResponseWriter, r *http.Request) {
+ var in models.FuelEntry
+ if err := decodeJSON(r, &in); err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ var existing fuelRecord
+ if err := s.pb.GetOne(r.Context(), colFuel, r.PathValue("id"), &existing); err != nil {
+ writePBError(w, err)
+ return
+ }
+ if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
+ return
+ }
+ // The car is fixed by the record being edited; a body claiming another car
+ // must not move the entry across the access boundary just checked.
+ in.Car = existing.Car
+ if err := validateFuelEntry(in); err != "" {
+ writeError(w, http.StatusBadRequest, err)
+ return
+ }
+ var rec fuelRecord
+ if err := s.pb.Update(r.Context(), colFuel, r.PathValue("id"), fuelPayload(in), &rec); err != nil {
+ writePBError(w, err)
+ return
+ }
+ s.advanceOdometer(r, rec.Car, in.Km)
+
+ entry, err := s.withFuelDerived(r, rec.Car, rec.ID)
+ if err != nil {
+ writePBError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, entry)
+}
+
+func (s *Server) deleteFuelEntry(w http.ResponseWriter, r *http.Request) {
+ var existing fuelRecord
+ if err := s.pb.GetOne(r.Context(), colFuel, 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(), colFuel, r.PathValue("id")); err != nil {
+ writePBError(w, err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// validateFuelEntry returns a human-readable reason the entry is unusable, or ""
+// when it is fine.
+func validateFuelEntry(f models.FuelEntry) string {
+ switch {
+ case f.Car == "":
+ return "car is required"
+ case f.Date.IsZero():
+ return "date is required"
+ case f.Km <= 0:
+ return "odometer (km) is required"
+ case f.Liters <= 0:
+ return "liters must be greater than zero"
+ case f.Cost < 0:
+ return "cost cannot be negative"
+ }
+ return ""
+}
+
+// advanceOdometer moves the car's current_km forward to km. It only ever
+// increases the reading and never fails the caller's request: a stale odometer
+// is a cosmetic problem, whereas rejecting a valid refill over it is not. A
+// lower km means the user is backfilling older history, which must not rewind
+// the car.
+func (s *Server) advanceOdometer(r *http.Request, carID string, km int) {
+ if km <= 0 {
+ return
+ }
+ var car carRecord
+ if err := s.pb.GetOne(r.Context(), colCars, carID, &car); err != nil {
+ return
+ }
+ if km <= car.CurrentKm {
+ return
+ }
+ _ = s.pb.Update(r.Context(), colCars, carID, map[string]any{"current_km": km}, nil)
+}
diff --git a/API Server/internal/api/maintenance.go b/API Server/internal/api/maintenance.go
new file mode 100644
index 0000000..fdc2f75
--- /dev/null
+++ b/API Server/internal/api/maintenance.go
@@ -0,0 +1,210 @@
+package api
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "time"
+
+ "drivervault/apiserver/internal/models"
+)
+
+// Maintenance log: workshop visits and repairs.
+//
+// This is deliberately NOT the service history. A ServiceRecord is the routine,
+// interval-driven schedule the spreadsheet was built around (oil at 15000 km,
+// filters once a year) and it drives the next-service-due calculation. A
+// MaintenanceEntry is unplanned or one-off work done at a garage — a failed
+// alternator, a clutch, bodywork after a scrape — which has a workshop, an
+// invoice, and a labour bill, and no bearing on the service interval.
+
+// maintenanceTypes are the kinds of visit the log accepts. Anything else is
+// rejected rather than silently stored, so the UI's filters stay meaningful.
+var maintenanceTypes = map[string]bool{
+ "repair": true,
+ "inspection": true,
+ "bodywork": true,
+ "tyres": true,
+ "diagnostics": true,
+ "recall": true,
+ "warranty": true,
+ "other": true,
+}
+
+var maintenanceStatuses = map[string]bool{
+ "scheduled": true,
+ "in_progress": true,
+ "completed": true,
+}
+
+// listCarMaintenance serves GET /api/cars/{id}/maintenance.
+func (s *Server) listCarMaintenance(w http.ResponseWriter, r *http.Request) {
+ carID := r.PathValue("id")
+ if !s.requireCarAccess(w, r, carID, accessRead) {
+ return
+ }
+ s.respondMaintenanceList(w, r, carID)
+}
+
+// listMaintenance serves GET /api/maintenance?car={id}.
+func (s *Server) listMaintenance(w http.ResponseWriter, r *http.Request) {
+ carID := r.URL.Query().Get("car")
+ if !s.requireCarAccess(w, r, carID, accessRead) {
+ return
+ }
+ s.respondMaintenanceList(w, r, carID)
+}
+
+func (s *Server) respondMaintenanceList(w http.ResponseWriter, r *http.Request, carID string) {
+ entries, err := s.fetchMaintenance(r, carID)
+ if err != nil {
+ writePBError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, entries)
+}
+
+// fetchMaintenance loads a car's maintenance entries newest-first with derived
+// cost and warranty fields filled in.
+func (s *Server) fetchMaintenance(r *http.Request, carID string) ([]models.MaintenanceEntry, error) {
+ res, err := s.pb.List(r.Context(), colMaintenance, url.Values{
+ "filter": {fmt.Sprintf("car='%s'", carID)},
+ "sort": {"-date"},
+ "perPage": {"500"},
+ })
+ if err != nil {
+ return nil, err
+ }
+ var recs []maintenanceRecord
+ if err := json.Unmarshal(res.Items, &recs); err != nil {
+ return nil, err
+ }
+ now := time.Now()
+ out := make([]models.MaintenanceEntry, 0, len(recs))
+ for _, rec := range recs {
+ m := rec.toModel()
+ m.ComputeMaintenanceDerived(now)
+ out = append(out, m)
+ }
+ return out, nil
+}
+
+func (s *Server) getMaintenance(w http.ResponseWriter, r *http.Request) {
+ var rec maintenanceRecord
+ if err := s.pb.GetOne(r.Context(), colMaintenance, r.PathValue("id"), &rec); err != nil {
+ writePBError(w, err)
+ return
+ }
+ if !s.requireCarAccess(w, r, rec.Car, accessRead) {
+ return
+ }
+ m := rec.toModel()
+ m.ComputeMaintenanceDerived(time.Now())
+ writeJSON(w, http.StatusOK, m)
+}
+
+func (s *Server) createMaintenance(w http.ResponseWriter, r *http.Request) {
+ var in models.MaintenanceEntry
+ if err := decodeJSON(r, &in); err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ applyMaintenanceDefaults(&in)
+ if msg := validateMaintenance(in); msg != "" {
+ writeError(w, http.StatusBadRequest, msg)
+ return
+ }
+ if !s.requireCarAccess(w, r, in.Car, accessWrite) {
+ return
+ }
+ var rec maintenanceRecord
+ if err := s.pb.Create(r.Context(), colMaintenance, maintenancePayload(in), &rec); err != nil {
+ writePBError(w, err)
+ return
+ }
+ // Only completed work proves the car actually reached that odometer; a
+ // scheduled visit carries an estimate of where it will be.
+ if in.Status == "completed" {
+ s.advanceOdometer(r, in.Car, in.Km)
+ }
+ m := rec.toModel()
+ m.ComputeMaintenanceDerived(time.Now())
+ writeJSON(w, http.StatusCreated, m)
+}
+
+func (s *Server) updateMaintenance(w http.ResponseWriter, r *http.Request) {
+ var in models.MaintenanceEntry
+ if err := decodeJSON(r, &in); err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ var existing maintenanceRecord
+ if err := s.pb.GetOne(r.Context(), colMaintenance, r.PathValue("id"), &existing); err != nil {
+ writePBError(w, err)
+ return
+ }
+ if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
+ return
+ }
+ in.Car = existing.Car // the record's car is not reassignable via PATCH
+ applyMaintenanceDefaults(&in)
+ if msg := validateMaintenance(in); msg != "" {
+ writeError(w, http.StatusBadRequest, msg)
+ return
+ }
+ var rec maintenanceRecord
+ if err := s.pb.Update(r.Context(), colMaintenance, r.PathValue("id"), maintenancePayload(in), &rec); err != nil {
+ writePBError(w, err)
+ return
+ }
+ if in.Status == "completed" {
+ s.advanceOdometer(r, rec.Car, in.Km)
+ }
+ m := rec.toModel()
+ m.ComputeMaintenanceDerived(time.Now())
+ writeJSON(w, http.StatusOK, m)
+}
+
+func (s *Server) deleteMaintenance(w http.ResponseWriter, r *http.Request) {
+ var existing maintenanceRecord
+ if err := s.pb.GetOne(r.Context(), colMaintenance, 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(), colMaintenance, r.PathValue("id")); err != nil {
+ writePBError(w, err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func applyMaintenanceDefaults(m *models.MaintenanceEntry) {
+ if m.Type == "" {
+ m.Type = "repair"
+ }
+ if m.Status == "" {
+ m.Status = "completed"
+ }
+}
+
+func validateMaintenance(m models.MaintenanceEntry) string {
+ switch {
+ case m.Car == "":
+ return "car is required"
+ case m.Date.IsZero():
+ return "date is required"
+ case m.Description == "":
+ return "description is required"
+ case !maintenanceTypes[m.Type]:
+ return "type must be one of: repair, inspection, bodywork, tyres, diagnostics, recall, warranty, other"
+ case !maintenanceStatuses[m.Status]:
+ return "status must be one of: scheduled, in_progress, completed"
+ case m.LaborCost < 0 || m.PartsCost < 0:
+ return "costs cannot be negative"
+ }
+ return ""
+}
diff --git a/API Server/internal/api/records.go b/API Server/internal/api/records.go
index 67f3519..641ea20 100644
--- a/API Server/internal/api/records.go
+++ b/API Server/internal/api/records.go
@@ -190,3 +190,238 @@ func partPayload(p models.Part) map[string]any {
"category": p.Category,
}
}
+
+// parsePBDatePtr is parsePBDate for optional dates: a blank or unparseable
+// value yields nil rather than the zero time, so "no expiry" stays
+// distinguishable from "expired in year zero".
+func parsePBDatePtr(s string) *time.Time {
+ t := parsePBDate(s)
+ if t.IsZero() {
+ return nil
+ }
+ return &t
+}
+
+// formatPBDatePtr is formatPBDate for optional dates; nil writes an empty value,
+// which is how PocketBase stores "unset".
+func formatPBDatePtr(t *time.Time) string {
+ if t == nil {
+ return ""
+ }
+ return formatPBDate(*t)
+}
+
+// --- fuel entries ---
+
+type fuelRecord struct {
+ ID string `json:"id"`
+ Car string `json:"car"`
+ Date string `json:"date"`
+ Km int `json:"km"`
+ Liters float64 `json:"liters"`
+ Cost float64 `json:"cost"`
+ FullTank bool `json:"full_tank"`
+ MissedFill bool `json:"missed_fill"`
+ Station string `json:"station"`
+ Notes string `json:"notes"`
+ Created string `json:"created"`
+ Updated string `json:"updated"`
+}
+
+func (rec fuelRecord) toModel() models.FuelEntry {
+ return models.FuelEntry{
+ ID: rec.ID,
+ Car: rec.Car,
+ Date: parsePBDate(rec.Date),
+ Km: rec.Km,
+ Liters: rec.Liters,
+ Cost: rec.Cost,
+ FullTank: rec.FullTank,
+ MissedFill: rec.MissedFill,
+ Station: rec.Station,
+ Notes: rec.Notes,
+ Created: rec.Created,
+ Updated: rec.Updated,
+ }
+}
+
+func fuelPayload(f models.FuelEntry) map[string]any {
+ return map[string]any{
+ "car": f.Car,
+ "date": formatPBDate(f.Date),
+ "km": f.Km,
+ "liters": f.Liters,
+ "cost": f.Cost,
+ "full_tank": f.FullTank,
+ "missed_fill": f.MissedFill,
+ "station": f.Station,
+ "notes": f.Notes,
+ }
+}
+
+// --- maintenance entries ---
+
+type maintenanceRecord struct {
+ ID string `json:"id"`
+ Car string `json:"car"`
+ Date string `json:"date"`
+ Km int `json:"km"`
+ Type string `json:"type"`
+ Status string `json:"status"`
+ Workshop string `json:"workshop"`
+ Location string `json:"location"`
+ Description string `json:"description"`
+ PartsUsed string `json:"parts_used"`
+ LaborCost float64 `json:"labor_cost"`
+ PartsCost float64 `json:"parts_cost"`
+ InvoiceNumber string `json:"invoice_number"`
+ WarrantyUntil string `json:"warranty_until"`
+ Notes string `json:"notes"`
+ Created string `json:"created"`
+ Updated string `json:"updated"`
+}
+
+func (rec maintenanceRecord) toModel() models.MaintenanceEntry {
+ return models.MaintenanceEntry{
+ ID: rec.ID,
+ Car: rec.Car,
+ Date: parsePBDate(rec.Date),
+ Km: rec.Km,
+ Type: rec.Type,
+ Status: rec.Status,
+ Workshop: rec.Workshop,
+ Location: rec.Location,
+ Description: rec.Description,
+ PartsUsed: rec.PartsUsed,
+ LaborCost: rec.LaborCost,
+ PartsCost: rec.PartsCost,
+ InvoiceNumber: rec.InvoiceNumber,
+ WarrantyUntil: parsePBDatePtr(rec.WarrantyUntil),
+ Notes: rec.Notes,
+ Created: rec.Created,
+ Updated: rec.Updated,
+ }
+}
+
+func maintenancePayload(m models.MaintenanceEntry) map[string]any {
+ return map[string]any{
+ "car": m.Car,
+ "date": formatPBDate(m.Date),
+ "km": m.Km,
+ "type": m.Type,
+ "status": m.Status,
+ "workshop": m.Workshop,
+ "location": m.Location,
+ "description": m.Description,
+ "parts_used": m.PartsUsed,
+ "labor_cost": m.LaborCost,
+ "parts_cost": m.PartsCost,
+ "invoice_number": m.InvoiceNumber,
+ "warranty_until": formatPBDatePtr(m.WarrantyUntil),
+ "notes": m.Notes,
+ }
+}
+
+// --- car documents ---
+
+type documentRecord struct {
+ ID string `json:"id"`
+ Car string `json:"car"`
+ Type string `json:"type"`
+ Title string `json:"title"`
+ Provider string `json:"provider"`
+ Reference string `json:"reference"`
+ IssueDate string `json:"issue_date"`
+ ExpiryDate string `json:"expiry_date"`
+ Cost float64 `json:"cost"`
+ Notes string `json:"notes"`
+ File string `json:"file"`
+ Created string `json:"created"`
+ Updated string `json:"updated"`
+}
+
+func (rec documentRecord) toModel() models.CarDocument {
+ return models.CarDocument{
+ ID: rec.ID,
+ Car: rec.Car,
+ Type: rec.Type,
+ Title: rec.Title,
+ Provider: rec.Provider,
+ Reference: rec.Reference,
+ IssueDate: parsePBDatePtr(rec.IssueDate),
+ ExpiryDate: parsePBDatePtr(rec.ExpiryDate),
+ Cost: rec.Cost,
+ Notes: rec.Notes,
+ FileName: rec.File,
+ HasFile: rec.File != "",
+ Created: rec.Created,
+ Updated: rec.Updated,
+ }
+}
+
+// documentPayload omits the file field: attachments move over multipart, never
+// as JSON, so a metadata write must not blank an existing upload.
+func documentPayload(d models.CarDocument) map[string]any {
+ return map[string]any{
+ "car": d.Car,
+ "type": d.Type,
+ "title": d.Title,
+ "provider": d.Provider,
+ "reference": d.Reference,
+ "issue_date": formatPBDatePtr(d.IssueDate),
+ "expiry_date": formatPBDatePtr(d.ExpiryDate),
+ "cost": d.Cost,
+ "notes": d.Notes,
+ }
+}
+
+// --- reminders ---
+
+type reminderRecord struct {
+ ID string `json:"id"`
+ Car string `json:"car"`
+ Title string `json:"title"`
+ Type string `json:"type"`
+ DueDate string `json:"due_date"`
+ DueKm int `json:"due_km"`
+ RepeatDays int `json:"repeat_days"`
+ RepeatKm int `json:"repeat_km"`
+ Done bool `json:"done"`
+ DoneAt string `json:"done_at"`
+ Notes string `json:"notes"`
+ Created string `json:"created"`
+ Updated string `json:"updated"`
+}
+
+func (rec reminderRecord) toModel() models.Reminder {
+ return models.Reminder{
+ ID: rec.ID,
+ Car: rec.Car,
+ Title: rec.Title,
+ Type: rec.Type,
+ DueDate: parsePBDatePtr(rec.DueDate),
+ DueKm: rec.DueKm,
+ RepeatDays: rec.RepeatDays,
+ RepeatKm: rec.RepeatKm,
+ Done: rec.Done,
+ DoneAt: parsePBDatePtr(rec.DoneAt),
+ Notes: rec.Notes,
+ Created: rec.Created,
+ Updated: rec.Updated,
+ }
+}
+
+func reminderPayload(r models.Reminder) map[string]any {
+ return map[string]any{
+ "car": r.Car,
+ "title": r.Title,
+ "type": r.Type,
+ "due_date": formatPBDatePtr(r.DueDate),
+ "due_km": r.DueKm,
+ "repeat_days": r.RepeatDays,
+ "repeat_km": r.RepeatKm,
+ "done": r.Done,
+ "done_at": formatPBDatePtr(r.DoneAt),
+ "notes": r.Notes,
+ }
+}
diff --git a/API Server/internal/api/reminders.go b/API Server/internal/api/reminders.go
new file mode 100644
index 0000000..4d70342
--- /dev/null
+++ b/API Server/internal/api/reminders.go
@@ -0,0 +1,405 @@
+package api
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+ "time"
+
+ "drivervault/apiserver/internal/models"
+)
+
+// Reminders: what the user wants to be told about, and when.
+//
+// A reminder fires on a date, an odometer reading, or both (whichever arrives
+// first). Two kinds are returned side by side:
+//
+// - Stored reminders, which the user creates and completes.
+// - Auto-derived reminders, synthesised on read from data the user has
+// already entered — a document's expiry date, the next service due from the
+// latest service record. These are read-only and carry no row of their own,
+// so a renewal date never has to be typed twice and can never drift out of
+// step with the document it came from. Their ids are namespaced ("auto:…")
+// and every write endpoint rejects them.
+
+// autoPrefix marks a reminder id as derived rather than stored.
+const autoPrefix = "auto:"
+
+var reminderTypes = map[string]bool{
+ "maintenance": true,
+ "document": true,
+ "service": true,
+ "inspection": true,
+ "other": true,
+}
+
+// documentTypeLabels give auto-derived document reminders a title that reads
+// like something a person would write.
+var documentTypeLabels = map[string]string{
+ "insurance": "Insurance",
+ "pollution": "Pollution certificate",
+ "registration": "Registration",
+ "inspection": "Inspection",
+ "roadTax": "Road tax",
+ "warranty": "Warranty",
+ "other": "Document",
+}
+
+// listCarReminders serves GET /api/cars/{id}/reminders.
+func (s *Server) listCarReminders(w http.ResponseWriter, r *http.Request) {
+ carID := r.PathValue("id")
+ if !s.requireCarAccess(w, r, carID, accessRead) {
+ return
+ }
+ s.respondReminderList(w, r, carID)
+}
+
+// listReminders serves GET /api/reminders?car={id}.
+func (s *Server) listReminders(w http.ResponseWriter, r *http.Request) {
+ carID := r.URL.Query().Get("car")
+ if !s.requireCarAccess(w, r, carID, accessRead) {
+ return
+ }
+ s.respondReminderList(w, r, carID)
+}
+
+func (s *Server) respondReminderList(w http.ResponseWriter, r *http.Request, carID string) {
+ car, err := s.carModel(r.Context(), carID)
+ if err != nil {
+ writePBError(w, err)
+ return
+ }
+
+ stored, err := s.fetchReminders(r, carID, car.CurrentKm)
+ if err != nil {
+ writePBError(w, err)
+ return
+ }
+ // A failure to derive extras must not take down the user's own reminders,
+ // which are the part they actually rely on.
+ out := append(stored, s.autoReminders(r, car)...)
+
+ now := time.Now()
+ sortReminders(out, now)
+ writeJSON(w, http.StatusOK, out)
+}
+
+// fetchReminders loads a car's stored reminders with their status resolved
+// against today and the car's odometer.
+func (s *Server) fetchReminders(r *http.Request, carID string, currentKm int) ([]models.Reminder, error) {
+ res, err := s.pb.List(r.Context(), colReminders, url.Values{
+ "filter": {fmt.Sprintf("car='%s'", carID)},
+ "perPage": {"500"},
+ })
+ if err != nil {
+ return nil, err
+ }
+ var recs []reminderRecord
+ if err := json.Unmarshal(res.Items, &recs); err != nil {
+ return nil, err
+ }
+ now := time.Now()
+ out := make([]models.Reminder, 0, len(recs))
+ for _, rec := range recs {
+ m := rec.toModel()
+ m.ComputeReminderDerived(now, currentKm)
+ out = append(out, m)
+ }
+ return out, nil
+}
+
+// autoReminders synthesises the read-only reminders implied by a car's
+// documents and its service schedule. Errors are swallowed: these are a
+// convenience layered on top of the stored list, and losing them is better than
+// failing the request.
+func (s *Server) autoReminders(r *http.Request, car *models.Car) []models.Reminder {
+ now := time.Now()
+ out := []models.Reminder{}
+
+ // One per document that has a renewal date.
+ if docs, err := s.fetchDocuments(r, car.ID); err == nil {
+ for _, d := range docs {
+ if d.ExpiryDate == nil {
+ continue
+ }
+ label := documentTypeLabels[d.Type]
+ if label == "" {
+ label = "Document"
+ }
+ rem := models.Reminder{
+ ID: autoPrefix + "doc:" + d.ID,
+ Car: car.ID,
+ Title: label + " renewal — " + d.Title,
+ Type: "document",
+ DueDate: d.ExpiryDate,
+ Auto: true,
+ SourceRef: d.ID,
+ }
+ if d.Provider != "" {
+ rem.Notes = d.Provider
+ }
+ rem.ComputeReminderDerived(now, car.CurrentKm)
+ out = append(out, rem)
+ }
+ }
+
+ // One for the next service due, from the most recent service record.
+ if latest, err := s.latestServiceRecord(r, car); err == nil && latest != nil {
+ rem := models.Reminder{
+ ID: autoPrefix + "service:" + latest.ID,
+ Car: car.ID,
+ Title: "Service due",
+ Type: "service",
+ DueDate: latest.NextServiceDate,
+ Auto: true,
+ SourceRef: latest.ID,
+ Notes: fmt.Sprintf("Based on the service on %s",
+ latest.Date.Format("2006-01-02")),
+ }
+ if latest.NextServiceKm != nil {
+ rem.DueKm = *latest.NextServiceKm
+ }
+ if rem.DueDate != nil || rem.DueKm > 0 {
+ rem.ComputeReminderDerived(now, car.CurrentKm)
+ out = append(out, rem)
+ }
+ }
+
+ return out
+}
+
+// latestServiceRecord returns the car's most recent service record with its
+// next-due fields computed, or nil when the car has no service history.
+func (s *Server) latestServiceRecord(r *http.Request, car *models.Car) (*models.ServiceRecord, error) {
+ res, err := s.pb.List(r.Context(), colServices, url.Values{
+ "filter": {fmt.Sprintf("car='%s'", car.ID)},
+ "sort": {"-date"},
+ "perPage": {"1"},
+ })
+ if err != nil {
+ return nil, err
+ }
+ var recs []serviceRecord
+ if err := json.Unmarshal(res.Items, &recs); err != nil {
+ return nil, err
+ }
+ if len(recs) == 0 {
+ return nil, nil
+ }
+ m := recs[0].toModel()
+ m.ComputeDerived(car)
+ return &m, nil
+}
+
+// sortReminders orders the list the way it needs to be acted on: everything
+// outstanding first, soonest deadline at the top, with completed reminders
+// pushed to the bottom.
+func sortReminders(rs []models.Reminder, now time.Time) {
+ sort.SliceStable(rs, func(i, j int) bool {
+ a, b := rs[i], rs[j]
+ if a.Done != b.Done {
+ return !a.Done
+ }
+ ad, bd := a.DueDate != nil, b.DueDate != nil
+ if ad != bd {
+ return ad // dated reminders before open-ended ones
+ }
+ if ad && bd && !a.DueDate.Equal(*b.DueDate) {
+ return a.DueDate.Before(*b.DueDate)
+ }
+ return a.Title < b.Title
+ })
+}
+
+func (s *Server) getReminder(w http.ResponseWriter, r *http.Request) {
+ rec, ok := s.loadStoredReminder(w, r, accessRead)
+ if !ok {
+ return
+ }
+ car, err := s.carModel(r.Context(), rec.Car)
+ if err != nil {
+ writePBError(w, err)
+ return
+ }
+ m := rec.toModel()
+ m.ComputeReminderDerived(time.Now(), car.CurrentKm)
+ writeJSON(w, http.StatusOK, m)
+}
+
+func (s *Server) createReminder(w http.ResponseWriter, r *http.Request) {
+ var in models.Reminder
+ if err := decodeJSON(r, &in); err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ if in.Type == "" {
+ in.Type = "other"
+ }
+ if msg := validateReminder(in); msg != "" {
+ writeError(w, http.StatusBadRequest, msg)
+ return
+ }
+ if !s.requireCarAccess(w, r, in.Car, accessWrite) {
+ return
+ }
+ var rec reminderRecord
+ if err := s.pb.Create(r.Context(), colReminders, reminderPayload(in), &rec); err != nil {
+ writePBError(w, err)
+ return
+ }
+ s.respondReminder(w, r, rec)
+}
+
+func (s *Server) updateReminder(w http.ResponseWriter, r *http.Request) {
+ var in models.Reminder
+ if err := decodeJSON(r, &in); err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ existing, ok := s.loadStoredReminder(w, r, accessWrite)
+ if !ok {
+ return
+ }
+ in.Car = existing.Car // the reminder's car is not reassignable via PATCH
+ if in.Type == "" {
+ in.Type = "other"
+ }
+ if msg := validateReminder(in); msg != "" {
+ writeError(w, http.StatusBadRequest, msg)
+ return
+ }
+ // Completing via PATCH should still stamp when it happened, matching what
+ // the complete endpoint records.
+ if in.Done && in.DoneAt == nil {
+ now := time.Now()
+ in.DoneAt = &now
+ }
+ if !in.Done {
+ in.DoneAt = nil
+ }
+ var rec reminderRecord
+ if err := s.pb.Update(r.Context(), colReminders, existing.ID, reminderPayload(in), &rec); err != nil {
+ writePBError(w, err)
+ return
+ }
+ s.respondReminder(w, r, rec)
+}
+
+// handleCompleteReminder serves POST /api/reminders/{id}/complete.
+//
+// A one-off reminder is simply closed. A recurring one (repeatDays/repeatKm)
+// instead rolls its triggers forward and stays open — the next oil change is
+// due a year after this one was done, not a year after it was first scheduled,
+// so the roll is measured from completion.
+func (s *Server) handleCompleteReminder(w http.ResponseWriter, r *http.Request) {
+ existing, ok := s.loadStoredReminder(w, r, accessWrite)
+ if !ok {
+ return
+ }
+ m := existing.toModel()
+ now := time.Now()
+
+ payload := map[string]any{}
+ if m.RepeatDays > 0 || m.RepeatKm > 0 {
+ if m.RepeatDays > 0 {
+ next := now.AddDate(0, 0, m.RepeatDays)
+ payload["due_date"] = formatPBDate(next)
+ }
+ if m.RepeatKm > 0 {
+ car, err := s.carModel(r.Context(), m.Car)
+ if err != nil {
+ writePBError(w, err)
+ return
+ }
+ // Roll from where the car actually is: the work was done now, so the
+ // next one is due RepeatKm from this reading, whether it was done
+ // early or late. Rolling from the old target instead would let an
+ // early completion drift the schedule forward for good. The target is
+ // only a fallback for a car whose odometer is untracked (0), where
+ // rolling from zero would put the next due date in the past.
+ base := car.CurrentKm
+ if base <= 0 {
+ base = m.DueKm
+ }
+ payload["due_km"] = base + m.RepeatKm
+ }
+ payload["done"] = false
+ payload["done_at"] = ""
+ } else {
+ payload["done"] = true
+ payload["done_at"] = formatPBDate(now)
+ }
+
+ var rec reminderRecord
+ if err := s.pb.Update(r.Context(), colReminders, existing.ID, payload, &rec); err != nil {
+ writePBError(w, err)
+ return
+ }
+ s.respondReminder(w, r, rec)
+}
+
+func (s *Server) deleteReminder(w http.ResponseWriter, r *http.Request) {
+ existing, ok := s.loadStoredReminder(w, r, accessWrite)
+ if !ok {
+ return
+ }
+ if err := s.pb.Delete(r.Context(), colReminders, existing.ID); err != nil {
+ writePBError(w, err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// loadStoredReminder fetches the {id} reminder and checks car access at `need`.
+// Auto-derived ids are rejected up front: they have no row behind them, so any
+// write against one is a client bug rather than a 404 from PocketBase.
+func (s *Server) loadStoredReminder(w http.ResponseWriter, r *http.Request, need string) (reminderRecord, bool) {
+ id := r.PathValue("id")
+ if strings.HasPrefix(id, autoPrefix) {
+ writeError(w, http.StatusBadRequest,
+ "this reminder is derived from a document or service record — edit that instead")
+ return reminderRecord{}, false
+ }
+ var rec reminderRecord
+ if err := s.pb.GetOne(r.Context(), colReminders, id, &rec); err != nil {
+ writePBError(w, err)
+ return reminderRecord{}, false
+ }
+ if !s.requireCarAccess(w, r, rec.Car, need) {
+ return reminderRecord{}, false
+ }
+ return rec, true
+}
+
+// respondReminder writes a stored reminder with its derived status resolved.
+func (s *Server) respondReminder(w http.ResponseWriter, r *http.Request, rec reminderRecord) {
+ m := rec.toModel()
+ currentKm := 0
+ if car, err := s.carModel(r.Context(), rec.Car); err == nil {
+ currentKm = car.CurrentKm
+ }
+ m.ComputeReminderDerived(time.Now(), currentKm)
+ writeJSON(w, http.StatusOK, m)
+}
+
+func validateReminder(rm models.Reminder) string {
+ switch {
+ case rm.Car == "":
+ return "car is required"
+ case strings.TrimSpace(rm.Title) == "":
+ return "title is required"
+ case !reminderTypes[rm.Type]:
+ return "type must be one of: maintenance, document, service, inspection, other"
+ case rm.DueKm < 0 || rm.RepeatDays < 0 || rm.RepeatKm < 0:
+ return "due km and repeat values cannot be negative"
+ }
+ // A reminder with neither trigger would never come due, which is not what
+ // anyone means by "remind me".
+ if (rm.DueDate == nil || rm.DueDate.IsZero()) && rm.DueKm == 0 {
+ return "set a due date, a due odometer reading, or both"
+ }
+ return ""
+}
diff --git a/API Server/internal/api/server.go b/API Server/internal/api/server.go
index bae0f22..72b27fe 100644
--- a/API Server/internal/api/server.go
+++ b/API Server/internal/api/server.go
@@ -53,6 +53,34 @@
// DELETE /api/service-records/{id}
// GET /api/parts POST /api/parts
// GET /api/parts/{id} PATCH /api/parts/{id} DELETE /api/parts/{id}
+//
+// # fuel tracking (efficiency is derived on read, never stored)
+// GET /api/cars/{id}/fuel-entries
+// GET /api/cars/{id}/fuel-stats
+// GET /api/fuel-entries POST /api/fuel-entries
+// GET /api/fuel-entries/{id} PATCH /api/fuel-entries/{id}
+// DELETE /api/fuel-entries/{id}
+//
+// # maintenance log (workshop visits + repairs; distinct from service records)
+// GET /api/cars/{id}/maintenance
+// GET /api/maintenance POST /api/maintenance
+// GET /api/maintenance/{id} PATCH /api/maintenance/{id}
+// DELETE /api/maintenance/{id}
+//
+// # document tracking (insurance, pollution certs, … + renewal dates)
+// GET /api/cars/{id}/documents
+// GET /api/car-documents POST /api/car-documents
+// GET /api/car-documents/{id} PATCH /api/car-documents/{id}
+// DELETE /api/car-documents/{id}
+// POST /api/car-documents/{id}/file
+// GET /api/car-documents/{id}/file
+// DELETE /api/car-documents/{id}/file
+//
+// # reminders (stored + auto-derived from documents and service records)
+// GET /api/cars/{id}/reminders
+// GET /api/reminders POST /api/reminders
+// GET /api/reminders/{id} PATCH /api/reminders/{id}
+// DELETE /api/reminders/{id} POST /api/reminders/{id}/complete
package api
import (
@@ -70,11 +98,15 @@ import (
// PocketBase collection names.
const (
- colCars = "cars"
- colServices = "service_records"
- colParts = "parts"
- colShares = "car_shares"
- colOrgs = "organizations"
+ colCars = "cars"
+ colServices = "service_records"
+ 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.
@@ -207,6 +239,11 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("DELETE /api/cars/{id}", s.deleteCar)
mux.HandleFunc("GET /api/cars/{id}/service-records", s.listCarServiceRecords)
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)
+ mux.HandleFunc("GET /api/cars/{id}/maintenance", s.listCarMaintenance)
+ mux.HandleFunc("GET /api/cars/{id}/documents", s.listCarDocuments)
+ mux.HandleFunc("GET /api/cars/{id}/reminders", s.listCarReminders)
mux.HandleFunc("GET /api/cars/{id}/shares", s.handleListShares)
mux.HandleFunc("POST /api/cars/{id}/shares", s.handleUpsertShare)
mux.HandleFunc("DELETE /api/cars/{id}/shares/{userId}", s.handleDeleteShare)
@@ -225,6 +262,39 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("PATCH /api/parts/{id}", s.updatePart)
mux.HandleFunc("DELETE /api/parts/{id}", s.deletePart)
+ // Fuel entries.
+ mux.HandleFunc("GET /api/fuel-entries", s.listFuelEntries)
+ mux.HandleFunc("POST /api/fuel-entries", s.createFuelEntry)
+ mux.HandleFunc("GET /api/fuel-entries/{id}", s.getFuelEntry)
+ mux.HandleFunc("PATCH /api/fuel-entries/{id}", s.updateFuelEntry)
+ mux.HandleFunc("DELETE /api/fuel-entries/{id}", s.deleteFuelEntry)
+
+ // Maintenance log.
+ mux.HandleFunc("GET /api/maintenance", s.listMaintenance)
+ mux.HandleFunc("POST /api/maintenance", s.createMaintenance)
+ mux.HandleFunc("GET /api/maintenance/{id}", s.getMaintenance)
+ mux.HandleFunc("PATCH /api/maintenance/{id}", s.updateMaintenance)
+ mux.HandleFunc("DELETE /api/maintenance/{id}", s.deleteMaintenance)
+
+ // Documents. Named /api/car-documents so the path can't be mistaken for the
+ // user-facing account documents some other Vault services expose.
+ mux.HandleFunc("GET /api/car-documents", s.listDocuments)
+ mux.HandleFunc("POST /api/car-documents", s.createDocument)
+ mux.HandleFunc("GET /api/car-documents/{id}", s.getDocument)
+ mux.HandleFunc("PATCH /api/car-documents/{id}", s.updateDocument)
+ mux.HandleFunc("DELETE /api/car-documents/{id}", s.deleteDocument)
+ mux.HandleFunc("POST /api/car-documents/{id}/file", s.handleUploadDocumentFile)
+ mux.HandleFunc("GET /api/car-documents/{id}/file", s.handleGetDocumentFile)
+ mux.HandleFunc("DELETE /api/car-documents/{id}/file", s.handleDeleteDocumentFile)
+
+ // Reminders.
+ mux.HandleFunc("GET /api/reminders", s.listReminders)
+ mux.HandleFunc("POST /api/reminders", s.createReminder)
+ mux.HandleFunc("GET /api/reminders/{id}", s.getReminder)
+ mux.HandleFunc("PATCH /api/reminders/{id}", s.updateReminder)
+ mux.HandleFunc("DELETE /api/reminders/{id}", s.deleteReminder)
+ mux.HandleFunc("POST /api/reminders/{id}/complete", s.handleCompleteReminder)
+
return s.withMiddleware(mux)
}
diff --git a/API Server/internal/models/models.go b/API Server/internal/models/models.go
index 310392f..be84b36 100644
--- a/API Server/internal/models/models.go
+++ b/API Server/internal/models/models.go
@@ -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)
+}
diff --git a/API Server/scripts/setup-pocketbase.mjs b/API Server/scripts/setup-pocketbase.mjs
index 2313a75..77dfb32 100644
--- a/API Server/scripts/setup-pocketbase.mjs
+++ b/API Server/scripts/setup-pocketbase.mjs
@@ -1,9 +1,12 @@
// Idempotent PocketBase schema setup for the Car Control project.
//
-// Creates three collections — cars, service_records, parts — matching the
-// original "Car Service.xlsx". Access rules are left admin-only (null) on
-// purpose: every client goes through the API Server, which authenticates as a
-// superuser, so the database is never exposed directly.
+// Creates the collections behind the app: cars, service_records and parts (which
+// match the original "Car Service.xlsx"), the sharing/tenancy tables, and the
+// fuel, maintenance, document and reminder logs layered on top. Access rules are
+// left admin-only (null) on purpose: every client goes through the API Server,
+// which authenticates as a superuser, so the database is never exposed directly
+// — including document attachments, which are proxied by the API rather than
+// served as public file URLs.
//
// Usage (PowerShell):
// $env:PB_URL="http://10.2.1.10:8027"
@@ -70,6 +73,8 @@ const F = {
relation: (name, relTo, required = false, cascadeDelete = true) => ({ name, type: "relation", required, relTo, cascadeDelete }),
select: (name, values, required = false) => ({ name, type: "select", required, values }),
autodate: (name, onCreate = false, onUpdate = false) => ({ name, type: "autodate", required: false, onCreate, onUpdate }),
+ // Single-file attachment. maxSize is in bytes; mimeTypes [] means "any".
+ file: (name, maxSize, mimeTypes = []) => ({ name, type: "file", required: false, maxSize, mimeTypes }),
};
function renderField(def, format, idByName) {
@@ -86,6 +91,11 @@ function renderField(def, format, idByName) {
options.values = def.values;
options.maxSelect = 1;
}
+ if (def.type === "file") {
+ options.maxSelect = 1;
+ options.maxSize = def.maxSize;
+ options.mimeTypes = def.mimeTypes || [];
+ }
return { name: def.name, type: def.type, required: def.required, options };
}
// Modern: options flattened onto the field.
@@ -104,6 +114,11 @@ function renderField(def, format, idByName) {
field.onCreate = def.onCreate;
field.onUpdate = def.onUpdate;
}
+ if (def.type === "file") {
+ field.maxSelect = 1;
+ field.maxSize = def.maxSize;
+ field.mimeTypes = def.mimeTypes || [];
+ }
return field;
}
@@ -238,6 +253,81 @@ const DESIRED = {
F.text("part_number"),
F.text("category"),
],
+ // Fuel refills. Consumption is NOT stored — the API derives it from the whole
+ // history on read (models.ComputeFuelDerived), so correcting an old fill fixes
+ // every figure it affects with no rows to migrate.
+ fuel_entries: [
+ F.relation("car", "cars", true),
+ F.date("date", true),
+ F.number("km"), // odometer at the pump
+ F.number("liters"),
+ F.number("cost"),
+ // Filled to the brim — the reference point efficiency is measured between.
+ F.bool("full_tank"),
+ // A refill happened before this one without being logged, so any window
+ // containing it is left uncomputed rather than reported as implausibly good.
+ F.bool("missed_fill"),
+ F.text("station"),
+ F.text("notes"),
+ ],
+ // 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/one-off garage work with a labour bill.
+ maintenance_entries: [
+ F.relation("car", "cars", true),
+ F.date("date", true),
+ F.number("km"),
+ F.select("type", ["repair", "inspection", "bodywork", "tyres", "diagnostics", "recall", "warranty", "other"]),
+ F.select("status", ["scheduled", "in_progress", "completed"]),
+ F.text("workshop"),
+ F.text("location"),
+ F.text("description"),
+ F.text("parts_used"),
+ F.number("labor_cost"),
+ F.number("parts_cost"),
+ F.text("invoice_number"),
+ F.date("warranty_until"),
+ F.text("notes"),
+ ],
+ // Insurance, pollution certificates, registration papers … The expiry date is
+ // the point of the record: it drives the renewal status badges and the
+ // auto-derived reminders. Blank expiry = never expires.
+ car_documents: [
+ F.relation("car", "cars", true),
+ F.select("type", ["insurance", "pollution", "registration", "inspection", "roadTax", "warranty", "other"]),
+ F.text("title", true),
+ F.text("provider"),
+ F.text("reference"),
+ F.date("issue_date"),
+ F.date("expiry_date"),
+ F.number("cost"),
+ F.text("notes"),
+ // The scan/PDF. 10MB cap, matching maxDocumentUpload in the API server.
+ // Reached only via the API's own file endpoint, never as a public URL.
+ F.file("file", 10485760, [
+ "application/pdf",
+ "image/jpeg",
+ "image/png",
+ "image/webp",
+ "image/heic",
+ ]),
+ ],
+ // User-set reminders. The API additionally synthesises read-only ones from
+ // document expiry dates and the next service due — those are derived on read
+ // and have no rows here.
+ reminders: [
+ F.relation("car", "cars", true),
+ F.text("title", true),
+ F.select("type", ["maintenance", "document", "service", "inspection", "other"]),
+ F.date("due_date"),
+ F.number("due_km"),
+ // Non-zero => recurring: completing rolls the trigger forward by this much.
+ F.number("repeat_days"),
+ F.number("repeat_km"),
+ F.bool("done"),
+ F.date("done_at"),
+ F.text("notes"),
+ ],
// Per-car sharing grants. One row = "this user may access this car" at the
// given permission. Cascades on both relations so grants disappear when
// either the car or the user is deleted. (Owner access is NOT stored here —
@@ -276,6 +366,12 @@ const DESIRED = {
// unique so the API can rely on PocketBase rejecting a duplicate.
const INDEXES = {
organizations: ["CREATE UNIQUE INDEX `idx_organizations_name` ON `organizations` (`name`)"],
+ // Every read of these is "…for this car", and the fuel history is walked in
+ // odometer order to build its efficiency windows.
+ fuel_entries: ["CREATE INDEX `idx_fuel_entries_car_km` ON `fuel_entries` (`car`, `km`)"],
+ 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`)"],
};
async function main() {
@@ -293,7 +389,17 @@ async function main() {
// Create in dependency order (organizations before users references it; cars
// before its relations; "users" already exists as PocketBase's built-in auth
// collection, so it's never created here — only reconciled below).
- for (const name of ["organizations", "cars", "service_records", "parts", "car_shares"]) {
+ for (const name of [
+ "organizations",
+ "cars",
+ "service_records",
+ "parts",
+ "car_shares",
+ "fuel_entries",
+ "maintenance_entries",
+ "car_documents",
+ "reminders",
+ ]) {
if (collections.some((c) => c.name === name)) continue;
await createCollection(token, name, DESIRED[name], format, idByName);
console.log(`✓ ${name} — created`);
@@ -305,12 +411,24 @@ async function main() {
// Reconcile fields on existing collections (add missing + fix relation options
// and select values — this is what grows users.role to include "superadmin"
// and adds users.organization on an existing deployment).
- for (const name of ["organizations", "users", "cars", "service_records", "parts", "car_shares"]) {
+ for (const name of [
+ "organizations",
+ "users",
+ "cars",
+ "service_records",
+ "parts",
+ "car_shares",
+ "fuel_entries",
+ "maintenance_entries",
+ "car_documents",
+ "reminders",
+ ]) {
await reconcileFields(token, name, DESIRED[name], format, idByName);
}
console.log(
- "\nDone. Collections ready: organizations, users, cars, service_records, parts, car_shares.",
+ "\nDone. Collections ready: organizations, users, cars, service_records, parts,\n" +
+ "car_shares, fuel_entries, maintenance_entries, car_documents, reminders.",
);
console.log(
"Note: the legacy `sessions` collection is no longer used (auth moved to PocketBase\n" +
diff --git a/Web App/web/src/api.js b/Web App/web/src/api.js
index 2d66cbe..a9c6c17 100644
--- a/Web App/web/src/api.js
+++ b/Web App/web/src/api.js
@@ -123,6 +123,47 @@ export const api = {
request(`/parts/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
deletePart: (id) => request(`/parts/${id}`, { method: "DELETE" }),
+ // Fuel. Consumption figures on each entry, and the rollup from fuel-stats, are
+ // derived server-side from the full history — nothing here is stored.
+ listCarFuel: (carId) => request(`/cars/${carId}/fuel-entries`),
+ getCarFuelStats: (carId) => request(`/cars/${carId}/fuel-stats`),
+ createFuel: (body) => request("/fuel-entries", { method: "POST", body: JSON.stringify(body) }),
+ updateFuel: (id, body) =>
+ request(`/fuel-entries/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
+ deleteFuel: (id) => request(`/fuel-entries/${id}`, { method: "DELETE" }),
+
+ // Maintenance log — workshop visits and repairs (not the service schedule).
+ listCarMaintenance: (carId) => request(`/cars/${carId}/maintenance`),
+ createMaintenance: (body) => request("/maintenance", { method: "POST", body: JSON.stringify(body) }),
+ updateMaintenance: (id, body) =>
+ request(`/maintenance/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
+ deleteMaintenance: (id) => request(`/maintenance/${id}`, { method: "DELETE" }),
+
+ // Documents — insurance, pollution certificates, … with renewal dates.
+ listCarDocuments: (carId) => request(`/cars/${carId}/documents`),
+ createDocument: (body) => request("/car-documents", { method: "POST", body: JSON.stringify(body) }),
+ updateDocument: (id, body) =>
+ request(`/car-documents/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
+ deleteDocument: (id) => request(`/car-documents/${id}`, { method: "DELETE" }),
+ // The attachment is proxied by the API (PocketBase files aren't public), so it
+ // needs the auth header — hence a Blob fetch rather than a plain link.
+ uploadDocumentFile: (id, file) => {
+ const form = new FormData();
+ form.append("file", file);
+ return requestForm(`/car-documents/${id}/file`, { method: "POST", body: form });
+ },
+ getDocumentFileBlob: (id) => requestBlob(`/car-documents/${id}/file`),
+ deleteDocumentFile: (id) => request(`/car-documents/${id}/file`, { method: "DELETE" }),
+
+ // Reminders. The list mixes stored reminders with read-only ones derived from
+ // documents and service records (flagged `auto`; their ids start with "auto:").
+ listCarReminders: (carId) => request(`/cars/${carId}/reminders`),
+ createReminder: (body) => request("/reminders", { method: "POST", body: JSON.stringify(body) }),
+ updateReminder: (id, body) =>
+ request(`/reminders/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
+ deleteReminder: (id) => request(`/reminders/${id}`, { method: "DELETE" }),
+ completeReminder: (id) => request(`/reminders/${id}/complete`, { method: "POST" }),
+
// Admin — user management (admin or superadmin). Admins are scoped by the
// server to their own organization; superadmins see everyone.
listUsers: () => request("/users").then((r) => r.users),
diff --git a/Web App/web/src/components/DocumentFormModal.vue b/Web App/web/src/components/DocumentFormModal.vue
new file mode 100644
index 0000000..d726ce9
--- /dev/null
+++ b/Web App/web/src/components/DocumentFormModal.vue
@@ -0,0 +1,166 @@
+
+
+
+ {{ error }} {{ error }} {{ error }} {{ error }}
Workshop visits and repairs. Routine servicing lives under Service history.
+| Date | +Km | +Type | +Work done | +Workshop | +Status | +Cost | ++ |
|---|---|---|---|---|---|---|---|
| {{ formatDate(m.date) }} | +{{ formatKm(m.km) }} | +{{ MAINTENANCE_LABELS[m.type] || m.type }} | +
+ {{ m.description }}
+ {{ m.partsUsed }}
+
+ Under warranty · {{ m.warrantyDaysLeft }}d left
+
+ |
+
+ {{ m.workshop || '—' }}
+ {{ m.location }}
+ |
+ + + {{ MAINTENANCE_STATUS_LABELS[m.status] || m.status }} + + | ++ {{ m.totalCost ? formatMoney(m.totalCost) : '—' }} + | ++ + + | +
Consumption is measured between full tanks.
++ Log at least two full tanks to see consumption figures. +
+| Date | +Km | +Litres | +Cost | +Per litre | +Distance | +Consumption | +Station | ++ |
|---|---|---|---|---|---|---|---|---|
| + {{ formatDate(f.date) }} + partial + gap + | +{{ formatKm(f.km) }} | +{{ formatLiters(f.liters) }} | +{{ f.cost ? formatMoney(f.cost) : '—' }} | +{{ formatMoney(f.pricePerLiter) }} | +{{ f.distanceKm ? formatKm(f.distanceKm) : '—' }} | ++ {{ formatConsumption(f.consumptionL100) }} + | +
+ {{ f.station || '—' }}
+ {{ f.notes }}
+ |
+ + + + | +
Insurance, pollution certificates and other paperwork with renewal dates.
+| Type | +Title | +Provider | +Issued | +Renewal | +Status | +File | ++ |
|---|---|---|---|---|---|---|---|
| {{ DOCUMENT_LABELS[d.type] || d.type }} | +
+ {{ d.title }}
+ {{ d.reference }}
+ |
+ {{ d.provider || '—' }} | +{{ d.issueDate ? formatDate(d.issueDate) : '—' }} | +{{ d.expiryDate ? formatDate(d.expiryDate) : '—' }} | ++ {{ expiryStatus(d).label }} + | ++ + — + | ++ + + | +
Renewal and service reminders are added automatically from your documents and service history.
++ {{ formatDate(r.dueDate) }} + · + at {{ formatKm(r.dueKm) }} + · {{ r.notes }} +
+