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>
515 lines
19 KiB
Go
515 lines
19 KiB
Go
// Package models defines the domain types for the car maintenance tracker.
|
|
//
|
|
// The shapes mirror the original "Car Service.xlsx": one Car per sheet, a log
|
|
// of ServiceRecords (date + km, plus which parts were changed), and a per-car
|
|
// catalog of Parts (cols M/N). Derived fields follow the spreadsheet formulas:
|
|
//
|
|
// Next Service Date = Service Date + ServiceIntervalDays (Excel: A + 365)
|
|
// Next Service Km = Service Km + ServiceIntervalKm (Excel: B + 15000)
|
|
package models
|
|
|
|
import "time"
|
|
|
|
// Car corresponds to one worksheet in the original spreadsheet.
|
|
type Car struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"` // e.g. "Toyota Yaris"
|
|
Make string `json:"make"` // e.g. "Toyota"
|
|
Model string `json:"model"` // e.g. "Yaris"
|
|
Year int `json:"year"` // optional
|
|
Registration string `json:"registration"` // optional plate
|
|
RegistrationCountry string `json:"registrationCountry"` // optional (country of registration)
|
|
VIN string `json:"vin"` // optional
|
|
|
|
// Maintenance intervals, configurable per car. The spreadsheet hard-coded
|
|
// 365 days and 15000 km; here they are stored so each car can differ.
|
|
ServiceIntervalDays int `json:"serviceIntervalDays"`
|
|
ServiceIntervalKm int `json:"serviceIntervalKm"`
|
|
|
|
// CurrentKm is the car's present odometer reading, updated by the user. Used
|
|
// to flag km-based overdue service (current_km >= last service km + interval).
|
|
CurrentKm int `json:"currentKm"`
|
|
|
|
OilSpec string `json:"oilSpec"` // e.g. "Toyota Advanced Fuel Economy 0W20"
|
|
TransmissionOilSpec string `json:"transmissionOilSpec"` // e.g. "Toyota WS"
|
|
DifferentialOilSpec string `json:"differentialOilSpec"` // e.g. "SAE 75W-90 GL-5"
|
|
BrakeFluidSpec string `json:"brakeFluidSpec"` // e.g. "DOT 4"
|
|
CoolantSpec string `json:"coolantSpec"` // e.g. "Toyota Super Long Life Coolant"
|
|
|
|
FuelType string `json:"fuelType"` // petrol | diesel | hybrid | electric
|
|
BuildDate string `json:"buildDate"` // ISO YYYY-MM-DD (date-only)
|
|
FirstRegistrationDate string `json:"firstRegistrationDate"` // ISO YYYY-MM-DD (date-only)
|
|
|
|
// Owner is the user id that owns this car. Access is the requesting user's
|
|
// permission on it — "owner", "write", or "read" — computed by the API at
|
|
// read time and never persisted (omitempty; not part of the write payload).
|
|
Owner string `json:"owner,omitempty"`
|
|
Access string `json:"access,omitempty"`
|
|
|
|
Created string `json:"created,omitempty"`
|
|
Updated string `json:"updated,omitempty"`
|
|
}
|
|
|
|
// ServiceRecord is one row of the Service log for a car.
|
|
type ServiceRecord struct {
|
|
ID string `json:"id"`
|
|
Car string `json:"car"` // relation -> Car.ID
|
|
Date time.Time `json:"date"` // service date (Excel col A)
|
|
Km int `json:"km"` // odometer at service (Excel col B)
|
|
|
|
// "Changed Parts" checkboxes (Excel cols E/F/G).
|
|
ChangedOil bool `json:"changedOil"` // Oil & Oil Filter
|
|
ChangedEngineAirFilter bool `json:"changedEngineAirFilter"` // Engine Air Filter
|
|
ChangedCabinAirFilter bool `json:"changedCabinAirFilter"` // Cabin Air Filter
|
|
|
|
Notes string `json:"notes,omitempty"`
|
|
|
|
// Derived (not stored): filled in by the API on read.
|
|
NextServiceDate *time.Time `json:"nextServiceDate,omitempty"` // Excel col C
|
|
NextServiceKm *int `json:"nextServiceKm,omitempty"` // Excel col D
|
|
|
|
Created string `json:"created,omitempty"`
|
|
Updated string `json:"updated,omitempty"`
|
|
}
|
|
|
|
// Part is one entry in a car's parts catalog (Excel cols M/N).
|
|
type Part struct {
|
|
ID string `json:"id"`
|
|
Car string `json:"car"` // relation -> Car.ID
|
|
Name string `json:"name"` // e.g. "Oil Filter"
|
|
PartNumber string `json:"partNumber"` // e.g. "04152-YZZA7"
|
|
Category string `json:"category"` // optional: oil|filter|wiper|other
|
|
|
|
Created string `json:"created,omitempty"`
|
|
Updated string `json:"updated,omitempty"`
|
|
}
|
|
|
|
// FuelEntry is one refuelling stop for a car.
|
|
//
|
|
// Efficiency is derived by the full-tank method rather than stored: a tank
|
|
// filled to the brim is a known reference point, so the fuel burned between two
|
|
// consecutive full tanks is exactly what was poured in over that span. Partial
|
|
// fills in between are not measurable on their own — they roll into the next
|
|
// full tank's window. See ComputeFuelDerived.
|
|
type FuelEntry struct {
|
|
ID string `json:"id"`
|
|
Car string `json:"car"` // relation -> Car.ID
|
|
Date time.Time `json:"date"` // date of the refill
|
|
Km int `json:"km"` // odometer at the pump
|
|
Liters float64 `json:"liters"`
|
|
Cost float64 `json:"cost"` // total paid for this fill
|
|
|
|
// FullTank marks a fill to the brim — the reference point efficiency windows
|
|
// are measured between.
|
|
FullTank bool `json:"fullTank"`
|
|
|
|
// MissedFill records that a refill happened before this one without being
|
|
// logged. The odometer span is then not accounted for by the litres on
|
|
// record, so any window containing it is left uncomputed rather than
|
|
// reported as an implausibly good figure.
|
|
MissedFill bool `json:"missedFill"`
|
|
|
|
Station string `json:"station,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
|
|
// Derived (not stored): filled in by the API on read.
|
|
PricePerLiter *float64 `json:"pricePerLiter,omitempty"`
|
|
DistanceKm *int `json:"distanceKm,omitempty"` // since the previous full tank
|
|
LitersUsed *float64 `json:"litersUsed,omitempty"` // litres burned over that distance
|
|
ConsumptionL100 *float64 `json:"consumptionL100,omitempty"` // litres per 100 km
|
|
KmPerLiter *float64 `json:"kmPerLiter,omitempty"`
|
|
CostPerKm *float64 `json:"costPerKm,omitempty"`
|
|
|
|
Created string `json:"created,omitempty"`
|
|
Updated string `json:"updated,omitempty"`
|
|
}
|
|
|
|
// FuelStats summarises a car's whole refill history.
|
|
type FuelStats struct {
|
|
Entries int `json:"entries"`
|
|
TotalLiters float64 `json:"totalLiters"`
|
|
TotalCost float64 `json:"totalCost"`
|
|
|
|
// TrackedDistanceKm is the distance covered by computable full-tank windows,
|
|
// which is less than the odometer span whenever the history starts or ends
|
|
// on a partial fill. The averages below describe exactly this distance.
|
|
TrackedDistanceKm int `json:"trackedDistanceKm"`
|
|
|
|
AvgConsumptionL100 *float64 `json:"avgConsumptionL100,omitempty"`
|
|
BestConsumptionL100 *float64 `json:"bestConsumptionL100,omitempty"`
|
|
WorstConsumptionL100 *float64 `json:"worstConsumptionL100,omitempty"`
|
|
AvgKmPerLiter *float64 `json:"avgKmPerLiter,omitempty"`
|
|
AvgPricePerLiter *float64 `json:"avgPricePerLiter,omitempty"`
|
|
CostPerKm *float64 `json:"costPerKm,omitempty"`
|
|
|
|
FirstDate *time.Time `json:"firstDate,omitempty"`
|
|
LastDate *time.Time `json:"lastDate,omitempty"`
|
|
}
|
|
|
|
// MaintenanceEntry is one workshop visit or repair — work done on the car
|
|
// outside the routine service schedule (which lives in ServiceRecord). A broken
|
|
// alternator replaced at a garage belongs here; the annual oil change does not.
|
|
type MaintenanceEntry struct {
|
|
ID string `json:"id"`
|
|
Car string `json:"car"` // relation -> Car.ID
|
|
Date time.Time `json:"date"` // date of the visit
|
|
Km int `json:"km"` // odometer at the visit
|
|
|
|
Type string `json:"type"` // repair|inspection|bodywork|tyres|diagnostics|recall|warranty|other
|
|
Status string `json:"status"` // scheduled|in_progress|completed
|
|
Workshop string `json:"workshop"` // garage/workshop name
|
|
Location string `json:"location"` // optional: city or address
|
|
Description string `json:"description"` // what was done
|
|
PartsUsed string `json:"partsUsed"` // free-text list of parts replaced
|
|
|
|
LaborCost float64 `json:"laborCost"`
|
|
PartsCost float64 `json:"partsCost"`
|
|
|
|
InvoiceNumber string `json:"invoiceNumber,omitempty"`
|
|
WarrantyUntil *time.Time `json:"warrantyUntil,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
|
|
// Derived (not stored): filled in by the API on read.
|
|
TotalCost float64 `json:"totalCost"`
|
|
WarrantyActive *bool `json:"warrantyActive,omitempty"`
|
|
WarrantyDaysLeft *int `json:"warrantyDaysLeft,omitempty"`
|
|
|
|
Created string `json:"created,omitempty"`
|
|
Updated string `json:"updated,omitempty"`
|
|
}
|
|
|
|
// CarDocument is a piece of paperwork tied to a car — insurance policies,
|
|
// pollution/emissions certificates, registration papers, and so on. The renewal
|
|
// date is the point of the whole record: an expired policy is a car that cannot
|
|
// legally be driven, so Expiry is computed live on every read.
|
|
type CarDocument struct {
|
|
ID string `json:"id"`
|
|
Car string `json:"car"` // relation -> Car.ID
|
|
Type string `json:"type"` // insurance|pollution|registration|inspection|roadTax|warranty|other
|
|
Title string `json:"title"` // e.g. "Third-party liability 2026"
|
|
|
|
Provider string `json:"provider,omitempty"` // insurer / issuing authority
|
|
Reference string `json:"reference,omitempty"` // policy or certificate number
|
|
|
|
IssueDate *time.Time `json:"issueDate,omitempty"`
|
|
ExpiryDate *time.Time `json:"expiryDate,omitempty"` // blank = never expires
|
|
Cost float64 `json:"cost"`
|
|
Notes string `json:"notes,omitempty"`
|
|
|
|
// FileName is the stored attachment (scan/PDF), served via
|
|
// GET /api/car-documents/{id}/file. Empty when nothing is attached.
|
|
FileName string `json:"fileName,omitempty"`
|
|
HasFile bool `json:"hasFile"`
|
|
|
|
// Derived (not stored): filled in by the API on read.
|
|
Expiry ExpiryAssessment `json:"expiry"`
|
|
|
|
Created string `json:"created,omitempty"`
|
|
Updated string `json:"updated,omitempty"`
|
|
}
|
|
|
|
// ExpiryAssessment is the server-computed lifecycle state of a dated document.
|
|
type ExpiryAssessment struct {
|
|
State string `json:"state"` // no_expiry | valid | expiring_soon | expired
|
|
Days *int `json:"daysUntilExpiry"` // nil when there is no expiry date
|
|
}
|
|
|
|
// Reminder is something the user wants to be told about: a booked workshop slot,
|
|
// an insurance renewal, a tyre swap. A reminder fires on a date, an odometer
|
|
// reading, or both — whichever comes first.
|
|
type Reminder struct {
|
|
ID string `json:"id"`
|
|
Car string `json:"car"` // relation -> Car.ID
|
|
Title string `json:"title"`
|
|
Type string `json:"type"` // maintenance|document|service|inspection|other
|
|
|
|
DueDate *time.Time `json:"dueDate,omitempty"`
|
|
DueKm int `json:"dueKm,omitempty"`
|
|
|
|
// RepeatDays/RepeatKm turn a reminder into a recurring one: completing it
|
|
// rolls the trigger forward by this much instead of closing it out.
|
|
RepeatDays int `json:"repeatDays,omitempty"`
|
|
RepeatKm int `json:"repeatKm,omitempty"`
|
|
|
|
Done bool `json:"done"`
|
|
DoneAt *time.Time `json:"doneAt,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
|
|
// Derived (not stored): filled in by the API on read.
|
|
Status string `json:"status"` // done | overdue | due_soon | upcoming | no_trigger
|
|
DaysLeft *int `json:"daysLeft,omitempty"` // nil when there is no due date
|
|
KmLeft *int `json:"kmLeft,omitempty"` // nil when there is no due km / no odometer
|
|
Auto bool `json:"auto"` // true = derived from a document/service, read-only
|
|
SourceRef string `json:"sourceRef,omitempty"` // id of the record an auto reminder came from
|
|
|
|
Created string `json:"created,omitempty"`
|
|
Updated string `json:"updated,omitempty"`
|
|
}
|
|
|
|
// User is the authenticated account's profile, covering the Settings panel's
|
|
// Account/Profile/Appearance sections.
|
|
type User struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Verified bool `json:"verified"`
|
|
Name string `json:"name"`
|
|
Bio string `json:"bio"`
|
|
|
|
HasAvatar bool `json:"hasAvatar"`
|
|
|
|
Theme string `json:"theme"` // light | dark | system
|
|
Locale string `json:"locale"` // e.g. "en-US"
|
|
DateFormat string `json:"dateFormat"` // YMD | DMY | MDY
|
|
FontSize string `json:"fontSize"` // small | medium | large
|
|
Role string `json:"role"` // user | admin
|
|
|
|
// Non-empty while an account-deletion request is pending its cooldown.
|
|
DeletionRequestedAt *time.Time `json:"deletionRequestedAt,omitempty"`
|
|
|
|
Created string `json:"created,omitempty"`
|
|
}
|
|
|
|
// Session is one active login (device) for the current user.
|
|
type Session struct {
|
|
ID string `json:"id"`
|
|
DeviceLabel string `json:"deviceLabel"`
|
|
IP string `json:"ip"`
|
|
Current bool `json:"current"`
|
|
Created time.Time `json:"created"`
|
|
ExpiresAt time.Time `json:"expiresAt"`
|
|
}
|
|
|
|
// ComputeDerived fills NextServiceDate / NextServiceKm from the car's intervals,
|
|
// reproducing the spreadsheet formulas. Intervals of 0 fall back to the
|
|
// spreadsheet defaults (365 days, 15000 km).
|
|
func (r *ServiceRecord) ComputeDerived(c *Car) {
|
|
days := c.ServiceIntervalDays
|
|
if days <= 0 {
|
|
days = 365
|
|
}
|
|
km := c.ServiceIntervalKm
|
|
if km <= 0 {
|
|
km = 15000
|
|
}
|
|
if !r.Date.IsZero() {
|
|
d := r.Date.AddDate(0, 0, days)
|
|
r.NextServiceDate = &d
|
|
}
|
|
if r.Km > 0 {
|
|
n := r.Km + km
|
|
r.NextServiceKm = &n
|
|
}
|
|
}
|
|
|
|
// SoonDays is the window within which an upcoming expiry or reminder is
|
|
// surfaced as "due soon" rather than merely upcoming.
|
|
const SoonDays = 30
|
|
|
|
// soonKm mirrors SoonDays for odometer-triggered reminders.
|
|
const soonKm = 1000
|
|
|
|
// ComputeFuelDerived fills the derived efficiency fields on a car's refill
|
|
// history. `entries` must be ordered oldest-first by odometer.
|
|
//
|
|
// The full-tank method: between two consecutive full tanks the car burned
|
|
// exactly the fuel added over that span, because both endpoints are the same
|
|
// known level. Everything poured in after the earlier full tank up to and
|
|
// including the later one counts, which is what folds partial fills into the
|
|
// window that closes them. A window is left uncomputed when a fill inside it is
|
|
// flagged MissedFill, when the odometer did not advance, or when no litres were
|
|
// recorded — reporting a figure there would be fiction.
|
|
func ComputeFuelDerived(entries []FuelEntry) {
|
|
for i := range entries {
|
|
if entries[i].Liters > 0 && entries[i].Cost > 0 {
|
|
p := entries[i].Cost / entries[i].Liters
|
|
entries[i].PricePerLiter = &p
|
|
}
|
|
}
|
|
|
|
lastFull := -1
|
|
for i := range entries {
|
|
if !entries[i].FullTank {
|
|
continue
|
|
}
|
|
if lastFull < 0 {
|
|
// First full tank: nothing before it to measure against.
|
|
lastFull = i
|
|
continue
|
|
}
|
|
|
|
dist := entries[i].Km - entries[lastFull].Km
|
|
liters, cost := 0.0, 0.0
|
|
usable := true
|
|
for j := lastFull + 1; j <= i; j++ {
|
|
if entries[j].MissedFill {
|
|
usable = false
|
|
}
|
|
liters += entries[j].Liters
|
|
cost += entries[j].Cost
|
|
}
|
|
|
|
if usable && dist > 0 && liters > 0 {
|
|
d, l := dist, liters
|
|
entries[i].DistanceKm = &d
|
|
entries[i].LitersUsed = &l
|
|
|
|
l100 := liters / float64(dist) * 100
|
|
entries[i].ConsumptionL100 = &l100
|
|
|
|
kmpl := float64(dist) / liters
|
|
entries[i].KmPerLiter = &kmpl
|
|
|
|
if cost > 0 {
|
|
cpk := cost / float64(dist)
|
|
entries[i].CostPerKm = &cpk
|
|
}
|
|
}
|
|
lastFull = i
|
|
}
|
|
}
|
|
|
|
// ComputeFuelStats summarises a refill history whose derived fields have already
|
|
// been filled in by ComputeFuelDerived. `entries` must be ordered oldest-first.
|
|
//
|
|
// Averages are distance-weighted — total litres over total distance across every
|
|
// computable window — rather than a mean of the per-window figures, so a long
|
|
// motorway run counts for more than a short trip across town, which is what
|
|
// actually happened to the fuel.
|
|
func ComputeFuelStats(entries []FuelEntry) FuelStats {
|
|
st := FuelStats{Entries: len(entries)}
|
|
if len(entries) == 0 {
|
|
return st
|
|
}
|
|
|
|
var windowLiters, windowCost float64
|
|
for i := range entries {
|
|
e := &entries[i]
|
|
st.TotalLiters += e.Liters
|
|
st.TotalCost += e.Cost
|
|
|
|
if e.ConsumptionL100 == nil {
|
|
continue
|
|
}
|
|
st.TrackedDistanceKm += *e.DistanceKm
|
|
windowLiters += *e.LitersUsed
|
|
if e.CostPerKm != nil {
|
|
windowCost += *e.CostPerKm * float64(*e.DistanceKm)
|
|
}
|
|
if st.BestConsumptionL100 == nil || *e.ConsumptionL100 < *st.BestConsumptionL100 {
|
|
v := *e.ConsumptionL100
|
|
st.BestConsumptionL100 = &v
|
|
}
|
|
if st.WorstConsumptionL100 == nil || *e.ConsumptionL100 > *st.WorstConsumptionL100 {
|
|
v := *e.ConsumptionL100
|
|
st.WorstConsumptionL100 = &v
|
|
}
|
|
}
|
|
|
|
if st.TrackedDistanceKm > 0 && windowLiters > 0 {
|
|
avg := windowLiters / float64(st.TrackedDistanceKm) * 100
|
|
st.AvgConsumptionL100 = &avg
|
|
kmpl := float64(st.TrackedDistanceKm) / windowLiters
|
|
st.AvgKmPerLiter = &kmpl
|
|
if windowCost > 0 {
|
|
cpk := windowCost / float64(st.TrackedDistanceKm)
|
|
st.CostPerKm = &cpk
|
|
}
|
|
}
|
|
if st.TotalLiters > 0 && st.TotalCost > 0 {
|
|
ppl := st.TotalCost / st.TotalLiters
|
|
st.AvgPricePerLiter = &ppl
|
|
}
|
|
|
|
first, last := entries[0].Date, entries[len(entries)-1].Date
|
|
if !first.IsZero() {
|
|
st.FirstDate = &first
|
|
}
|
|
if !last.IsZero() {
|
|
st.LastDate = &last
|
|
}
|
|
return st
|
|
}
|
|
|
|
// ComputeMaintenanceDerived fills the derived cost and warranty fields.
|
|
func (m *MaintenanceEntry) ComputeMaintenanceDerived(now time.Time) {
|
|
m.TotalCost = m.LaborCost + m.PartsCost
|
|
if m.WarrantyUntil == nil || m.WarrantyUntil.IsZero() {
|
|
return
|
|
}
|
|
days := daysBetween(now, *m.WarrantyUntil)
|
|
active := days >= 0
|
|
m.WarrantyActive = &active
|
|
m.WarrantyDaysLeft = &days
|
|
}
|
|
|
|
// ComputeExpiry classifies a document by its expiry date relative to `now`.
|
|
func (d *CarDocument) ComputeExpiry(now time.Time) {
|
|
if d.ExpiryDate == nil || d.ExpiryDate.IsZero() {
|
|
d.Expiry = ExpiryAssessment{State: "no_expiry"}
|
|
return
|
|
}
|
|
days := daysBetween(now, *d.ExpiryDate)
|
|
state := "valid"
|
|
switch {
|
|
case days < 0:
|
|
state = "expired"
|
|
case days <= SoonDays:
|
|
state = "expiring_soon"
|
|
}
|
|
d.Expiry = ExpiryAssessment{State: state, Days: &days}
|
|
}
|
|
|
|
// ComputeReminderDerived resolves a reminder's status against today's date and
|
|
// the car's current odometer. A reminder with both triggers fires on whichever
|
|
// arrives first, so the worse of the two signals wins.
|
|
func (r *Reminder) ComputeReminderDerived(now time.Time, currentKm int) {
|
|
if r.Done {
|
|
r.Status = "done"
|
|
return
|
|
}
|
|
|
|
rank := map[string]int{"no_trigger": 0, "upcoming": 1, "due_soon": 2, "overdue": 3}
|
|
status := "no_trigger"
|
|
worsen := func(s string) {
|
|
if rank[s] > rank[status] {
|
|
status = s
|
|
}
|
|
}
|
|
|
|
if r.DueDate != nil && !r.DueDate.IsZero() {
|
|
days := daysBetween(now, *r.DueDate)
|
|
r.DaysLeft = &days
|
|
switch {
|
|
case days < 0:
|
|
worsen("overdue")
|
|
case days <= SoonDays:
|
|
worsen("due_soon")
|
|
default:
|
|
worsen("upcoming")
|
|
}
|
|
}
|
|
|
|
if r.DueKm > 0 && currentKm > 0 {
|
|
left := r.DueKm - currentKm
|
|
r.KmLeft = &left
|
|
switch {
|
|
case left < 0:
|
|
worsen("overdue")
|
|
case left <= soonKm:
|
|
worsen("due_soon")
|
|
default:
|
|
worsen("upcoming")
|
|
}
|
|
}
|
|
|
|
r.Status = status
|
|
}
|
|
|
|
// daysBetween returns whole days from `now` to `target`, both truncated to the
|
|
// day, so a deadline later today reads as 0 rather than a fraction.
|
|
func daysBetween(now, target time.Time) int {
|
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
|
t := time.Date(target.Year(), target.Month(), target.Day(), 0, 0, 0, 0, time.UTC)
|
|
return int(t.Sub(today).Hours() / 24)
|
|
}
|