Files
DriverVault/API Server/internal/api/reminders.go
T
tajniak81andClaude Opus 4.8 e64c89a564 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>
2026-07-17 09:50:11 +02:00

406 lines
12 KiB
Go

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 ""
}