Files
bike_admin/app/services.py
T
2026-09-09 17:49:01 +01:00

116 lines
3.7 KiB
Python

from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from . import db
from .config import get_settings
@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
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),
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"