Files
DriverVault/API Server/internal/api/records.go
T
tajniak81andClaude Opus 5 b60d929ed6 Service history: columns you can switch off and rearrange
A car's page has let you choose and arrange two things for a while - which tabs
it shows, and which rows the Information tab lists, both dragged into whatever
order you like. The Service history table was left out of that: nine columns,
hardcoded, in one order, for every car. An EV shows Oil & Oil filter and Engine
air filter on every row of a history that will never record either, and a reader
who mostly wants Notes has to look past four columns of dates and distances to
reach it.

It works the way the other two do, because a third mechanism for the same idea
would be one to keep in step. Both lists are properties of the car, so everyone
it is shared with sees the same table, and both need write access to set. The
columns are stored as the hidden set rather than the visible one, so a column
added in a later release is on by default. The arrangement covers the hidden
columns too, which is what makes a column switched back on return to where it
was instead of reappearing at the end - verified below, since that is the part
of this shape that is easy to get wrong and invisible until somebody hits it.

Date cannot be switched off. Every row of that table is work done on a day, and
a history with the day taken out stops being a history; it can still be dragged
anywhere, which is exactly the rule Information already follows in the tab bar.
That is a judgment call and the annotation that prompted this only circled the
other eight columns - moving "date" into hideableServiceColumns and dropping the
filter in the picker would reverse it in two lines if it turns out to be wrong.

Server: hidden_service_columns and service_column_order on the car, validated
against their own key sets by the endpoint that already does this for tabs,
fields and readings. The arrangeable set is derived from the hideable one plus
the date rather than written out again, so the two cannot drift as columns are
added. Bootstrap appends missing fields to existing collections, so the two
columns appear on the next server start with no migration to run.

Web: the table stopped being nine hardcoded th/td pairs and is now driven by one
list of columns, head and body from the same source, which is what stops a moved
or hidden column from shifting the headings out of line with the cells. The
cells are built a row at a time rather than a call per cell, so a long history
doesn't rebuild every cell three times to read its text, its classes and whether
it is the file column. The column headings kept their existing car.services.col*
translations - the keys are mapped rather than derived, because renaming a dozen
strings in three languages to save a lookup table would be the wrong trade. Four
new strings in all three languages.

Verified: go vet and go test ./... pass, with new tests covering both key sets -
that hiding the date is refused, that a field key is not a column key, and that
the arrangeable set is the hideable one plus the date. npm run build is clean.
The page itself was driven in a browser against a throwaway stub API: the
rewritten table renders identically to the hardcoded one, switching two columns
off removed exactly those two from head and body with the rest still aligned and
sent {"hiddenServiceColumns":["oil","engineFilter"]}, dragging Notes onto Km
reordered head and body live and saved an order with the hidden columns still
holding their places, switching Oil back on returned it between Next km and
Cabin air filter rather than to the end, and a read-only share gets no gear
button, no draggable headings and no drag hint.

Not verified: the drag was exercised by dispatching drag events at the
component's own handlers, not by a pointer - the browser pane was not
compositing, which rules out both screenshots and a real drag - so the native
drag image and cursor are unchecked. No automated test guards any of the web
behaviour; the web app still has no test runner. The API rejects unknown JSON
fields, so this web build against an older API Server would take a 400 when
saving the picker: they deploy together from this repo, but one must not ship
without the other. The phone app is deliberately untouched, having no column
table to arrange, and ignores both new fields.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 10:31:52 +02:00

590 lines
19 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, Information fields and Service history columns, plus
// the arrangements of the tabs, the Information rows, those columns 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"`
HiddenServiceColumns json.RawMessage `json:"hidden_service_columns"`
TabOrder json.RawMessage `json:"tab_order"`
FieldOrder json.RawMessage `json:"field_order"`
ServiceColumnOrder json.RawMessage `json:"service_column_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),
HiddenServiceColumns: decodeStringList(rec.HiddenServiceColumns),
TabOrder: decodeStringList(rec.TabOrder),
FieldOrder: decodeStringList(rec.FieldOrder),
ServiceColumnOrder: decodeStringList(rec.ServiceColumnOrder),
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,
}
}