- Renewal tracking: compute due/overdue status from the most recent mot/insurance event (renewal_months, renewal_due_soon_days settings), surfaced on the service card and dashboard. - Service-status card metrics now render as a two-column label/value table with row separators instead of wrapping auto-fill grid cells. - Add a bicycle favicon (favicon.svg) to base + login templates. Co-Authored-By: opencode <opencode@users.noreply.github.com>
195 lines
6.1 KiB
Python
195 lines
6.1 KiB
Python
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
from calendar import monthrange
|
|
from dataclasses import dataclass
|
|
from datetime import date
|
|
|
|
from . import db
|
|
from .config import get_settings
|
|
|
|
|
|
@dataclass
|
|
class RenewalDue:
|
|
"""MOT / insurance renewal snapshot for one bike.
|
|
|
|
renewal_date: the date the logged MOT/insurance took effect.
|
|
due_date: when it falls due (renewal_date + renewal_months).
|
|
days_until: due_date - today (negative when overdue).
|
|
status:
|
|
"no-data" -> no MOT/insurance event logged
|
|
"ok" -> more than the lead time from due
|
|
"due-soon" -> within the lead time of the due date
|
|
"overdue" -> past the due date
|
|
"""
|
|
|
|
renewal_date: date | None
|
|
due_date: date | None
|
|
days_until: int | None
|
|
status: str
|
|
|
|
|
|
def _add_months(d: date, months: int) -> date:
|
|
"""Clamp the day to the last valid day of the target month (e.g. Feb 29)."""
|
|
total = d.month - 1 + months
|
|
year = d.year + total // 12
|
|
month = total % 12 + 1
|
|
day = min(d.day, monthrange(year, month)[1])
|
|
return date(year, month, day)
|
|
|
|
|
|
def compute_renewal_due(
|
|
conn: sqlite3.Connection, bike: db.Bike, type: str, today: date | None = None
|
|
) -> RenewalDue:
|
|
"""Compute when the most recent `type` (mot/insurance) event falls due."""
|
|
if today is None:
|
|
today = date.today()
|
|
settings = get_settings()
|
|
|
|
last = db.latest_event_of_type(conn, bike.id, type)
|
|
if last is None:
|
|
return RenewalDue(None, None, None, "no-data")
|
|
|
|
renewal_date = last.event_date
|
|
due_date = _add_months(renewal_date, settings.renewal_months)
|
|
days_until = (due_date - today).days
|
|
if days_until < 0:
|
|
status = "overdue"
|
|
elif days_until <= settings.renewal_due_soon_days:
|
|
status = "due-soon"
|
|
else:
|
|
status = "ok"
|
|
|
|
return RenewalDue(renewal_date, due_date, days_until, status)
|
|
|
|
|
|
@dataclass
|
|
class ServiceDue:
|
|
"""Service-due snapshot for one bike.
|
|
|
|
status:
|
|
"no-data" -> cannot compute (missing current mileage or last service,
|
|
or no interval set)
|
|
"ok" -> still under the interval
|
|
"due-soon" -> within a short lead time of the interval - book it soon
|
|
"overdue" -> past the interval - book a service now
|
|
"""
|
|
|
|
current_mileage: int | None
|
|
last_service: db.BikeEvent | None
|
|
miles_since: int | None
|
|
miles_until_due: int | None # negative when overdue
|
|
status: str
|
|
|
|
|
|
def compute_service_due(conn: sqlite3.Connection, bike: db.Bike) -> ServiceDue:
|
|
# 'current_mileage' is derived from the most recent event that recorded a
|
|
# mileage, i.e. the bike's latest known odometer reading.
|
|
current_evt = db.latest_event_with_mileage(conn, bike.id)
|
|
current_mileage = current_evt.mileage if current_evt else None
|
|
|
|
# The 'last service' is the most recent event typed 'service' that ALSO has
|
|
# a mileage recorded, so we can compute a delta.
|
|
last_service = db.latest_event_of_type(conn, bike.id, "service")
|
|
if last_service is not None and last_service.mileage is None:
|
|
last_service = None
|
|
|
|
no_data = ServiceDue(current_mileage, last_service, None, None, "no-data")
|
|
if current_mileage is None or last_service is None or bike.service_interval_miles is None:
|
|
return no_data
|
|
|
|
miles_since = current_mileage - last_service.mileage
|
|
interval = bike.service_interval_miles
|
|
miles_until_due = interval - miles_since
|
|
if miles_since > interval:
|
|
status = "overdue"
|
|
elif miles_until_due <= get_settings().service_due_soon_miles:
|
|
status = "due-soon"
|
|
else:
|
|
status = "ok"
|
|
|
|
return ServiceDue(
|
|
current_mileage=current_mileage,
|
|
last_service=last_service,
|
|
miles_since=miles_since,
|
|
miles_until_due=miles_until_due,
|
|
status=status,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class BikeSummary:
|
|
bike: db.Bike
|
|
due: ServiceDue
|
|
mot: RenewalDue
|
|
insurance: RenewalDue
|
|
event_count: int
|
|
total_cost: int
|
|
|
|
|
|
def summarize_bike(conn: sqlite3.Connection, bike: db.Bike) -> BikeSummary:
|
|
return BikeSummary(
|
|
bike=bike,
|
|
due=compute_service_due(conn, bike),
|
|
mot=compute_renewal_due(conn, bike, "mot"),
|
|
insurance=compute_renewal_due(conn, bike, "insurance"),
|
|
event_count=db.count_events(conn, bike.id),
|
|
total_cost=db.sum_cost(conn, bike.id),
|
|
)
|
|
|
|
|
|
def all_summaries(conn: sqlite3.Connection) -> list[BikeSummary]:
|
|
"""All bikes with summaries; bikes needing attention first."""
|
|
summaries = [summarize_bike(conn, b) for b in db.list_bikes(conn)]
|
|
rank = {"overdue": 0, "due-soon": 1, "ok": 2, "no-data": 3}
|
|
summaries.sort(key=lambda s: (rank.get(s.due.status, 4), s.bike.name))
|
|
return summaries
|
|
|
|
|
|
def action_needed(conn: sqlite3.Connection) -> list[BikeSummary]:
|
|
"""Bikes past their service interval - book it now."""
|
|
return [s for s in all_summaries(conn) if s.due.status == "overdue"]
|
|
|
|
|
|
def due_soon(conn: sqlite3.Connection) -> list[BikeSummary]:
|
|
"""Bikes within the lead time of the interval - book it soon."""
|
|
return [s for s in all_summaries(conn) if s.due.status == "due-soon"]
|
|
|
|
|
|
def status_class(due: ServiceDue) -> str:
|
|
return {
|
|
"overdue": "badge-overdue",
|
|
"due-soon": "badge-due-soon",
|
|
"ok": "badge-ok",
|
|
"no-data": "badge-muted",
|
|
}[due.status]
|
|
|
|
|
|
def status_text(due: ServiceDue) -> str:
|
|
if due.status == "no-data":
|
|
return "no service logged"
|
|
if due.status == "due-soon":
|
|
return f"book soon - {due.miles_until_due:,} mi left"
|
|
if due.status == "ok":
|
|
return f"due in {due.miles_until_due:,} mi"
|
|
return f"overdue by {abs(due.miles_until_due):,} mi"
|
|
|
|
|
|
def renewal_class(r: RenewalDue) -> str:
|
|
return {
|
|
"overdue": "badge-overdue",
|
|
"due-soon": "badge-due-soon",
|
|
"ok": "badge-ok",
|
|
"no-data": "badge-muted",
|
|
}[r.status]
|
|
|
|
|
|
def renewal_text(r: RenewalDue) -> str:
|
|
if r.status == "no-data":
|
|
return "not logged"
|
|
if r.status == "overdue":
|
|
return f"overdue by {abs(r.days_until)} days"
|
|
if r.status == "due-soon":
|
|
return f"due in {r.days_until} days"
|
|
return f"due in {r.days_until} days"
|