Costs rendered as bare numbers because the project had no currency to render them with. Add one to the profile, beside the existing appearance preferences: - users.currency in PocketBase, exposed via /api/me, validated against the same 28 codes in the schema, the API and the web app. - Settings offers the European currencies plus the non-European ones the panel already had. Labels come from Intl.DisplayNames rather than a hand-kept table, so the lists read in the user's own language and sort by what is actually on screen. The single "Language & region" picker becomes two controls over the one stored BCP-47 tag, covering European languages and countries, so the two can be mixed (English in Poland). The API now enforces the language-REGION shape: the web app feeds the tag straight to Intl, which throws on a malformed one rather than falling back. formatKm and the km-count labels passed no locale, so kilometres followed the browser while the dates and costs beside them followed the saved preference. They now share one helper that uses the preference. Rename the "Maintenance log" tab to "Maintenance", the name the reminder-type list already used. Amounts are display-only: nothing is converted, so changing currency reinterprets existing records rather than recalculating them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
539 lines
19 KiB
Go
539 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
|
|
Currency string `json:"currency"` // ISO 4217 code, e.g. "EUR"
|
|
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)
|
|
}
|