diff --git a/app/config.py b/app/config.py index 9a35140..4d34ce5 100644 --- a/app/config.py +++ b/app/config.py @@ -31,6 +31,13 @@ class Settings(BaseSettings): # Service hint: flag a bike as "book soon" when it is this close to due. service_due_soon_miles: int = 200 + # Renewal hint: flag MOT/insurance as "due soon" when it falls due within + # this many days (default 60 days). + renewal_due_soon_days: int = 60 + + # Months a logged MOT/insurance event is valid before it falls due. + renewal_months: int = 12 + # Event types (preset) event_types: list[str] = [ "service", @@ -39,6 +46,8 @@ class Settings(BaseSettings): "chain", "brake", "repair", + "mot", + "insurance", "other", ] diff --git a/app/main.py b/app/main.py index 71d24c9..9109c83 100644 --- a/app/main.py +++ b/app/main.py @@ -230,6 +230,8 @@ templates.env.filters["money"] = lambda v: f"{v:,}" if v is not None else "" templates.env.globals["event_types"] = get_settings().event_types templates.env.globals["status_class"] = svc.status_class templates.env.globals["status_text"] = svc.status_text +templates.env.globals["renewal_class"] = svc.renewal_class +templates.env.globals["renewal_text"] = svc.renewal_text @app.middleware("http") diff --git a/app/services.py b/app/services.py index 3c0e5e2..59c1a4f 100644 --- a/app/services.py +++ b/app/services.py @@ -1,12 +1,68 @@ 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. @@ -65,6 +121,8 @@ def compute_service_due(conn: sqlite3.Connection, bike: db.Bike) -> ServiceDue: class BikeSummary: bike: db.Bike due: ServiceDue + mot: RenewalDue + insurance: RenewalDue event_count: int total_cost: int @@ -73,6 +131,8 @@ 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), ) @@ -113,3 +173,22 @@ def status_text(due: ServiceDue) -> str: 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" diff --git a/app/static/favicon.svg b/app/static/favicon.svg new file mode 100644 index 0000000..2c98401 --- /dev/null +++ b/app/static/favicon.svg @@ -0,0 +1,9 @@ + diff --git a/app/static/main.css b/app/static/main.css index abee57e..451bd22 100644 --- a/app/static/main.css +++ b/app/static/main.css @@ -88,7 +88,9 @@ h1, h2, h3 { line-height: 1.2; font-weight: 600; } .due-top { display: flex; justify-content: space-between; align-items: flex-start; gap: .75rem; margin-bottom: .75rem; } .due-title { margin: 0; } .due-summary { margin: .15rem 0 0; color: var(--muted); font-size: .95rem; } -.due-meta { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: .4rem .75rem; margin: 0; } +.due-meta { display: grid; grid-template-columns: minmax(7rem, max-content) 1fr; gap: 0; margin: 0; } +.due-meta > * { padding: .4rem 0; border-bottom: 1px solid var(--line); } +.due-meta > :nth-last-child(-n+2) { border-bottom: none; } .due-meta dt { color: var(--muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .03em; } .due-meta dd { margin: 0; font-weight: 600; } @@ -97,6 +99,12 @@ h1, h2, h3 { line-height: 1.2; font-weight: 600; } .due-card.badge-overdue { border-left: 4px solid var(--overdue-ink); } .due-card.badge-muted { border-left: 4px solid var(--muted); } +/* renewals (MOT / insurance) */ +.renewals { display: grid; gap: .5rem; margin-top: .85rem; padding-top: .85rem; border-top: 1px solid var(--line); } +.renewal { display: flex; align-items: center; gap: .6rem; } +.renewal-label { font-size: .8rem; color: var(--muted); text-transform: uppercase; letter-spacing: .03em; min-width: 4.5rem; } +.renewal .badge { margin-left: 0; } + /* badges */ .badge { display: inline-flex; align-items: center; @@ -160,6 +168,8 @@ input:focus, select:focus, textarea:focus { .tag-chain { background: #241a3a; color: #b39ddb; } .tag-brake { background: #331a1a; color: #f0908c; } .tag-repair { background: #14291d; color: #7fcfa0; } +.tag-mot { background: #14263a; color: #6fb3ff; } +.tag-insurance { background: #2a2313; color: #e6c36b; } .tag-other { background: #1c2129; color: #9aa5b1; } /* alert */ diff --git a/app/templates/base.html b/app/templates/base.html index 4f49260..c4b751c 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -4,6 +4,7 @@