Three things, all on a car's page. A Charging cost tab, which is the Fuel cost tab written for an electric car: charges in kWh, consumption in kWh/100km beside km per kWh, cost per km and price per kWh, and the same summary panel over the whole history. It keeps the reference-point method too, and has to — a session records the energy that went in, not what was left in the battery, so a given number of kWh only maps to a distance between two charges that ended at the same state. A charge to the car's usual full point plays the part of the full tank; partial charges still count towards the cost and roll into the next full one; and a charge taken without logging it leaves its window uncomputed rather than reporting an implausibly good figure. Sessions live in their own collection, the figures are derived on read like the fuel ones, and logging a charge advances the odometer exactly as a refill does. The tab switches off from the gear like every other, so a petrol car need never see it. The headline readings on the connected-service tab now drag into any order, saved on drop. Stored on the car as metricOrder, like the Information rows, rather than per device the way the collapsed cards are: an arrangement is something everyone the car is shared with should see, where a folded card is one browser's reading habit. Only what the provider reported can be arranged, so a reading that turns up later joins the end rather than displacing the arrangement. Fuel is now Fuel cost, tab and heading, which is what the tab has always been about and what pairs it with Charging cost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
583 lines
18 KiB
Go
583 lines
18 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"time"
|
|
|
|
"drivervault/apiserver/internal/models"
|
|
)
|
|
|
|
// PocketBase stores datetimes as e.g. "2015-06-12 00:00:00.000Z". These layouts
|
|
// are tried (in order) when parsing values coming back from PocketBase.
|
|
var pbDateLayouts = []string{
|
|
"2006-01-02 15:04:05.000Z",
|
|
"2006-01-02 15:04:05Z",
|
|
time.RFC3339,
|
|
"2006-01-02",
|
|
}
|
|
|
|
func parsePBDate(s string) time.Time {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return time.Time{}
|
|
}
|
|
for _, l := range pbDateLayouts {
|
|
if t, err := time.Parse(l, s); err == nil {
|
|
return t
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
// formatPBDate renders a date in the format PocketBase expects on write.
|
|
func formatPBDate(t time.Time) string {
|
|
if t.IsZero() {
|
|
return ""
|
|
}
|
|
return t.UTC().Format("2006-01-02 15:04:05.000Z")
|
|
}
|
|
|
|
// --- cars ---
|
|
|
|
// carRecord is the PocketBase-facing shape of a car (snake_case fields).
|
|
type carRecord struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Make string `json:"make"`
|
|
Model string `json:"model"`
|
|
Year int `json:"year"`
|
|
Registration string `json:"registration"`
|
|
RegistrationCountry string `json:"registration_country"`
|
|
VIN string `json:"vin"`
|
|
ServiceIntervalDays int `json:"service_interval_days"`
|
|
ServiceIntervalKm int `json:"service_interval_km"`
|
|
TechnicalCheckIntervalDays int `json:"technical_check_interval_days"`
|
|
OilSpec string `json:"oil_spec"`
|
|
TransmissionOilSpec string `json:"transmission_oil_spec"`
|
|
DifferentialOilSpec string `json:"differential_oil_spec"`
|
|
BrakeFluidSpec string `json:"brake_fluid_spec"`
|
|
CoolantSpec string `json:"coolant_spec"`
|
|
CurrentKm int `json:"current_km"`
|
|
FuelType string `json:"fuel_type"`
|
|
BuildDate string `json:"build_date"`
|
|
FirstRegistrationDate string `json:"first_registration_date"`
|
|
Provider string `json:"provider"`
|
|
ProviderVehicleID string `json:"provider_vehicle_id"`
|
|
Owner string `json:"owner"`
|
|
Created string `json:"created"`
|
|
Updated string `json:"updated"`
|
|
|
|
// Switched-off tabs and Information fields, plus the arrangements of the
|
|
// Information rows and the connected service's readings. Raw because
|
|
// PocketBase hands back whatever a json field holds — null on a car nobody
|
|
// has configured — which is not a []string.
|
|
HiddenTabs json.RawMessage `json:"hidden_tabs"`
|
|
HiddenFields json.RawMessage `json:"hidden_fields"`
|
|
FieldOrder json.RawMessage `json:"field_order"`
|
|
MetricOrder json.RawMessage `json:"metric_order"`
|
|
}
|
|
|
|
func (rec carRecord) toModel() models.Car {
|
|
return models.Car{
|
|
ID: rec.ID,
|
|
Name: rec.Name,
|
|
Make: rec.Make,
|
|
Model: rec.Model,
|
|
Year: rec.Year,
|
|
Registration: rec.Registration,
|
|
RegistrationCountry: rec.RegistrationCountry,
|
|
VIN: rec.VIN,
|
|
ServiceIntervalDays: rec.ServiceIntervalDays,
|
|
ServiceIntervalKm: rec.ServiceIntervalKm,
|
|
TechnicalCheckIntervalDays: rec.TechnicalCheckIntervalDays,
|
|
OilSpec: rec.OilSpec,
|
|
TransmissionOilSpec: rec.TransmissionOilSpec,
|
|
DifferentialOilSpec: rec.DifferentialOilSpec,
|
|
BrakeFluidSpec: rec.BrakeFluidSpec,
|
|
CoolantSpec: rec.CoolantSpec,
|
|
CurrentKm: rec.CurrentKm,
|
|
FuelType: rec.FuelType,
|
|
BuildDate: rec.BuildDate,
|
|
FirstRegistrationDate: rec.FirstRegistrationDate,
|
|
Provider: rec.Provider,
|
|
ProviderVehicleID: rec.ProviderVehicleID,
|
|
HiddenTabs: decodeStringList(rec.HiddenTabs),
|
|
HiddenFields: decodeStringList(rec.HiddenFields),
|
|
FieldOrder: decodeStringList(rec.FieldOrder),
|
|
MetricOrder: decodeStringList(rec.MetricOrder),
|
|
Owner: rec.Owner,
|
|
Created: rec.Created,
|
|
Updated: rec.Updated,
|
|
}
|
|
}
|
|
|
|
// carPayload builds the write payload for create/update from a domain Car. It
|
|
// deliberately omits owner and the provider link: a car edit must not reassign
|
|
// ownership, and it must not touch the connected-service link either (that is
|
|
// carProviderPayload's job, reached only through the provider endpoints).
|
|
func carPayload(c models.Car) map[string]any {
|
|
return map[string]any{
|
|
"name": c.Name,
|
|
"make": c.Make,
|
|
"model": c.Model,
|
|
"year": c.Year,
|
|
"registration": c.Registration,
|
|
"registration_country": c.RegistrationCountry,
|
|
"vin": c.VIN,
|
|
"service_interval_days": c.ServiceIntervalDays,
|
|
"service_interval_km": c.ServiceIntervalKm,
|
|
"technical_check_interval_days": c.TechnicalCheckIntervalDays,
|
|
"oil_spec": c.OilSpec,
|
|
"transmission_oil_spec": c.TransmissionOilSpec,
|
|
"differential_oil_spec": c.DifferentialOilSpec,
|
|
"brake_fluid_spec": c.BrakeFluidSpec,
|
|
"coolant_spec": c.CoolantSpec,
|
|
"current_km": c.CurrentKm,
|
|
"fuel_type": c.FuelType,
|
|
"build_date": c.BuildDate,
|
|
"first_registration_date": c.FirstRegistrationDate,
|
|
}
|
|
}
|
|
|
|
// carProviderPayload is the connected-service link on its own, so linking and
|
|
// unlinking is a one-field write that leaves the rest of the car alone. An empty
|
|
// provider clears both fields (unlink).
|
|
func carProviderPayload(provider, vehicleID string) map[string]any {
|
|
if provider == "" {
|
|
return map[string]any{"provider": "", "provider_vehicle_id": ""}
|
|
}
|
|
return map[string]any{"provider": provider, "provider_vehicle_id": vehicleID}
|
|
}
|
|
|
|
// --- service records ---
|
|
|
|
type serviceRecord struct {
|
|
ID string `json:"id"`
|
|
Car string `json:"car"`
|
|
Date string `json:"date"`
|
|
Km int `json:"km"`
|
|
ChangedOil bool `json:"changed_oil"`
|
|
ChangedEngineAirFilter bool `json:"changed_engine_air_filter"`
|
|
ChangedCabinAirFilter bool `json:"changed_cabin_air_filter"`
|
|
Notes string `json:"notes"`
|
|
File string `json:"file"`
|
|
Created string `json:"created"`
|
|
Updated string `json:"updated"`
|
|
}
|
|
|
|
func (rec serviceRecord) toModel() models.ServiceRecord {
|
|
return models.ServiceRecord{
|
|
ID: rec.ID,
|
|
Car: rec.Car,
|
|
Date: parsePBDate(rec.Date),
|
|
Km: rec.Km,
|
|
ChangedOil: rec.ChangedOil,
|
|
ChangedEngineAirFilter: rec.ChangedEngineAirFilter,
|
|
ChangedCabinAirFilter: rec.ChangedCabinAirFilter,
|
|
Notes: rec.Notes,
|
|
Attachment: attachmentOf(rec.File),
|
|
Created: rec.Created,
|
|
Updated: rec.Updated,
|
|
}
|
|
}
|
|
|
|
// servicePayload omits the file field — see attachmentOf.
|
|
func servicePayload(r models.ServiceRecord) map[string]any {
|
|
return map[string]any{
|
|
"car": r.Car,
|
|
"date": formatPBDate(r.Date),
|
|
"km": r.Km,
|
|
"changed_oil": r.ChangedOil,
|
|
"changed_engine_air_filter": r.ChangedEngineAirFilter,
|
|
"changed_cabin_air_filter": r.ChangedCabinAirFilter,
|
|
"notes": r.Notes,
|
|
}
|
|
}
|
|
|
|
// --- technical checks ---
|
|
|
|
type technicalCheckRecord struct {
|
|
ID string `json:"id"`
|
|
Car string `json:"car"`
|
|
Date string `json:"date"`
|
|
Result string `json:"result"`
|
|
Cost float64 `json:"cost"`
|
|
Station string `json:"station"`
|
|
ValidUntil string `json:"valid_until"`
|
|
Notes string `json:"notes"`
|
|
File string `json:"file"`
|
|
Created string `json:"created"`
|
|
Updated string `json:"updated"`
|
|
}
|
|
|
|
func (rec technicalCheckRecord) toModel() models.TechnicalCheck {
|
|
return models.TechnicalCheck{
|
|
ID: rec.ID,
|
|
Car: rec.Car,
|
|
Date: parsePBDate(rec.Date),
|
|
Result: rec.Result,
|
|
Cost: rec.Cost,
|
|
Station: rec.Station,
|
|
ValidUntil: parsePBDatePtr(rec.ValidUntil),
|
|
Notes: rec.Notes,
|
|
Attachment: attachmentOf(rec.File),
|
|
Created: rec.Created,
|
|
Updated: rec.Updated,
|
|
}
|
|
}
|
|
|
|
// technicalCheckPayload omits the file field — see attachmentOf.
|
|
func technicalCheckPayload(t models.TechnicalCheck) map[string]any {
|
|
return map[string]any{
|
|
"car": t.Car,
|
|
"date": formatPBDate(t.Date),
|
|
"result": t.Result,
|
|
"cost": t.Cost,
|
|
"station": t.Station,
|
|
"valid_until": formatPBDatePtr(t.ValidUntil),
|
|
"notes": t.Notes,
|
|
}
|
|
}
|
|
|
|
// --- attachments ---
|
|
|
|
// attachmentOf renders a PocketBase file field into the model's attachment pair.
|
|
//
|
|
// It has no counterpart on the write side on purpose: attachments move over
|
|
// multipart via their own endpoint (attachments.go), never as JSON, so a
|
|
// metadata write must not carry a file field that would blank an existing
|
|
// upload.
|
|
func attachmentOf(file string) models.Attachment {
|
|
return models.Attachment{FileName: file, HasFile: file != ""}
|
|
}
|
|
|
|
// --- parts ---
|
|
|
|
type partRecord struct {
|
|
ID string `json:"id"`
|
|
Car string `json:"car"`
|
|
Name string `json:"name"`
|
|
PartNumber string `json:"part_number"`
|
|
Category string `json:"category"`
|
|
Notes string `json:"notes"`
|
|
File string `json:"file"`
|
|
Created string `json:"created"`
|
|
Updated string `json:"updated"`
|
|
}
|
|
|
|
func (rec partRecord) toModel() models.Part {
|
|
return models.Part{
|
|
ID: rec.ID,
|
|
Car: rec.Car,
|
|
Name: rec.Name,
|
|
PartNumber: rec.PartNumber,
|
|
Category: rec.Category,
|
|
Notes: rec.Notes,
|
|
Attachment: attachmentOf(rec.File),
|
|
Created: rec.Created,
|
|
Updated: rec.Updated,
|
|
}
|
|
}
|
|
|
|
// partPayload omits the file field — see attachmentOf.
|
|
func partPayload(p models.Part) map[string]any {
|
|
return map[string]any{
|
|
"car": p.Car,
|
|
"name": p.Name,
|
|
"part_number": p.PartNumber,
|
|
"category": p.Category,
|
|
"notes": p.Notes,
|
|
}
|
|
}
|
|
|
|
// 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"`
|
|
File string `json:"file"`
|
|
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,
|
|
Attachment: attachmentOf(rec.File),
|
|
Created: rec.Created,
|
|
Updated: rec.Updated,
|
|
}
|
|
}
|
|
|
|
// fuelPayload omits the file field — see attachmentOf.
|
|
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,
|
|
}
|
|
}
|
|
|
|
// --- charging sessions ---
|
|
|
|
type chargingRecord struct {
|
|
ID string `json:"id"`
|
|
Car string `json:"car"`
|
|
Date string `json:"date"`
|
|
Km int `json:"km"`
|
|
Kwh float64 `json:"kwh"`
|
|
Cost float64 `json:"cost"`
|
|
FullCharge bool `json:"full_charge"`
|
|
MissedSession bool `json:"missed_session"`
|
|
Location string `json:"location"`
|
|
Notes string `json:"notes"`
|
|
File string `json:"file"`
|
|
Created string `json:"created"`
|
|
Updated string `json:"updated"`
|
|
}
|
|
|
|
func (rec chargingRecord) toModel() models.ChargingSession {
|
|
return models.ChargingSession{
|
|
ID: rec.ID,
|
|
Car: rec.Car,
|
|
Date: parsePBDate(rec.Date),
|
|
Km: rec.Km,
|
|
Kwh: rec.Kwh,
|
|
Cost: rec.Cost,
|
|
FullCharge: rec.FullCharge,
|
|
MissedSession: rec.MissedSession,
|
|
Location: rec.Location,
|
|
Notes: rec.Notes,
|
|
Attachment: attachmentOf(rec.File),
|
|
Created: rec.Created,
|
|
Updated: rec.Updated,
|
|
}
|
|
}
|
|
|
|
// chargingPayload omits the file field — see attachmentOf.
|
|
func chargingPayload(c models.ChargingSession) map[string]any {
|
|
return map[string]any{
|
|
"car": c.Car,
|
|
"date": formatPBDate(c.Date),
|
|
"km": c.Km,
|
|
"kwh": c.Kwh,
|
|
"cost": c.Cost,
|
|
"full_charge": c.FullCharge,
|
|
"missed_session": c.MissedSession,
|
|
"location": c.Location,
|
|
"notes": c.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"`
|
|
File string `json:"file"`
|
|
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,
|
|
Attachment: attachmentOf(rec.File),
|
|
Created: rec.Created,
|
|
Updated: rec.Updated,
|
|
}
|
|
}
|
|
|
|
// maintenancePayload omits the file field — see attachmentOf.
|
|
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,
|
|
Attachment: attachmentOf(rec.File),
|
|
Created: rec.Created,
|
|
Updated: rec.Updated,
|
|
}
|
|
}
|
|
|
|
// documentPayload omits the file field — see attachmentOf.
|
|
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,
|
|
}
|
|
}
|