Files
DriverVault/API Server/internal/api/reminders.go
T
tajniak81andClaude Opus 5 9abb03ee4f Service history: 0 km is a reading, not a blank
A car collected new sits at 0 km, and every km calculation in the app
quietly refused to work for it. ComputeDerived only filled NextServiceKm
when Km > 0, so a service record entered at 0 produced no next-due
distance at all — the date side worked, because it guards on IsZero(),
which is genuine absence rather than a number that happens to be low.

The same conflation had been copied outward from there. The reminder's
km signal wanted currentKm > 0 before it would count anything down, the
web badge and the service-life ring tested the odometer for truthiness,
formatKm printed an em dash for zero, and fuel and charging rejected a
0 km entry as "odometer (km) is required" — which is the first charge
of an EV on the driveway on delivery day. The phone app carried its own
copy of each. Editing such a car offered an empty odometer box, since
the forms only prefilled a reading above zero.

Everywhere the odometer is a measurement, absence is now tested as
absence: null in the clients, negative on the server, and the required
fields check that the box was filled rather than that the number cleared
zero. Fuel and charging validate Km < 0 instead, and their inputs drop
min="1". Completing a repeating km reminder rolls from the car's actual
reading in every case; the old fallback to the previous target existed
to keep an untracked car off a due date in the past, but CurrentKm +
RepeatKm is ahead of the car by construction, so it could not have
happened.

Left as it was: dueKm, repeatKm and the service intervals, where zero
really does encode "no trigger" and "use the default", and the liters
and kwh checks, since a zero fill is not a fill.

Maintenance is the exception. Its odometer is the one that is genuinely
optional, so zero there still has to mean "not recorded" and those three
sites keep the truthiness test, commented. Fixing that properly wants a
nullable field rather than an int, which is a schema change and its own
commit — the same shape of problem as the latency em dash in 3c4eba8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:26:56 +02:00

404 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.
//
// A reading of 0 rolls from 0 like any other: a car collected new is
// genuinely there, and CurrentKm + RepeatKm is ahead of the car by
// construction, so this cannot land a target in the past.
payload["due_km"] = car.CurrentKm + 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 ""
}