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

Maintenance log

+

Workshop visits and repairs. Routine servicing lives under Service history.

+
+ +
+ +
+ No workshop visits logged yet. +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
DateKmTypeWork doneWorkshopStatusCost
{{ formatDate(m.date) }}{{ formatKm(m.km) }}{{ MAINTENANCE_LABELS[m.type] || m.type }} +
{{ m.description }}
+
{{ m.partsUsed }}
+
+ Under warranty · {{ m.warrantyDaysLeft }}d left +
+
+ {{ m.workshop || '—' }} +
{{ m.location }}
+
+ + {{ MAINTENANCE_STATUS_LABELS[m.status] || m.status }} + + + {{ m.totalCost ? formatMoney(m.totalCost) : '—' }} + + + +
+
+
+ + +
+
+
+

Fuel

+

Consumption is measured between full tanks.

+
+ +
+ + +
+
+
+
Average
+
{{ formatConsumption(fuelStats.avgConsumptionL100) }}
+
{{ formatKmPerLiter(fuelStats.avgKmPerLiter) }}
+
+
+
Best
+
{{ formatConsumption(fuelStats.bestConsumptionL100) }}
+
+
+
Worst
+
{{ formatConsumption(fuelStats.worstConsumptionL100) }}
+
+
+
Cost per km
+
{{ formatMoney(fuelStats.costPerKm) }}
+
+
+
Refills
+
{{ fuelStats.entries }}
+
+
+
Total litres
+
{{ formatLiters(fuelStats.totalLiters) }}
+
+
+
Total spent
+
{{ formatMoney(fuelStats.totalCost) }}
+
+
+
Tracked distance
+
{{ formatKm(fuelStats.trackedDistanceKm) }}
+
Avg. price {{ formatMoney(fuelStats.avgPricePerLiter) }}/L
+
+
+

+ Log at least two full tanks to see consumption figures. +

+
+ +
+ No refills logged yet. +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
DateKmLitresCostPer litreDistanceConsumptionStation
+ {{ formatDate(f.date) }} + partial + gap + {{ formatKm(f.km) }}{{ formatLiters(f.liters) }}{{ f.cost ? formatMoney(f.cost) : '—' }}{{ formatMoney(f.pricePerLiter) }}{{ f.distanceKm ? formatKm(f.distanceKm) : '—' }} + {{ formatConsumption(f.consumptionL100) }} + + {{ f.station || '—' }} +
{{ f.notes }}
+
+ + +
+
+
+ + +
+
+
+

Documents

+

Insurance, pollution certificates and other paperwork with renewal dates.

+
+ +
+ +
+ No documents yet. +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
TypeTitleProviderIssuedRenewalStatusFile
{{ DOCUMENT_LABELS[d.type] || d.type }} +
{{ d.title }}
+
{{ d.reference }}
+
{{ d.provider || '—' }}{{ d.issueDate ? formatDate(d.issueDate) : '—' }}{{ d.expiryDate ? formatDate(d.expiryDate) : '—' }} + {{ expiryStatus(d).label }} + + + + + + +
+
+
+ + +
+
+
+

Reminders

+

Renewal and service reminders are added automatically from your documents and service history.

+
+ +
+ +
+ Nothing to be reminded about yet. +
+ + +
+ -
+

Parts catalog