Add fuel, maintenance, document and reminder tracking

Four features layered onto cars, each following the existing parts/services
pattern: a Go handler gated on requireCarAccess, snake_case PocketBase
mappers, a Vue form modal, and a tab on CarDetail (now driven by an array
rather than repeated markup).

Fuel: refills logged with odometer, litres and cost. Consumption is derived
on read from the whole history rather than stored, so correcting an old fill
re-derives every window it touches with no rows to migrate. Efficiency uses
the full-tank method — two consecutive full tanks are the same known level,
so the fuel burned between them is exactly what was poured in. Partial fills
roll into the window that closes them; a missed-fill flag leaves that window
uncomputed rather than reporting an implausibly good figure. Averages in the
stats rollup are distance-weighted, so a long motorway run counts for more
than a trip across town — which is what actually happened to the fuel.

Maintenance: 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 garage work with a workshop, an
invoice and a labour bill, and no bearing on the interval.

Documents: insurance, pollution certificates and registration papers. The
renewal date is the point of the record, so expiry is assessed live on every
read instead of stored and left to go stale. Scans are proxied through the
API — PocketBase's collections have no public read rule, so an attachment is
never a public URL and car access is re-checked per fetch.

Reminders: fire on a date, an odometer reading, or both (whichever comes
first). Stored reminders sit alongside read-only ones derived from document
expiry and next-service-due, so a renewal date is never typed twice and can
never drift from the document it came from. Derived ids are namespaced
"auto:" and every write endpoint rejects them.

A refill or a completed visit also writes the car's odometer forward, since
it is the freshest reading there is — never backwards, so backfilling old
history can't rewind the car.

Adds fuel_entries, maintenance_entries, car_documents and reminders to the
idempotent schema script, plus a file-field builder for attachments.

