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 @@ {% block title %}bike-admin{% endblock %} + diff --git a/app/templates/components/service_card.html b/app/templates/components/service_card.html index 63df47a..65c7df6 100644 --- a/app/templates/components/service_card.html +++ b/app/templates/components/service_card.html @@ -20,4 +20,16 @@
Records
{{ summary.event_count }}
Cost to date
{{ summary.total_cost | money }}
+
+
+ MOT + {{ renewal_text(summary.mot) }} + due {{ summary.mot.due_date if summary.mot.due_date else 'n/a' }} +
+
+ Insurance + {{ renewal_text(summary.insurance) }} + due {{ summary.insurance.due_date if summary.insurance.due_date else 'n/a' }} +
+
diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index b21297b..b9a1cda 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -47,6 +47,8 @@ {% if s.bike.service_interval_miles %}
Every
{{ s.bike.service_interval_miles | money }} mi
{% endif %} +
MOT
{{ renewal_text(s.mot) }}
+
Insurance
{{ renewal_text(s.insurance) }}
Records
{{ s.event_count }}
Cost to date
{{ s.total_cost | money }}
diff --git a/app/templates/login.html b/app/templates/login.html index c88ac61..2c0859b 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -4,6 +4,7 @@ Log in - bike-admin + diff --git a/tests/test_renewals.py b/tests/test_renewals.py new file mode 100644 index 0000000..4eab5c1 --- /dev/null +++ b/tests/test_renewals.py @@ -0,0 +1,99 @@ +from datetime import date, timedelta + +from app import db as d +from app.config import get_settings +from app.services import compute_renewal_due + + +def _conn(): + settings = get_settings() + settings.data_dir.mkdir(parents=True, exist_ok=True) + d.init_schema(settings.resolved_db_path) + return d.connect(settings.resolved_db_path) + + +def _bike(conn, name="B"): + return d.get_bike(conn, d.create_bike(conn, name, None)) + + +def _log(conn, bike_id, type, day): + d.create_event(conn, bike_id, type, day, None, None, None) + + +def test_no_data_when_no_renewal_event(): + conn = _conn() + b = _bike(conn) + _log(conn, b.id, "mot", date(2024, 6, 1)) + r = compute_renewal_due(conn, b, "insurance") + assert r.status == "no-data" + assert r.due_date is None + assert r.days_until is None + conn.close() + + +def test_due_is_twelve_months_after_renewal(): + conn = _conn() + b = _bike(conn) + _log(conn, b.id, "mot", date(2025, 6, 1)) + r = compute_renewal_due(conn, b, "mot", today=date(2025, 6, 1)) + assert r.renewal_date == date(2025, 6, 1) + assert r.due_date == date(2026, 6, 1) + assert r.status == "ok" + assert r.days_until > get_settings().renewal_due_soon_days + conn.close() + + +def test_due_soon_within_lead_time(): + # Due 30 days after today; renewal was 12 months before that. + conn = _conn() + b = _bike(conn) + today = date(2026, 1, 15) + _log(conn, b.id, "insurance", date(2025, 2, 14)) # due 2026-02-14 + r = compute_renewal_due(conn, b, "insurance", today=today) + assert r.status == "due-soon" + assert 0 <= r.days_until <= get_settings().renewal_due_soon_days + conn.close() + + +def test_exactly_at_lead_time_is_due_soon(): + conn = _conn() + b = _bike(conn) + lead = get_settings().renewal_due_soon_days + due = date(2026, 4, 1) + _log(conn, b.id, "mot", date(2025, 4, 1)) # due 2026-04-01 + r = compute_renewal_due(conn, b, "mot", today=due - timedelta(days=lead)) + assert r.days_until == lead + assert r.status == "due-soon" + conn.close() + + +def test_just_outside_lead_time_is_ok(): + conn = _conn() + b = _bike(conn) + lead = get_settings().renewal_due_soon_days + due = date(2026, 4, 1) + _log(conn, b.id, "mot", date(2025, 4, 1)) # due 2026-04-01 + r = compute_renewal_due(conn, b, "mot", today=due - timedelta(days=lead + 1)) + assert r.days_until == lead + 1 + assert r.status == "ok" + conn.close() + + +def test_overdue_when_renewal_is_thirteen_months_old(): + conn = _conn() + b = _bike(conn) + _log(conn, b.id, "mot", date.today() - timedelta(days=365 + 31)) + r = compute_renewal_due(conn, b, "mot") + assert r.status == "overdue" + assert r.days_until < 0 + conn.close() + + +def test_renewal_badge_shown_on_bike_page(client): + client.login() + bike_id = client.create_bike("Roadie") + result = client.create_event(bike_id, type="mot", event_date=date.today().isoformat()) + assert result["status"] == 200 + page = client.get(f"/bikes/{bike_id}").text + assert "MOT" in page + assert "Insurance" in page