Documents already carried a single optional file: upload, download, detach,
access-checked on every request and proxied through this server, so PocketBase's
files are never public URLs. Service records, workshop visits, refills and
catalog parts all want the same thing — a receipt, an invoice, a photo of the
box — so extend it to them.
Rather than copy the document handlers four more times, lift them into one
shared layer. Every attachable collection has a car relation and a file field,
which is what lets a single set of handlers authorize and serve all of them. An
upload finishes by delegating to the collection's own GET handler, so the
response carries the full record — derived fields and all — exactly as a re-read
would. The web side gets the same treatment: one picker component and one
upload-after-save helper behind all five forms. Net effect is five features for
about the cost of the one that was already there.
Alongside:
- parts gain a notes field
- Reminders moves behind Parts catalog in the car detail tabs
- the changed-part labels spell out in full ("Oil & Oil filter" rather than
"Oil & filter"), and the form and table now agree
The PocketBase schema must be migrated before the new attachments work:
scripts/setup-pocketbase.mjs adds the file fields and parts.notes. It is
additive and safe to re-run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
538 lines
19 KiB
Go
538 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"
|
|
|
|
// Attachment is the single optional file a record carries — a scan, a receipt, a
|
|
// workshop invoice, a photo of a part's box. Embedded by every type that can
|
|
// hold one.
|
|
//
|
|
// FileName is the name PocketBase stored it under. The bytes are not in here:
|
|
// they are served from GET /api/{records}/{id}/file, which re-checks access on
|
|
// every request, so an attachment is never a public URL.
|
|
type Attachment struct {
|
|
FileName string `json:"fileName,omitempty"`
|
|
HasFile bool `json:"hasFile"`
|
|
}
|
|
|
|
// 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"`
|
|
|
|
// The workshop receipt or stamped service-book page for this visit.
|
|
Attachment
|
|
|
|
// 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
|
|
Notes string `json:"notes,omitempty"`
|
|
|
|
// A photo of the box, or the spec sheet for the part.
|
|
Attachment
|
|
|
|
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"`
|
|
|
|
// The pump receipt.
|
|
Attachment
|
|
|
|
// 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"`
|
|
|
|
// The workshop's invoice.
|
|
Attachment
|
|
|
|
// 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"`
|
|
|
|
// The scan or photo of the paperwork itself.
|
|
Attachment
|
|
|
|
// 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)
|
|
}
|