Verified end-to-end against a live PocketBase with a throwaway account: 39
checks covering the efficiency maths, expiry states, the derived reminders,
the upload/download round-trip, and that a stranger can reach none of it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-17 09:50:11 +02:00
co-authored by Claude Opus 4.8
parent ae6ed4ac1e
commit e64c89a564
15 changed files with 3358 additions and 39 deletions
+327
View File
@@ -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 ""
}
+250
View File
@@ -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)
}
+210
View File
@@ -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 ""
}
+235
View File
@@ -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,
}
}
+405
View File
@@ -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 ""
}
+75 -5
View File
@@ -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)
}
+374
View File
@@ -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)
}
+125 -7
View File
@@ -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" +
+41
View File
@@ -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),
@@ -0,0 +1,166 @@
<script setup>
import { ref } from "vue";
import { api } from "../api";
import Modal from "./Modal.vue";
const props = defineProps({
carId: { type: String, required: true },
doc: { type: Object, default: null },
});
const emit = defineEmits(["saved", "close"]);
const isEdit = !!props.doc;
const saving = ref(false);
const error = ref("");
const TYPES = [
{ value: "insurance", label: "Insurance" },
{ value: "pollution", label: "Pollution certificate" },
{ value: "registration", label: "Registration" },
{ value: "inspection", label: "Inspection" },
{ value: "roadTax", label: "Road tax" },
{ value: "warranty", label: "Warranty" },
{ value: "other", label: "Other" },
];
const form = ref({
type: props.doc?.type ?? "insurance",
title: props.doc?.title ?? "",
provider: props.doc?.provider ?? "",
reference: props.doc?.reference ?? "",
issueDate: props.doc?.issueDate ? toDateInput(props.doc.issueDate) : "",
expiryDate: props.doc?.expiryDate ? toDateInput(props.doc.expiryDate) : "",
cost: props.doc?.cost ?? "",
notes: props.doc?.notes ?? "",
});
// The picked file is uploaded after the metadata save, since the attachment
// endpoint addresses a document that must already exist.
const file = ref(null);
// Tracks a request to detach the existing attachment without picking a new one.
const removeFile = ref(false);
function toDateInput(value) {
const d = new Date(value);
return isNaN(d) ? "" : d.toISOString().slice(0, 10);
}
function onFilePick(e) {
file.value = e.target.files?.[0] || null;
if (file.value) removeFile.value = false;
}
async function submit() {
saving.value = true;
error.value = "";
try {
const saved = isEdit
? await api.updateDocument(props.doc.id, payload())
: await api.createDocument(payload());
// Attachment changes are separate calls; a failure here must not read as a
// failed save, because the metadata is already committed.
let final = saved;
if (file.value) {
final = await api.uploadDocumentFile(saved.id, file.value);
} else if (removeFile.value && isEdit) {
await api.deleteDocumentFile(saved.id);
final = { ...saved, fileName: "", hasFile: false };
}
emit("saved", final);
} catch (e) {
error.value = e.message;
} finally {
saving.value = false;
}
}
function payload() {
return {
car: props.carId,
type: form.value.type,
title: form.value.title.trim(),
provider: form.value.provider.trim(),
reference: form.value.reference.trim(),
issueDate: form.value.issueDate ? new Date(form.value.issueDate).toISOString() : null,
expiryDate: form.value.expiryDate ? new Date(form.value.expiryDate).toISOString() : null,
cost: form.value.cost ? Number(form.value.cost) : 0,
notes: form.value.notes.trim(),
};
}
</script>
<template>
<Modal :title="isEdit ? 'Edit document' : 'Add document'" @close="emit('close')">
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-3" @submit.prevent="submit">
<div>
<label class="dh-label">Type</label>
<select v-model="form.type" class="dh-input">
<option v-for="t in TYPES" :key="t.value" :value="t.value">{{ t.label }}</option>
</select>
</div>
<div>
<label class="dh-label">Title *</label>
<input v-model="form.title" required placeholder="Third-party liability 2026" class="dh-input" />
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Provider</label>
<input v-model="form.provider" placeholder="PZU" class="dh-input" />
</div>
<div>
<label class="dh-label">Policy / certificate no.</label>
<input v-model="form.reference" class="dh-input data" />
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Issued</label>
<input v-model="form.issueDate" type="date" class="dh-input data" />
</div>
<div>
<label class="dh-label">Renewal date</label>
<input v-model="form.expiryDate" type="date" class="dh-input data" />
</div>
</div>
<p class="text-xs text-muted">
Leave the renewal date blank for a document that never expires. Setting it adds a reminder automatically.
</p>
<div>
<label class="dh-label">Cost</label>
<input v-model="form.cost" type="number" step="0.01" min="0" class="dh-input data" />
</div>
<fieldset class="rounded-control border border-subtle p-3">
<legend class="eyebrow px-1">Scan or photo</legend>
<input type="file" accept=".pdf,.jpg,.jpeg,.png,.webp,.heic" class="w-full text-sm text-body" @change="onFilePick" />
<p v-if="isEdit && doc.hasFile && !file && !removeFile" class="mt-2 flex items-center gap-2 text-xs text-muted">
<span>Attached: <span class="data text-strong">{{ doc.fileName }}</span></span>
<button type="button" class="font-medium text-danger hover:underline" @click="removeFile = true">Remove</button>
</p>
<p v-else-if="removeFile" class="mt-2 flex items-center gap-2 text-xs text-muted">
<span>Attachment will be removed on save.</span>
<button type="button" class="font-medium text-brandtext hover:underline" @click="removeFile = false">Undo</button>
</p>
<p class="mt-1.5 text-xs text-muted">PDF or image, up to 10MB.</p>
</fieldset>
<div>
<label class="dh-label">Notes</label>
<input v-model="form.notes" class="dh-input" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
{{ saving ? "Saving…" : isEdit ? "Save changes" : "Add document" }}
</button>
</div>
</form>
</Modal>
</template>
@@ -0,0 +1,132 @@
<script setup>
import { ref, computed } from "vue";
import { api } from "../api";
import Modal from "./Modal.vue";
const props = defineProps({
carId: { type: String, required: true },
entry: { type: Object, default: null },
});
const emit = defineEmits(["saved", "close"]);
const isEdit = !!props.entry;
const saving = ref(false);
const error = ref("");
const form = ref({
date: props.entry ? toDateInput(props.entry.date) : new Date().toISOString().slice(0, 10),
km: props.entry?.km ?? "",
liters: props.entry?.liters ?? "",
cost: props.entry?.cost ?? "",
// A full tank is the common case and the one that makes the entry count
// towards efficiency, so it is the default.
fullTank: props.entry ? props.entry.fullTank : true,
missedFill: props.entry ? props.entry.missedFill : false,
station: props.entry?.station ?? "",
notes: props.entry?.notes ?? "",
});
function toDateInput(value) {
const d = new Date(value);
return isNaN(d) ? "" : d.toISOString().slice(0, 10);
}
const pricePerLiter = computed(() => {
const l = Number(form.value.liters);
const c = Number(form.value.cost);
if (!l || !c) return null;
return (c / l).toFixed(3);
});
async function submit() {
saving.value = true;
error.value = "";
try {
const saved = await (isEdit ? api.updateFuel(props.entry.id, payload()) : api.createFuel(payload()));
emit("saved", saved);
} catch (e) {
error.value = e.message;
} finally {
saving.value = false;
}
}
function payload() {
return {
car: props.carId,
date: new Date(form.value.date).toISOString(),
km: form.value.km ? Number(form.value.km) : 0,
liters: form.value.liters ? Number(form.value.liters) : 0,
cost: form.value.cost ? Number(form.value.cost) : 0,
fullTank: form.value.fullTank,
missedFill: form.value.missedFill,
station: form.value.station.trim(),
notes: form.value.notes.trim(),
};
}
</script>
<template>
<Modal :title="isEdit ? 'Edit refill' : 'Log refill'" @close="emit('close')">
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-3" @submit.prevent="submit">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Date *</label>
<input v-model="form.date" type="date" required class="dh-input data" />
</div>
<div>
<label class="dh-label">Odometer (km) *</label>
<input v-model="form.km" type="number" min="1" required placeholder="16138" class="dh-input data" />
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Litres *</label>
<input v-model="form.liters" type="number" step="0.01" min="0.01" required placeholder="42.5" class="dh-input data" />
</div>
<div>
<label class="dh-label">Total cost</label>
<input v-model="form.cost" type="number" step="0.01" min="0" placeholder="285.00" class="dh-input data" />
</div>
</div>
<p v-if="pricePerLiter" class="text-xs text-muted">
Price per litre: <span class="data text-strong">{{ pricePerLiter }}</span>
</p>
<fieldset class="rounded-control border border-subtle p-3">
<legend class="eyebrow px-1">Tank</legend>
<label class="flex items-center gap-2 py-1 text-sm text-body">
<input type="checkbox" v-model="form.fullTank" class="accent-[var(--accent)]" /> Filled to full
</label>
<label class="flex items-center gap-2 py-1 text-sm text-body">
<input type="checkbox" v-model="form.missedFill" class="accent-[var(--accent)]" /> I missed logging a refill before this one
</label>
<p class="mt-1.5 text-xs text-muted">
Consumption is measured between full tanks, so partial fills count towards the next full one.
Flagging a missed refill leaves that stretch out of the figures instead of reporting it as unrealistically economical.
</p>
</fieldset>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Station</label>
<input v-model="form.station" placeholder="Orlen" class="dh-input" />
</div>
<div>
<label class="dh-label">Notes</label>
<input v-model="form.notes" class="dh-input" />
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
{{ saving ? "Saving…" : isEdit ? "Save changes" : "Log refill" }}
</button>
</div>
</form>
</Modal>
</template>
@@ -0,0 +1,185 @@
<script setup>
import { ref, computed } from "vue";
import { api } from "../api";
import { formatMoney } from "../lib/format.js";
import Modal from "./Modal.vue";
const props = defineProps({
carId: { type: String, required: true },
entry: { type: Object, default: null },
});
const emit = defineEmits(["saved", "close"]);
const isEdit = !!props.entry;
const saving = ref(false);
const error = ref("");
const TYPES = [
{ value: "repair", label: "Repair" },
{ value: "inspection", label: "Inspection" },
{ value: "bodywork", label: "Bodywork" },
{ value: "tyres", label: "Tyres" },
{ value: "diagnostics", label: "Diagnostics" },
{ value: "recall", label: "Recall" },
{ value: "warranty", label: "Warranty work" },
{ value: "other", label: "Other" },
];
const STATUSES = [
{ value: "scheduled", label: "Scheduled" },
{ value: "in_progress", label: "In progress" },
{ value: "completed", label: "Completed" },
];
const form = ref({
date: props.entry ? toDateInput(props.entry.date) : new Date().toISOString().slice(0, 10),
km: props.entry?.km ?? "",
type: props.entry?.type ?? "repair",
status: props.entry?.status ?? "completed",
workshop: props.entry?.workshop ?? "",
location: props.entry?.location ?? "",
description: props.entry?.description ?? "",
partsUsed: props.entry?.partsUsed ?? "",
laborCost: props.entry?.laborCost ?? "",
partsCost: props.entry?.partsCost ?? "",
invoiceNumber: props.entry?.invoiceNumber ?? "",
warrantyUntil: props.entry?.warrantyUntil ? toDateInput(props.entry.warrantyUntil) : "",
notes: props.entry?.notes ?? "",
});
function toDateInput(value) {
const d = new Date(value);
return isNaN(d) ? "" : d.toISOString().slice(0, 10);
}
const totalCost = computed(() => {
const total = Number(form.value.laborCost || 0) + Number(form.value.partsCost || 0);
return total > 0 ? formatMoney(total) : null;
});
async function submit() {
saving.value = true;
error.value = "";
try {
const saved = await (isEdit
? api.updateMaintenance(props.entry.id, payload())
: api.createMaintenance(payload()));
emit("saved", saved);
} catch (e) {
error.value = e.message;
} finally {
saving.value = false;
}
}
function payload() {
return {
car: props.carId,
date: new Date(form.value.date).toISOString(),
km: form.value.km ? Number(form.value.km) : 0,
type: form.value.type,
status: form.value.status,
workshop: form.value.workshop.trim(),
location: form.value.location.trim(),
description: form.value.description.trim(),
partsUsed: form.value.partsUsed.trim(),
laborCost: form.value.laborCost ? Number(form.value.laborCost) : 0,
partsCost: form.value.partsCost ? Number(form.value.partsCost) : 0,
invoiceNumber: form.value.invoiceNumber.trim(),
warrantyUntil: form.value.warrantyUntil ? new Date(form.value.warrantyUntil).toISOString() : null,
notes: form.value.notes.trim(),
};
}
</script>
<template>
<Modal :title="isEdit ? 'Edit workshop visit' : 'Log workshop visit'" @close="emit('close')">
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-3" @submit.prevent="submit">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Date *</label>
<input v-model="form.date" type="date" required class="dh-input data" />
</div>
<div>
<label class="dh-label">Odometer (km)</label>
<input v-model="form.km" type="number" min="0" placeholder="16138" class="dh-input data" />
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Type</label>
<select v-model="form.type" class="dh-input">
<option v-for="t in TYPES" :key="t.value" :value="t.value">{{ t.label }}</option>
</select>
</div>
<div>
<label class="dh-label">Status</label>
<select v-model="form.status" class="dh-input">
<option v-for="s in STATUSES" :key="s.value" :value="s.value">{{ s.label }}</option>
</select>
</div>
</div>
<div>
<label class="dh-label">What was done *</label>
<input v-model="form.description" required placeholder="Replaced alternator and drive belt" class="dh-input" />
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Workshop</label>
<input v-model="form.workshop" placeholder="Auto Serwis Kowalski" class="dh-input" />
</div>
<div>
<label class="dh-label">Location</label>
<input v-model="form.location" placeholder="Kraków" class="dh-input" />
</div>
</div>
<div>
<label class="dh-label">Parts replaced</label>
<input v-model="form.partsUsed" placeholder="Alternator 27060-0T010, belt 90916-02660" class="dh-input" />
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Labour cost</label>
<input v-model="form.laborCost" type="number" step="0.01" min="0" class="dh-input data" />
</div>
<div>
<label class="dh-label">Parts cost</label>
<input v-model="form.partsCost" type="number" step="0.01" min="0" class="dh-input data" />
</div>
</div>
<p v-if="totalCost" class="text-xs text-muted">
Total: <span class="data text-strong">{{ totalCost }}</span>
</p>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Invoice number</label>
<input v-model="form.invoiceNumber" class="dh-input data" />
</div>
<div>
<label class="dh-label">Warranty until</label>
<input v-model="form.warrantyUntil" type="date" class="dh-input data" />
</div>
</div>
<div>
<label class="dh-label">Notes</label>
<input v-model="form.notes" class="dh-input" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
{{ saving ? "Saving…" : isEdit ? "Save changes" : "Log visit" }}
</button>
</div>
</form>
</Modal>
</template>
@@ -0,0 +1,149 @@
<script setup>
import { ref, computed } from "vue";
import { api } from "../api";
import { formatKm } from "../lib/format.js";
import Modal from "./Modal.vue";
const props = defineProps({
carId: { type: String, required: true },
car: { type: Object, default: null },
reminder: { type: Object, default: null },
});
const emit = defineEmits(["saved", "close"]);
const isEdit = !!props.reminder;
const saving = ref(false);
const error = ref("");
const TYPES = [
{ value: "maintenance", label: "Maintenance" },
{ value: "document", label: "Document renewal" },
{ value: "service", label: "Service" },
{ value: "inspection", label: "Inspection" },
{ value: "other", label: "Other" },
];
const form = ref({
title: props.reminder?.title ?? "",
type: props.reminder?.type ?? "maintenance",
dueDate: props.reminder?.dueDate ? toDateInput(props.reminder.dueDate) : "",
dueKm: props.reminder?.dueKm || "",
repeatDays: props.reminder?.repeatDays || "",
repeatKm: props.reminder?.repeatKm || "",
notes: props.reminder?.notes ?? "",
});
function toDateInput(value) {
const d = new Date(value);
return isNaN(d) ? "" : d.toISOString().slice(0, 10);
}
// Mirrors the server's rule: a reminder with neither trigger would never fire.
const hasTrigger = computed(() => !!form.value.dueDate || Number(form.value.dueKm) > 0);
const isRecurring = computed(() => Number(form.value.repeatDays) > 0 || Number(form.value.repeatKm) > 0);
async function submit() {
if (!hasTrigger.value) {
error.value = "Set a due date, a due odometer reading, or both.";
return;
}
saving.value = true;
error.value = "";
try {
const saved = await (isEdit
? api.updateReminder(props.reminder.id, payload())
: api.createReminder(payload()));
emit("saved", saved);
} catch (e) {
error.value = e.message;
} finally {
saving.value = false;
}
}
function payload() {
return {
car: props.carId,
title: form.value.title.trim(),
type: form.value.type,
dueDate: form.value.dueDate ? new Date(form.value.dueDate).toISOString() : null,
dueKm: form.value.dueKm ? Number(form.value.dueKm) : 0,
repeatDays: form.value.repeatDays ? Number(form.value.repeatDays) : 0,
repeatKm: form.value.repeatKm ? Number(form.value.repeatKm) : 0,
// Editing never silently closes a reminder; that is what Done does.
done: props.reminder?.done ?? false,
notes: form.value.notes.trim(),
};
}
</script>
<template>
<Modal :title="isEdit ? 'Edit reminder' : 'Add reminder'" @close="emit('close')">
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-3" @submit.prevent="submit">
<div>
<label class="dh-label">Title *</label>
<input v-model="form.title" required placeholder="Swap to winter tyres" class="dh-input" />
</div>
<div>
<label class="dh-label">Type</label>
<select v-model="form.type" class="dh-input">
<option v-for="t in TYPES" :key="t.value" :value="t.value">{{ t.label }}</option>
</select>
</div>
<fieldset class="rounded-control border border-subtle p-3">
<legend class="eyebrow px-1">Remind me</legend>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">On date</label>
<input v-model="form.dueDate" type="date" class="dh-input data" />
</div>
<div>
<label class="dh-label">At odometer (km)</label>
<input v-model="form.dueKm" type="number" min="0" placeholder="30000" class="dh-input data" />
</div>
</div>
<p class="mt-1.5 text-xs text-muted">
Set either or both with both, whichever comes first wins.
<span v-if="car?.currentKm"> The car is at {{ formatKm(car.currentKm) }} now.</span>
</p>
</fieldset>
<fieldset class="rounded-control border border-subtle p-3">
<legend class="eyebrow px-1">Repeat (optional)</legend>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">Every days</label>
<input v-model="form.repeatDays" type="number" min="0" placeholder="365" class="dh-input data" />
</div>
<div>
<label class="dh-label">Every km</label>
<input v-model="form.repeatKm" type="number" min="0" placeholder="15000" class="dh-input data" />
</div>
</div>
<p class="mt-1.5 text-xs text-muted">
<template v-if="isRecurring">
Marking this done will roll it forward instead of closing it.
</template>
<template v-else>
Leave blank for a one-off reminder that closes when you mark it done.
</template>
</p>
</fieldset>
<div>
<label class="dh-label">Notes</label>
<input v-model="form.notes" class="dh-input" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="submit" :disabled="saving || !hasTrigger" class="dh-btn dh-btn-primary">
{{ saving ? "Saving…" : isEdit ? "Save changes" : "Add reminder" }}
</button>
</div>
</form>
</Modal>
</template>
+94
View File
@@ -80,6 +80,100 @@ function kmSignal(currentKm, nextServiceKm) {
// serviceStatus combines the date- and km-based signals, returning the worse of
// the two for the badge. `latest` is the most recent service record (with
// nextServiceDate/nextServiceKm); `car` carries the current odometer.
// formatLiters / formatMoney / formatConsumption render the fuel figures. The
// server sends null for anything it could not derive (a window with a missed
// fill, a first-ever tank), which reads as "—" rather than a misleading zero.
export function formatLiters(value) {
if (value == null || value === "") return "—";
return Number(value).toFixed(2) + " L";
}
// Amounts are unit-less on purpose: the project stores plain numbers and has no
// currency setting, so imposing a symbol here would be a guess.
export function formatMoney(value) {
if (value == null || value === "") return "—";
return Number(value).toLocaleString(prefs.locale || undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
// One decimal: the interesting differences between tanks live in tenths, and
// rounding to whole litres collapses a best of 6.8 and a worst of 7.0 into the
// same number.
export function formatConsumption(value) {
if (value == null) return "—";
return Number(value).toFixed(1) + " L/100km";
}
export function formatKmPerLiter(value) {
if (value == null) return "—";
return Number(value).toFixed(2) + " km/L";
}
// Document renewal badge, driven by the server's expiry assessment so the client
// never re-derives the date maths.
const EXPIRY_STYLE = {
no_expiry: "dh-badge dh-badge-neutral",
valid: "dh-badge dh-badge-success",
expiring_soon: "dh-badge dh-badge-warning",
expired: "dh-badge dh-badge-danger",
};
export function expiryStatus(doc) {
const state = doc?.expiry?.state || "no_expiry";
const days = doc?.expiry?.daysUntilExpiry;
let label;
switch (state) {
case "expired":
label = `Expired ${Math.abs(days)}d ago`;
break;
case "expiring_soon":
label = days === 0 ? "Expires today" : `Renew in ${days}d`;
break;
case "valid":
label = `Valid · ${days}d`;
break;
default:
label = "No expiry";
}
return { key: state, label, classes: EXPIRY_STYLE[state] || EXPIRY_STYLE.no_expiry };
}
// Reminder badge. The server has already picked the worse of the date and
// odometer signals; this only chooses the wording, preferring whichever trigger
// is actually driving the status.
const REMINDER_STYLE = {
done: "dh-badge dh-badge-neutral",
no_trigger: "dh-badge dh-badge-neutral",
upcoming: "dh-badge dh-badge-success",
due_soon: "dh-badge dh-badge-warning",
overdue: "dh-badge dh-badge-danger",
};
export function reminderStatus(rem) {
const state = rem?.status || "no_trigger";
const days = rem?.daysLeft;
const km = rem?.kmLeft;
let label;
if (state === "done") label = "Done";
else if (state === "no_trigger") label = "No trigger";
else if (state === "overdue") {
const parts = [];
if (days != null && days < 0) parts.push(`${Math.abs(days)}d`);
if (km != null && km < 0) parts.push(`${Math.abs(km).toLocaleString()} km`);
label = parts.length ? `Overdue ${parts.join(" · ")}` : "Overdue";
} else {
// Lead with the trigger that is closest to firing.
const parts = [];
if (days != null && days >= 0) parts.push(days === 0 ? "today" : `${days}d`);
if (km != null && km >= 0) parts.push(`${km.toLocaleString()} km`);
label = parts.length ? `Due in ${parts.join(" · ")}` : "Upcoming";
}
return { key: state, label, classes: REMINDER_STYLE[state] || REMINDER_STYLE.no_trigger };
}
export function serviceStatus(latest, car = null) {
const date = dateSignal(latest?.nextServiceDate);
const km = kmSignal(car?.currentKm, latest?.nextServiceKm);
+590 -27
View File
@@ -2,10 +2,24 @@
import { ref, onMounted, computed } from "vue";
import { useRouter } from "vue-router";
import { api } from "../api";
import { formatDate, formatKm, serviceStatus } from "../lib/format.js";
import {
formatDate,
formatKm,
formatLiters,
formatMoney,
formatConsumption,
formatKmPerLiter,
serviceStatus,
expiryStatus,
reminderStatus,
} from "../lib/format.js";
import CarFormModal from "../components/CarFormModal.vue";
import ServiceFormModal from "../components/ServiceFormModal.vue";
import PartFormModal from "../components/PartFormModal.vue";
import FuelFormModal from "../components/FuelFormModal.vue";
import MaintenanceFormModal from "../components/MaintenanceFormModal.vue";
import DocumentFormModal from "../components/DocumentFormModal.vue";
import ReminderFormModal from "../components/ReminderFormModal.vue";
import ShareModal from "../components/ShareModal.vue";
const props = defineProps({ id: { type: String, required: true } });
@@ -14,6 +28,11 @@ const router = useRouter();
const car = ref(null);
const services = ref([]);
const parts = ref([]);
const fuel = ref([]);
const fuelStats = ref(null);
const maintenance = ref([]);
const documents = ref([]);
const reminders = ref([]);
const loading = ref(true);
const error = ref("");
@@ -23,6 +42,14 @@ const showService = ref(false);
const editingService = ref(null);
const showPart = ref(false);
const editingPart = ref(null);
const showFuel = ref(false);
const editingFuel = ref(null);
const showMaintenance = ref(false);
const editingMaintenance = ref(null);
const showDocument = ref(false);
const editingDocument = ref(null);
const showReminder = ref(false);
const editingReminder = ref(null);
// Delete-car confirmation (guarded: user must type the car name).
const showDeleteCar = ref(false);
@@ -44,14 +71,44 @@ const isReadOnly = computed(() => car.value?.access === "read");
const activeTab = ref("info");
// Count of reminders wanting attention, surfaced on the tab so it is visible
// without opening it — the whole point of a reminder.
const dueReminders = computed(
() => reminders.value.filter((r) => r.status === "overdue" || r.status === "due_soon").length
);
const TABS = [
{ key: "info", label: "Information" },
{ key: "services", label: "Service history" },
{ key: "maintenance", label: "Maintenance log" },
{ key: "fuel", label: "Fuel" },
{ key: "documents", label: "Documents" },
{ key: "reminders", label: "Reminders" },
{ key: "parts", label: "Parts catalog" },
];
async function load() {
loading.value = true;
error.value = "";
try {
[car.value, services.value, parts.value] = await Promise.all([
[
car.value,
services.value,
parts.value,
fuel.value,
fuelStats.value,
maintenance.value,
documents.value,
reminders.value,
] = await Promise.all([
api.getCar(props.id),
api.listCarServices(props.id),
api.listCarParts(props.id),
api.listCarFuel(props.id),
api.getCarFuelStats(props.id),
api.listCarMaintenance(props.id),
api.listCarDocuments(props.id),
api.listCarReminders(props.id),
]);
} catch (e) {
error.value = e.message;
@@ -71,6 +128,7 @@ function openEditService(s) {
async function onServiceSaved() {
showService.value = false;
editingService.value = null;
// A service record moves the next-service-due reminder, so reload everything.
await load();
}
async function deleteService(id) {
@@ -106,6 +164,166 @@ async function deletePart(id) {
}
}
// --- fuel ---
function openAddFuel() {
editingFuel.value = null;
showFuel.value = true;
}
function openEditFuel(f) {
editingFuel.value = f;
showFuel.value = true;
}
async function reloadFuel() {
// Editing one refill re-derives every window it touches, and a refill also
// advances the car's odometer — which in turn moves any km-triggered reminder.
// So refetch all four, not just the list that was edited.
[fuel.value, fuelStats.value, car.value, reminders.value] = await Promise.all([
api.listCarFuel(props.id),
api.getCarFuelStats(props.id),
api.getCar(props.id),
api.listCarReminders(props.id),
]);
}
async function onFuelSaved() {
showFuel.value = false;
editingFuel.value = null;
await reloadFuel();
}
async function deleteFuel(id) {
if (!confirm("Delete this refill?")) return;
try {
await api.deleteFuel(id);
await reloadFuel();
} catch (e) {
error.value = e.message;
}
}
// --- maintenance ---
function openAddMaintenance() {
editingMaintenance.value = null;
showMaintenance.value = true;
}
function openEditMaintenance(m) {
editingMaintenance.value = m;
showMaintenance.value = true;
}
async function onMaintenanceSaved() {
showMaintenance.value = false;
editingMaintenance.value = null;
// A completed visit advances the odometer, which moves km-triggered reminders.
[maintenance.value, car.value, reminders.value] = await Promise.all([
api.listCarMaintenance(props.id),
api.getCar(props.id),
api.listCarReminders(props.id),
]);
}
async function deleteMaintenance(id) {
if (!confirm("Delete this workshop visit?")) return;
try {
await api.deleteMaintenance(id);
maintenance.value = await api.listCarMaintenance(props.id);
} catch (e) {
error.value = e.message;
}
}
// --- documents ---
function openAddDocument() {
editingDocument.value = null;
showDocument.value = true;
}
function openEditDocument(d) {
editingDocument.value = d;
showDocument.value = true;
}
async function onDocumentSaved() {
showDocument.value = false;
editingDocument.value = null;
// A renewal date change adds/moves an auto-derived reminder.
[documents.value, reminders.value] = await Promise.all([
api.listCarDocuments(props.id),
api.listCarReminders(props.id),
]);
}
async function deleteDocument(id) {
if (!confirm("Delete this document?")) return;
try {
await api.deleteDocument(id);
[documents.value, reminders.value] = await Promise.all([
api.listCarDocuments(props.id),
api.listCarReminders(props.id),
]);
} catch (e) {
error.value = e.message;
}
}
// The attachment needs the auth header, so it is fetched as a Blob rather than
// linked to directly.
async function downloadDocument(doc) {
try {
const { blob, filename } = await api.getDocumentFileBlob(doc.id);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename || doc.fileName || "document";
a.click();
URL.revokeObjectURL(url);
} catch (e) {
error.value = e.message;
}
}
// --- reminders ---
function openAddReminder() {
editingReminder.value = null;
showReminder.value = true;
}
function openEditReminder(r) {
editingReminder.value = r;
showReminder.value = true;
}
async function onReminderSaved() {
showReminder.value = false;
editingReminder.value = null;
reminders.value = await api.listCarReminders(props.id);
}
async function completeReminder(r) {
try {
await api.completeReminder(r.id);
reminders.value = await api.listCarReminders(props.id);
} catch (e) {
error.value = e.message;
}
}
async function reopenReminder(r) {
try {
await api.updateReminder(r.id, {
car: r.car,
title: r.title,
type: r.type,
dueDate: r.dueDate ?? null,
dueKm: r.dueKm || 0,
repeatDays: r.repeatDays || 0,
repeatKm: r.repeatKm || 0,
done: false,
notes: r.notes || "",
});
reminders.value = await api.listCarReminders(props.id);
} catch (e) {
error.value = e.message;
}
}
async function deleteReminder(id) {
if (!confirm("Delete this reminder?")) return;
try {
await api.deleteReminder(id);
reminders.value = await api.listCarReminders(props.id);
} catch (e) {
error.value = e.message;
}
}
async function onCarSaved(updated) {
showCarEdit.value = false;
car.value = updated;
@@ -148,6 +366,45 @@ function fuelLabel(v) {
return FUEL_LABELS[v] || "—";
}
const MAINTENANCE_LABELS = {
repair: "Repair",
inspection: "Inspection",
bodywork: "Bodywork",
tyres: "Tyres",
diagnostics: "Diagnostics",
recall: "Recall",
warranty: "Warranty work",
other: "Other",
};
const MAINTENANCE_STATUS = {
scheduled: "dh-badge dh-badge-warning",
in_progress: "dh-badge dh-badge-warning",
completed: "dh-badge dh-badge-success",
};
const MAINTENANCE_STATUS_LABELS = {
scheduled: "Scheduled",
in_progress: "In progress",
completed: "Completed",
};
const DOCUMENT_LABELS = {
insurance: "Insurance",
pollution: "Pollution certificate",
registration: "Registration",
inspection: "Inspection",
roadTax: "Road tax",
warranty: "Warranty",
other: "Other",
};
const REMINDER_LABELS = {
maintenance: "Maintenance",
document: "Document",
service: "Service",
inspection: "Inspection",
other: "Other",
};
onMounted(load);
</script>
@@ -184,30 +441,21 @@ onMounted(load);
</div>
<!-- Tabs -->
<div class="mb-6 flex gap-1 border-b border-subtle">
<div class="mb-6 flex flex-wrap gap-1 border-b border-subtle">
<button
class="-mb-px border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors"
:class="activeTab === 'info'
v-for="t in TABS"
:key="t.key"
class="-mb-px flex items-center gap-1.5 border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors"
:class="activeTab === t.key
? 'border-accent text-brandtext'
: 'border-transparent text-muted hover:text-strong'"
@click="activeTab = 'info'">
Information
</button>
<button
class="-mb-px border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors"
:class="activeTab === 'services'
? 'border-accent text-brandtext'
: 'border-transparent text-muted hover:text-strong'"
@click="activeTab = 'services'">
Service history
</button>
<button
class="-mb-px border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors"
:class="activeTab === 'parts'
? 'border-accent text-brandtext'
: 'border-transparent text-muted hover:text-strong'"
@click="activeTab = 'parts'">
Parts catalog
@click="activeTab = t.key">
{{ t.label }}
<span
v-if="t.key === 'reminders' && dueReminders"
class="rounded-full bg-danger px-1.5 py-0.5 text-[10px] font-bold leading-none text-white">
{{ dueReminders }}
</span>
</button>
</div>
@@ -282,8 +530,291 @@ onMounted(load);
</div>
</section>
<!-- Maintenance log -->
<section v-else-if="activeTab === 'maintenance'">
<div class="mb-3 flex items-center justify-between">
<div>
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Maintenance log</h2>
<p class="text-sm text-muted">Workshop visits and repairs. Routine servicing lives under Service history.</p>
</div>
<button v-if="canWrite" class="dh-btn dh-btn-primary !px-3 !py-1.5" @click="openAddMaintenance">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" /></svg>
Log visit
</button>
</div>
<div v-if="maintenance.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
No workshop visits logged yet.
</div>
<div v-else class="dh-card overflow-x-auto p-0">
<table class="min-w-full text-sm">
<thead class="bg-sunken text-left">
<tr class="[&>th]:eyebrow [&>th]:px-4 [&>th]:py-3">
<th>Date</th>
<th>Km</th>
<th>Type</th>
<th>Work done</th>
<th>Workshop</th>
<th>Status</th>
<th class="!text-right">Cost</th>
<th v-if="canWrite"></th>
</tr>
</thead>
<tbody class="divide-y divide-subtle">
<tr v-for="m in maintenance" :key="m.id" class="transition-colors hover:bg-sunken">
<td class="whitespace-nowrap px-4 py-3 data font-medium text-strong">{{ formatDate(m.date) }}</td>
<td class="whitespace-nowrap px-4 py-3 data text-body">{{ formatKm(m.km) }}</td>
<td class="whitespace-nowrap px-4 py-3 text-body">{{ MAINTENANCE_LABELS[m.type] || m.type }}</td>
<td class="px-4 py-3 text-body">
<div class="font-medium text-strong">{{ m.description }}</div>
<div v-if="m.partsUsed" class="text-xs text-muted">{{ m.partsUsed }}</div>
<div v-if="m.warrantyActive" class="mt-0.5 text-xs text-success">
Under warranty · {{ m.warrantyDaysLeft }}d left
</div>
</td>
<td class="px-4 py-3 text-body">
{{ m.workshop || '—' }}
<div v-if="m.location" class="text-xs text-muted">{{ m.location }}</div>
</td>
<td class="whitespace-nowrap px-4 py-3">
<span :class="MAINTENANCE_STATUS[m.status] || 'dh-badge dh-badge-neutral'">
{{ MAINTENANCE_STATUS_LABELS[m.status] || m.status }}
</span>
</td>
<td class="whitespace-nowrap px-4 py-3 text-right data text-body">
{{ m.totalCost ? formatMoney(m.totalCost) : '—' }}
</td>
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditMaintenance(m)">Edit</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteMaintenance(m.id)">Delete</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Fuel -->
<section v-else-if="activeTab === 'fuel'">
<div class="mb-3 flex items-center justify-between">
<div>
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Fuel</h2>
<p class="text-sm text-muted">Consumption is measured between full tanks.</p>
</div>
<button v-if="canWrite" class="dh-btn dh-btn-primary !px-3 !py-1.5" @click="openAddFuel">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" /></svg>
Log refill
</button>
</div>
<!-- Rollup -->
<div v-if="fuelStats && fuelStats.entries > 0" class="dh-card mb-4 p-6">
<dl class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
<div>
<dt class="eyebrow">Average</dt>
<dd class="mt-0.5 data text-lg font-bold text-strong">{{ formatConsumption(fuelStats.avgConsumptionL100) }}</dd>
<dd class="text-xs text-muted">{{ formatKmPerLiter(fuelStats.avgKmPerLiter) }}</dd>
</div>
<div>
<dt class="eyebrow">Best</dt>
<dd class="mt-0.5 data font-medium text-success">{{ formatConsumption(fuelStats.bestConsumptionL100) }}</dd>
</div>
<div>
<dt class="eyebrow">Worst</dt>
<dd class="mt-0.5 data font-medium text-danger">{{ formatConsumption(fuelStats.worstConsumptionL100) }}</dd>
</div>
<div>
<dt class="eyebrow">Cost per km</dt>
<dd class="mt-0.5 data font-medium text-strong">{{ formatMoney(fuelStats.costPerKm) }}</dd>
</div>
<div>
<dt class="eyebrow">Refills</dt>
<dd class="mt-0.5 data font-medium text-strong">{{ fuelStats.entries }}</dd>
</div>
<div>
<dt class="eyebrow">Total litres</dt>
<dd class="mt-0.5 data font-medium text-strong">{{ formatLiters(fuelStats.totalLiters) }}</dd>
</div>
<div>
<dt class="eyebrow">Total spent</dt>
<dd class="mt-0.5 data font-medium text-strong">{{ formatMoney(fuelStats.totalCost) }}</dd>
</div>
<div>
<dt class="eyebrow">Tracked distance</dt>
<dd class="mt-0.5 data font-medium text-strong">{{ formatKm(fuelStats.trackedDistanceKm) }}</dd>
<dd class="text-xs text-muted">Avg. price {{ formatMoney(fuelStats.avgPricePerLiter) }}/L</dd>
</div>
</dl>
<p v-if="!fuelStats.avgConsumptionL100" class="mt-4 text-xs text-muted">
Log at least two full tanks to see consumption figures.
</p>
</div>
<div v-if="fuel.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
No refills logged yet.
</div>
<div v-else class="dh-card overflow-x-auto p-0">
<table class="min-w-full text-sm">
<thead class="bg-sunken text-left">
<tr class="[&>th]:eyebrow [&>th]:px-4 [&>th]:py-3">
<th>Date</th>
<th>Km</th>
<th class="!text-right">Litres</th>
<th class="!text-right">Cost</th>
<th class="!text-right">Per litre</th>
<th class="!text-right">Distance</th>
<th class="!text-right">Consumption</th>
<th>Station</th>
<th v-if="canWrite"></th>
</tr>
</thead>
<tbody class="divide-y divide-subtle">
<tr v-for="f in fuel" :key="f.id" class="transition-colors hover:bg-sunken">
<td class="whitespace-nowrap px-4 py-3 data font-medium text-strong">
{{ formatDate(f.date) }}
<span v-if="!f.fullTank" class="ml-1 text-xs font-normal text-muted">partial</span>
<span v-if="f.missedFill" class="ml-1 text-xs font-normal text-warning">gap</span>
</td>
<td class="whitespace-nowrap px-4 py-3 data text-body">{{ formatKm(f.km) }}</td>
<td class="whitespace-nowrap px-4 py-3 text-right data text-body">{{ formatLiters(f.liters) }}</td>
<td class="whitespace-nowrap px-4 py-3 text-right data text-body">{{ f.cost ? formatMoney(f.cost) : '—' }}</td>
<td class="whitespace-nowrap px-4 py-3 text-right data text-muted">{{ formatMoney(f.pricePerLiter) }}</td>
<td class="whitespace-nowrap px-4 py-3 text-right data text-muted">{{ f.distanceKm ? formatKm(f.distanceKm) : '—' }}</td>
<td class="whitespace-nowrap px-4 py-3 text-right data font-medium" :class="f.consumptionL100 ? 'text-strong' : 'text-muted'">
{{ formatConsumption(f.consumptionL100) }}
</td>
<td class="px-4 py-3 text-body">
{{ f.station || '—' }}
<div v-if="f.notes" class="text-xs text-muted">{{ f.notes }}</div>
</td>
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditFuel(f)">Edit</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteFuel(f.id)">Delete</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Documents -->
<section v-else-if="activeTab === 'documents'">
<div class="mb-3 flex items-center justify-between">
<div>
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Documents</h2>
<p class="text-sm text-muted">Insurance, pollution certificates and other paperwork with renewal dates.</p>
</div>
<button v-if="canWrite" class="dh-btn dh-btn-primary !px-3 !py-1.5" @click="openAddDocument">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" /></svg>
Add document
</button>
</div>
<div v-if="documents.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
No documents yet.
</div>
<div v-else class="dh-card overflow-x-auto p-0">
<table class="min-w-full text-sm">
<thead class="bg-sunken text-left">
<tr class="[&>th]:eyebrow [&>th]:px-4 [&>th]:py-3">
<th>Type</th>
<th>Title</th>
<th>Provider</th>
<th>Issued</th>
<th>Renewal</th>
<th>Status</th>
<th>File</th>
<th v-if="canWrite"></th>
</tr>
</thead>
<tbody class="divide-y divide-subtle">
<tr v-for="d in documents" :key="d.id" class="transition-colors hover:bg-sunken">
<td class="whitespace-nowrap px-4 py-3 text-body">{{ DOCUMENT_LABELS[d.type] || d.type }}</td>
<td class="px-4 py-3">
<div class="font-medium text-strong">{{ d.title }}</div>
<div v-if="d.reference" class="data text-xs text-muted">{{ d.reference }}</div>
</td>
<td class="px-4 py-3 text-body">{{ d.provider || '—' }}</td>
<td class="whitespace-nowrap px-4 py-3 data text-muted">{{ d.issueDate ? formatDate(d.issueDate) : '—' }}</td>
<td class="whitespace-nowrap px-4 py-3 data text-body">{{ d.expiryDate ? formatDate(d.expiryDate) : '—' }}</td>
<td class="whitespace-nowrap px-4 py-3">
<span :class="expiryStatus(d).classes">{{ expiryStatus(d).label }}</span>
</td>
<td class="whitespace-nowrap px-4 py-3">
<button v-if="d.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadDocument(d)">
Download
</button>
<span v-else class="text-xs text-muted"></span>
</td>
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditDocument(d)">Edit</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteDocument(d.id)">Delete</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Reminders -->
<section v-else-if="activeTab === 'reminders'">
<div class="mb-3 flex items-center justify-between">
<div>
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Reminders</h2>
<p class="text-sm text-muted">Renewal and service reminders are added automatically from your documents and service history.</p>
</div>
<button v-if="canWrite" class="dh-btn dh-btn-primary !px-3 !py-1.5" @click="openAddReminder">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" /></svg>
Add reminder
</button>
</div>
<div v-if="reminders.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
Nothing to be reminded about yet.
</div>
<ul v-else class="space-y-2">
<li
v-for="r in reminders"
:key="r.id"
class="dh-card flex flex-wrap items-center justify-between gap-3 p-4"
:class="r.done ? 'opacity-60' : ''">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-strong" :class="r.done ? 'line-through' : ''">{{ r.title }}</span>
<span class="dh-badge dh-badge-neutral">{{ REMINDER_LABELS[r.type] || r.type }}</span>
<span v-if="r.auto" class="dh-badge dh-badge-neutral">Automatic</span>
<span v-if="r.repeatDays || r.repeatKm" class="dh-badge dh-badge-neutral">Repeats</span>
</div>
<p class="mt-0.5 text-xs text-muted">
<span v-if="r.dueDate" class="data">{{ formatDate(r.dueDate) }}</span>
<span v-if="r.dueDate && r.dueKm"> · </span>
<span v-if="r.dueKm" class="data">at {{ formatKm(r.dueKm) }}</span>
<span v-if="r.notes"> · {{ r.notes }}</span>
</p>
</div>
<div class="flex items-center gap-3">
<span :class="reminderStatus(r).classes">{{ reminderStatus(r).label }}</span>
<!-- Auto reminders have no row behind them: they clear by renewing
the document or logging the service they came from. -->
<template v-if="canWrite && !r.auto">
<button v-if="!r.done" class="text-xs font-medium text-success hover:underline" @click="completeReminder(r)">
{{ r.repeatDays || r.repeatKm ? 'Done · roll forward' : 'Mark done' }}
</button>
<button v-else class="text-xs font-medium text-brandtext hover:underline" @click="reopenReminder(r)">Reopen</button>
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditReminder(r)">Edit</button>
<button class="text-xs font-medium text-danger hover:underline" @click="deleteReminder(r.id)">Delete</button>
</template>
</div>
</li>
</ul>
</section>
<!-- Parts catalog -->
<section v-else>
<section v-else-if="activeTab === 'parts'">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Parts catalog</h2>
<button v-if="canWrite" class="dh-btn dh-btn-primary !px-3 !py-1.5" @click="openAddPart">
@@ -337,6 +868,35 @@ onMounted(load);
@saved="onPartSaved"
@close="showPart = false"
/>
<FuelFormModal
v-if="showFuel"
:car-id="id"
:entry="editingFuel"
@saved="onFuelSaved"
@close="showFuel = false"
/>
<MaintenanceFormModal
v-if="showMaintenance"
:car-id="id"
:entry="editingMaintenance"
@saved="onMaintenanceSaved"
@close="showMaintenance = false"
/>
<DocumentFormModal
v-if="showDocument"
:car-id="id"
:doc="editingDocument"
@saved="onDocumentSaved"
@close="showDocument = false"
/>
<ReminderFormModal
v-if="showReminder"
:car-id="id"
:car="car"
:reminder="editingReminder"
@saved="onReminderSaved"
@close="showReminder = false"
/>
<ShareModal v-if="showShare && car" :car="car" @close="showShare = false" />
<!-- Delete-car confirmation (type-to-confirm; cascade removes all data) -->
@@ -344,9 +904,12 @@ onMounted(load);
<div class="dh-card w-full max-w-md p-6 shadow-pop">
<h2 class="mb-2 text-lg font-bold tracking-[-0.02em] text-danger">Delete this car?</h2>
<p class="mb-4 text-sm text-body">
This permanently deletes <strong class="text-strong">{{ car.name }}</strong> and all of its
<strong class="text-strong">{{ services.length }}</strong> service record{{ services.length === 1 ? '' : 's' }}
and <strong class="text-strong">{{ parts.length }}</strong> part{{ parts.length === 1 ? '' : 's' }}. This cannot be undone.
This permanently deletes <strong class="text-strong">{{ car.name }}</strong> and everything logged against it
<strong class="text-strong">{{ services.length }}</strong> service record{{ services.length === 1 ? '' : 's' }},
<strong class="text-strong">{{ maintenance.length }}</strong> workshop visit{{ maintenance.length === 1 ? '' : 's' }},
<strong class="text-strong">{{ fuel.length }}</strong> refill{{ fuel.length === 1 ? '' : 's' }},
<strong class="text-strong">{{ documents.length }}</strong> document{{ documents.length === 1 ? '' : 's' }} and
<strong class="text-strong">{{ parts.length }}</strong> part{{ parts.length === 1 ? '' : 's' }}. This cannot be undone.
</p>
<label class="dh-label">
Type <span class="data text-strong">{{ car.name }}</span> to confirm