Two things, both about layouts you arrange by dragging.
A car's tab bar now takes a drag: the tabs reorder as the pointer crosses
them and the arrangement saves on drop — or on dragend, since a tab
released in the gap beside the bar never produces a drop and would
otherwise revert on the next load. Same native drag events as the garage
and the Information rows, so also pointer-only, and it needs write access.
The order belongs to the car, like the choice of which tabs show at all,
so everyone it is shared with sees the same bar. It is stored as the full
list of keys, hidden tabs included, so a tab switched off and back on
returns to where it was rather than to the end; a key the stored
arrangement doesn't mention — a tab added in a later release — follows
the arranged ones. Information is arrangeable although it cannot be
switched off, which is why the validation needs arrangeableCarTabs rather
than reusing hideableCarTabs; it is derived from that set so the two
cannot drift as tabs are added. tabOrder rides on the existing PUT
/api/cars/{id}/view, so a tab drag never has to resend what is hidden.
Where the page opens is unchanged: Information, wherever it now sits.
And a padlock in the sidebar, above the theme toggle, holds every
arrangement in the app still at once — the garage, a car's tabs, its
Information rows, the provider's readings. It is a guard against nudging
a layout while reading it, not a permission: it is the user's own setting
and says nothing about what anybody may edit, so locking hides your own
drag handles rather than stopping a co-owner rearranging a shared car.
Stored as dragLocked on the profile, like the theme it sits above, so a
locked account is still locked on the next device — where a folded
provider card stays one browser's reading habit. Unlocked by default, so
nothing changes until it is clicked, and while locked the grab cursor and
the drag hints go with the drag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
585 lines
18 KiB
Go
585 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
|
|
// tabs, 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"`
|
|
TabOrder json.RawMessage `json:"tab_order"`
|
|
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),
|
|
TabOrder: decodeStringList(rec.TabOrder),
|
|
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,
|
|
}
|
|
}
|