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"