Add MOT/insurance renewal hints, table service card, and favicon
- Renewal tracking: compute due/overdue status from the most recent mot/insurance event (renewal_months, renewal_due_soon_days settings), surfaced on the service card and dashboard. - Service-status card metrics now render as a two-column label/value table with row separators instead of wrapping auto-fill grid cells. - Add a bicycle favicon (favicon.svg) to base + login templates. Co-Authored-By: opencode <opencode@users.noreply.github.com>
This commit is contained in:
@@ -31,6 +31,13 @@ class Settings(BaseSettings):
|
|||||||
# Service hint: flag a bike as "book soon" when it is this close to due.
|
# Service hint: flag a bike as "book soon" when it is this close to due.
|
||||||
service_due_soon_miles: int = 200
|
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 (preset)
|
||||||
event_types: list[str] = [
|
event_types: list[str] = [
|
||||||
"service",
|
"service",
|
||||||
@@ -39,6 +46,8 @@ class Settings(BaseSettings):
|
|||||||
"chain",
|
"chain",
|
||||||
"brake",
|
"brake",
|
||||||
"repair",
|
"repair",
|
||||||
|
"mot",
|
||||||
|
"insurance",
|
||||||
"other",
|
"other",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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["event_types"] = get_settings().event_types
|
||||||
templates.env.globals["status_class"] = svc.status_class
|
templates.env.globals["status_class"] = svc.status_class
|
||||||
templates.env.globals["status_text"] = svc.status_text
|
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")
|
@app.middleware("http")
|
||||||
|
|||||||
@@ -1,12 +1,68 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from calendar import monthrange
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
from . import db
|
from . import db
|
||||||
from .config import get_settings
|
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
|
@dataclass
|
||||||
class ServiceDue:
|
class ServiceDue:
|
||||||
"""Service-due snapshot for one bike.
|
"""Service-due snapshot for one bike.
|
||||||
@@ -65,6 +121,8 @@ def compute_service_due(conn: sqlite3.Connection, bike: db.Bike) -> ServiceDue:
|
|||||||
class BikeSummary:
|
class BikeSummary:
|
||||||
bike: db.Bike
|
bike: db.Bike
|
||||||
due: ServiceDue
|
due: ServiceDue
|
||||||
|
mot: RenewalDue
|
||||||
|
insurance: RenewalDue
|
||||||
event_count: int
|
event_count: int
|
||||||
total_cost: int
|
total_cost: int
|
||||||
|
|
||||||
@@ -73,6 +131,8 @@ def summarize_bike(conn: sqlite3.Connection, bike: db.Bike) -> BikeSummary:
|
|||||||
return BikeSummary(
|
return BikeSummary(
|
||||||
bike=bike,
|
bike=bike,
|
||||||
due=compute_service_due(conn, 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),
|
event_count=db.count_events(conn, bike.id),
|
||||||
total_cost=db.sum_cost(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":
|
if due.status == "ok":
|
||||||
return f"due in {due.miles_until_due:,} mi"
|
return f"due in {due.miles_until_due:,} mi"
|
||||||
return f"overdue by {abs(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"
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||||
|
<rect width="64" height="64" rx="14" fill="#4c8dff"/>
|
||||||
|
<g fill="none" stroke="#ffffff" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="18" cy="43" r="10"/>
|
||||||
|
<circle cx="46" cy="43" r="10"/>
|
||||||
|
<path d="M18 43 L31 43 M31 43 L26 24 M26 24 L45 24 M45 24 L46 43 M31 43 L45 24"/>
|
||||||
|
<path d="M45 24 L49 17 M22 24 L30 24"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 459 B |
+11
-1
@@ -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-top { display: flex; justify-content: space-between; align-items: flex-start; gap: .75rem; margin-bottom: .75rem; }
|
||||||
.due-title { margin: 0; }
|
.due-title { margin: 0; }
|
||||||
.due-summary { margin: .15rem 0 0; color: var(--muted); font-size: .95rem; }
|
.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 dt { color: var(--muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .03em; }
|
||||||
.due-meta dd { margin: 0; font-weight: 600; }
|
.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-overdue { border-left: 4px solid var(--overdue-ink); }
|
||||||
.due-card.badge-muted { border-left: 4px solid var(--muted); }
|
.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 */
|
/* badges */
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-flex; align-items: center;
|
display: inline-flex; align-items: center;
|
||||||
@@ -160,6 +168,8 @@ input:focus, select:focus, textarea:focus {
|
|||||||
.tag-chain { background: #241a3a; color: #b39ddb; }
|
.tag-chain { background: #241a3a; color: #b39ddb; }
|
||||||
.tag-brake { background: #331a1a; color: #f0908c; }
|
.tag-brake { background: #331a1a; color: #f0908c; }
|
||||||
.tag-repair { background: #14291d; color: #7fcfa0; }
|
.tag-repair { background: #14291d; color: #7fcfa0; }
|
||||||
|
.tag-mot { background: #14263a; color: #6fb3ff; }
|
||||||
|
.tag-insurance { background: #2a2313; color: #e6c36b; }
|
||||||
.tag-other { background: #1c2129; color: #9aa5b1; }
|
.tag-other { background: #1c2129; color: #9aa5b1; }
|
||||||
|
|
||||||
/* alert */
|
/* alert */
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{% block title %}bike-admin{% endblock %}</title>
|
<title>{% block title %}bike-admin{% endblock %}</title>
|
||||||
|
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="/static/main.css">
|
<link rel="stylesheet" href="/static/main.css">
|
||||||
<script src="/static/htmx.min.js" defer></script>
|
<script src="/static/htmx.min.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -20,4 +20,16 @@
|
|||||||
<dt>Records</dt><dd>{{ summary.event_count }}</dd>
|
<dt>Records</dt><dd>{{ summary.event_count }}</dd>
|
||||||
<dt>Cost to date</dt><dd>{{ summary.total_cost | money }}</dd>
|
<dt>Cost to date</dt><dd>{{ summary.total_cost | money }}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
<div class="renewals">
|
||||||
|
<div class="renewal">
|
||||||
|
<span class="renewal-label">MOT</span>
|
||||||
|
<span class="badge {{ renewal_class(summary.mot) }} {{ summary.mot.status }}">{{ renewal_text(summary.mot) }}</span>
|
||||||
|
<span class="muted">due {{ summary.mot.due_date if summary.mot.due_date else 'n/a' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="renewal">
|
||||||
|
<span class="renewal-label">Insurance</span>
|
||||||
|
<span class="badge {{ renewal_class(summary.insurance) }} {{ summary.insurance.status }}">{{ renewal_text(summary.insurance) }}</span>
|
||||||
|
<span class="muted">due {{ summary.insurance.due_date if summary.insurance.due_date else 'n/a' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,6 +47,8 @@
|
|||||||
{% if s.bike.service_interval_miles %}
|
{% if s.bike.service_interval_miles %}
|
||||||
<dt>Every</dt><dd>{{ s.bike.service_interval_miles | money }} mi</dd>
|
<dt>Every</dt><dd>{{ s.bike.service_interval_miles | money }} mi</dd>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<dt>MOT</dt><dd><span class="badge {{ renewal_class(s.mot) }}">{{ renewal_text(s.mot) }}</span></dd>
|
||||||
|
<dt>Insurance</dt><dd><span class="badge {{ renewal_class(s.insurance) }}">{{ renewal_text(s.insurance) }}</span></dd>
|
||||||
<dt>Records</dt><dd>{{ s.event_count }}</dd>
|
<dt>Records</dt><dd>{{ s.event_count }}</dd>
|
||||||
<dt>Cost to date</dt><dd>{{ s.total_cost | money }}</dd>
|
<dt>Cost to date</dt><dd>{{ s.total_cost | money }}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Log in - bike-admin</title>
|
<title>Log in - bike-admin</title>
|
||||||
|
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="/static/main.css">
|
<link rel="stylesheet" href="/static/main.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="auth">
|
<body class="auth">
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user