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"