Skip to content

Commit 0376978

Browse files
committed
signups, modal, style, AGENTS, update README
1 parent 8286447 commit 0376978

12 files changed

Lines changed: 1999 additions & 1024 deletions

File tree

AGENTS.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# AGENTS.md
2+
3+
## Project overview
4+
5+
Where the Plow -- real-time and historical snowplow tracker for St. John's, NL.
6+
Python FastAPI backend serving a vanilla JS/HTML/CSS frontend. DuckDB for
7+
storage. No build step, no frontend framework.
8+
9+
Production: https://plow.jackharrhy.dev
10+
11+
## Architecture
12+
13+
- **Backend:** `src/where_the_plow/` -- FastAPI app (`main.py`), routes
14+
(`routes.py`), Pydantic models (`models.py`), DuckDB wrapper (`db.py`),
15+
AVL API client (`client.py`), background collector (`collector.py`),
16+
realtime snapshot builder (`snapshot.py`), file-based coverage cache
17+
(`cache.py`), config (`config.py`).
18+
- **Frontend:** `src/where_the_plow/static/` -- three files only:
19+
`index.html`, `app.js`, `style.css`. MapLibre GL JS for the map,
20+
noUiSlider for time range controls. All loaded from CDN, no bundler.
21+
- **Styling:** Plain CSS with custom properties defined in `:root`
22+
(`--color-*`, `--border-*`, `--font-sans`). Dark theme. Use the
23+
existing tokens -- don't hardcode colors.
24+
25+
## Database & migrations
26+
27+
DuckDB with the spatial extension. Schema is defined in `db.py`
28+
`Database.init()` using `CREATE TABLE IF NOT EXISTS`.
29+
30+
**Important:** `CREATE TABLE IF NOT EXISTS` does NOT alter existing tables.
31+
If you add columns to a table definition, existing production databases will
32+
silently keep the old schema and inserts referencing new columns will fail.
33+
34+
When adding columns to existing tables, you MUST add an explicit migration
35+
in `Database.init()` after the table creation block. The pattern:
36+
37+
1. Query `information_schema.columns` to check if the column exists.
38+
2. Run `ALTER TABLE ... ADD COLUMN` if it doesn't.
39+
40+
See the existing migrations in `db.init()` for examples (e.g. `geom` on
41+
`positions`, `ip`/`user_agent` on `viewports` and `signups`).
42+
43+
## Key conventions
44+
45+
- No frontend framework -- all DOM manipulation is vanilla JS with
46+
`getElementById` / `addEventListener`.
47+
- Two main JS classes: `PlowMap` (map layer management) and `PlowApp`
48+
(application state and logic). Event wiring is at the bottom of `app.js`.
49+
- Analytics/write endpoints (`/track`, `/signup`) use the `RateLimiter`
50+
class in `routes.py` for in-memory per-IP rate limiting, and store
51+
`ip` + `user_agent` for fingerprinting.
52+
- `_client_ip(request)` helper in `routes.py` extracts IP from
53+
`X-Forwarded-For` with fallback to `request.client.host`.

README.md

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
# Where the Plow
1+
# where the plow
22

3-
Real-time tracking of City of St. John's snowplow vehicles.
3+
Real-time and historical tracking of City of St. John's snowplow vehicles.
44

55
Polls the city's public AVL (Automatic Vehicle Location) API every 6 seconds, stores historical position data in DuckDB, serves it as GeoJSON, and visualizes it on a live map.
66

@@ -12,6 +12,12 @@ Polls the city's public AVL (Automatic Vehicle Location) API every 6 seconds, st
1212

1313
Requires [uv](https://docs.astral.sh/uv/):
1414

15+
```
16+
uv run cli.py dev
17+
```
18+
19+
This starts the app with auto-reload and sets `DB_PATH=./data/plow.db`. You can also run uvicorn directly:
20+
1521
```
1622
uv run uvicorn where_the_plow.main:app --host 0.0.0.0 --port 8000
1723
```
@@ -39,12 +45,16 @@ All geo endpoints return GeoJSON. Full OpenAPI docs at [`/docs`](https://plow.ja
3945

4046
| Endpoint | Description |
4147
|---|---|
42-
| `GET /vehicles` | Latest position for every vehicle |
48+
| `GET /vehicles` | Latest position for every vehicle (with mini-trails) |
4349
| `GET /vehicles/nearby?lat=&lng=&radius=` | Vehicles within radius (meters) |
4450
| `GET /vehicles/{id}/history?since=&until=` | Position history for one vehicle |
4551
| `GET /coverage?since=&until=` | Per-vehicle LineString trails with timestamps |
4652
| `GET /stats` | Collection statistics |
4753
| `GET /health` | Health check |
54+
| `POST /track` | Record anonymous viewport focus event |
55+
| `POST /signup` | Email signup for notifications |
56+
57+
All GET list endpoints support cursor-based pagination via `limit` and `after` query parameters. Write endpoints (`/track`, `/signup`) are rate-limited per IP.
4858

4959
## Database schema
5060

@@ -76,6 +86,8 @@ CREATE TABLE positions (
7686

7787
Deduplication is by `(vehicle_id, timestamp)` composite key -- if the API returns the same `LocationDateTime` for a vehicle, the row is skipped.
7888

89+
There are also `viewports` (analytics) and `signups` (email signups) tables -- see `db.py` for their full schemas.
90+
7991
## Stack
8092

81-
Python 3.12, FastAPI, DuckDB (spatial), httpx, MapLibre GL JS, Docker.
93+
Python 3.12, FastAPI, DuckDB (spatial), httpx, MapLibre GL JS, noUiSlider, Docker.

docs/plans/2026-02-19-api-and-map-impl.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -844,7 +844,7 @@ Update `main.py` to include the router and add OpenAPI metadata:
844844
from where_the_plow.routes import router
845845

846846
app = FastAPI(
847-
title="Where the Plow",
847+
title="where the plow",
848848
description="Real-time and historical plow tracker for the City of St. John's. "
849849
"All geo endpoints return GeoJSON FeatureCollections with cursor-based pagination.",
850850
version="0.1.0",

docs/plans/2026-02-19-data-collection-impl.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -799,7 +799,7 @@ async def lifespan(app: FastAPI):
799799
logger.info("Shutdown complete")
800800

801801

802-
app = FastAPI(title="Where the Plow", lifespan=lifespan)
802+
app = FastAPI(title="where the plow", lifespan=lifespan)
803803

804804

805805
@app.get("/health")

docs/poll_rate.py

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,6 @@
1818

1919
import argparse
2020
import time
21-
import json
22-
import sys
2321
from datetime import datetime, timezone
2422

2523
import httpx
@@ -101,17 +99,26 @@ def diff_snapshots(prev: dict[str, dict], curr: dict[str, dict]) -> dict:
10199

102100

103101
def main():
104-
parser = argparse.ArgumentParser(description="Poll AVL endpoint to measure update rate")
105-
parser.add_argument("--duration", type=int, default=120, help="How long to poll in seconds (default: 120)")
106-
parser.add_argument("--interval", type=int, default=3, help="Poll interval in seconds (default: 3)")
102+
parser = argparse.ArgumentParser(
103+
description="Poll AVL endpoint to measure update rate"
104+
)
105+
parser.add_argument(
106+
"--duration",
107+
type=int,
108+
default=120,
109+
help="How long to poll in seconds (default: 120)",
110+
)
111+
parser.add_argument(
112+
"--interval", type=int, default=3, help="Poll interval in seconds (default: 3)"
113+
)
107114
args = parser.parse_args()
108115

109116
duration = args.duration
110117
interval = args.interval
111118
total_ticks = duration // interval
112119

113120
print(f"Polling every {interval}s for {duration}s ({total_ticks} ticks)")
114-
print(f"Filtering: isDriving = 'maybe' (active vehicles only)")
121+
print("Filtering: isDriving = 'maybe' (active vehicles only)")
115122
print("-" * 70)
116123

117124
client = httpx.Client()
@@ -146,7 +153,9 @@ def main():
146153
ticks_with_changes += 1
147154
updated = [v for v in changes.values() if v["type"] == "updated"]
148155
appeared = [v for v in changes.values() if v["type"] == "appeared"]
149-
disappeared = [v for v in changes.values() if v["type"] == "disappeared"]
156+
disappeared = [
157+
v for v in changes.values() if v["type"] == "disappeared"
158+
]
150159

151160
parts = []
152161
if updated:
@@ -156,12 +165,16 @@ def main():
156165
if disappeared:
157166
parts.append(f"{len(disappeared)} disappeared")
158167

159-
print(f"[{now}] Tick {tick:3d}: {len(curr)} vehicles | {', '.join(parts)}")
168+
print(
169+
f"[{now}] Tick {tick:3d}: {len(curr)} vehicles | {', '.join(parts)}"
170+
)
160171

161172
# Track per-vehicle updates
162173
for vid, change in changes.items():
163174
if change["type"] == "updated":
164-
vehicle_update_counts[vid] = vehicle_update_counts.get(vid, 0) + 1
175+
vehicle_update_counts[vid] = (
176+
vehicle_update_counts.get(vid, 0) + 1
177+
)
165178
vehicle_descriptions[vid] = change["description"]
166179
# Track descriptions/types from current data
167180
if vid in curr:
@@ -188,9 +201,11 @@ def main():
188201
print("RESULTS")
189202
print("=" * 70)
190203
print(f"Total ticks polled: {actual_ticks}")
191-
print(f"Ticks with changes: {ticks_with_changes} ({ticks_with_changes/actual_ticks*100:.1f}%)")
204+
print(
205+
f"Ticks with changes: {ticks_with_changes} ({ticks_with_changes / actual_ticks * 100:.1f}%)"
206+
)
192207
print(f"Ticks with no changes: {actual_ticks - ticks_with_changes}")
193-
print(f"Avg changes per tick: {sum(tick_change_counts)/actual_ticks:.1f}")
208+
print(f"Avg changes per tick: {sum(tick_change_counts) / actual_ticks:.1f}")
194209
print(f"Max changes in one tick: {max(tick_change_counts)}")
195210
print()
196211

@@ -199,7 +214,7 @@ def main():
199214
print()
200215
print("Per-vehicle update frequency (sorted by most updates):")
201216
print(f" {'Vehicle':<30} {'Type':<16} {'Updates':>8} {'Rate':>10}")
202-
print(f" {'-'*30} {'-'*16} {'-'*8} {'-'*10}")
217+
print(f" {'-' * 30} {'-' * 16} {'-' * 8} {'-' * 10}")
203218
for vid, count in sorted(vehicle_update_counts.items(), key=lambda x: -x[1]):
204219
desc = vehicle_descriptions.get(vid, vid)
205220
vtype = vehicle_types.get(vid, "?")
@@ -209,13 +224,23 @@ def main():
209224
print()
210225
print("Interpretation:")
211226
if ticks_with_changes / actual_ticks > 0.8:
212-
print(f" Data updates very frequently. Polling every {interval}s is reasonable.")
227+
print(
228+
f" Data updates very frequently. Polling every {interval}s is reasonable."
229+
)
213230
elif ticks_with_changes / actual_ticks > 0.4:
214-
effective = actual_ticks * interval // ticks_with_changes if ticks_with_changes else 0
215-
print(f" Data updates moderately. Consider polling every ~{effective}s instead.")
231+
effective = (
232+
actual_ticks * interval // ticks_with_changes if ticks_with_changes else 0
233+
)
234+
print(
235+
f" Data updates moderately. Consider polling every ~{effective}s instead."
236+
)
216237
else:
217-
effective = actual_ticks * interval // ticks_with_changes if ticks_with_changes else 0
218-
print(f" Data updates infrequently. Polling every ~{effective}s would be sufficient.")
238+
effective = (
239+
actual_ticks * interval // ticks_with_changes if ticks_with_changes else 0
240+
)
241+
print(
242+
f" Data updates infrequently. Polling every ~{effective}s would be sufficient."
243+
)
219244

220245

221246
if __name__ == "__main__":

src/where_the_plow/db.py

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ def init(self):
5959
CREATE TABLE IF NOT EXISTS viewports (
6060
id BIGINT DEFAULT nextval('viewports_seq') PRIMARY KEY,
6161
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
62+
ip VARCHAR,
63+
user_agent VARCHAR,
6264
zoom DOUBLE NOT NULL,
6365
center_lng DOUBLE NOT NULL,
6466
center_lat DOUBLE NOT NULL,
@@ -68,6 +70,22 @@ def init(self):
6870
ne_lat DOUBLE NOT NULL
6971
)
7072
""")
73+
cur.execute("""
74+
CREATE SEQUENCE IF NOT EXISTS signups_seq
75+
""")
76+
cur.execute("""
77+
CREATE TABLE IF NOT EXISTS signups (
78+
id BIGINT DEFAULT nextval('signups_seq') PRIMARY KEY,
79+
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
80+
email VARCHAR NOT NULL,
81+
ip VARCHAR,
82+
user_agent VARCHAR,
83+
notify_plow BOOLEAN NOT NULL DEFAULT FALSE,
84+
notify_projects BOOLEAN NOT NULL DEFAULT FALSE,
85+
notify_siliconharbour BOOLEAN NOT NULL DEFAULT FALSE,
86+
note VARCHAR
87+
)
88+
""")
7189

7290
# Migration: add geom column to existing tables that lack it
7391
cols = cur.execute(
@@ -82,6 +100,33 @@ def init(self):
82100
"UPDATE positions SET geom = ST_Point(longitude, latitude) WHERE geom IS NULL"
83101
)
84102

103+
# Migration: add ip/user_agent columns to viewports
104+
vp_cols = {
105+
r[0]
106+
for r in cur.execute(
107+
"SELECT column_name FROM information_schema.columns "
108+
"WHERE table_name='viewports'"
109+
).fetchall()
110+
}
111+
if "ip" not in vp_cols:
112+
cur.execute("ALTER TABLE viewports ADD COLUMN ip VARCHAR")
113+
if "user_agent" not in vp_cols:
114+
cur.execute("ALTER TABLE viewports ADD COLUMN user_agent VARCHAR")
115+
116+
# Migration: add ip/user_agent columns to signups
117+
su_cols = {
118+
r[0]
119+
for r in cur.execute(
120+
"SELECT column_name FROM information_schema.columns "
121+
"WHERE table_name='signups'"
122+
).fetchall()
123+
}
124+
if su_cols: # table exists from a prior run
125+
if "ip" not in su_cols:
126+
cur.execute("ALTER TABLE signups ADD COLUMN ip VARCHAR")
127+
if "user_agent" not in su_cols:
128+
cur.execute("ALTER TABLE signups ADD COLUMN user_agent VARCHAR")
129+
85130
def upsert_vehicles(self, vehicles: list[dict], now: datetime):
86131
cur = self._cursor()
87132
for v in vehicles:
@@ -408,15 +453,69 @@ def insert_viewport(
408453
sw_lat: float,
409454
ne_lng: float,
410455
ne_lat: float,
456+
ip: str | None = None,
457+
user_agent: str | None = None,
411458
):
412459
"""Record a user viewport focus event."""
413460
self._cursor().execute(
414461
"""
415-
INSERT INTO viewports (zoom, center_lng, center_lat, sw_lng, sw_lat, ne_lng, ne_lat)
462+
INSERT INTO viewports (ip, user_agent, zoom, center_lng, center_lat, sw_lng, sw_lat, ne_lng, ne_lat)
463+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
464+
""",
465+
[
466+
ip,
467+
user_agent,
468+
zoom,
469+
center_lng,
470+
center_lat,
471+
sw_lng,
472+
sw_lat,
473+
ne_lng,
474+
ne_lat,
475+
],
476+
)
477+
478+
def insert_signup(
479+
self,
480+
email: str,
481+
ip: str | None = None,
482+
user_agent: str | None = None,
483+
notify_plow: bool = False,
484+
notify_projects: bool = False,
485+
notify_siliconharbour: bool = False,
486+
note: str | None = None,
487+
):
488+
"""Record an email signup."""
489+
self._cursor().execute(
490+
"""
491+
INSERT INTO signups (email, ip, user_agent, notify_plow, notify_projects, notify_siliconharbour, note)
416492
VALUES (?, ?, ?, ?, ?, ?, ?)
417493
""",
418-
[zoom, center_lng, center_lat, sw_lng, sw_lat, ne_lng, ne_lat],
494+
[
495+
email,
496+
ip,
497+
user_agent,
498+
notify_plow,
499+
notify_projects,
500+
notify_siliconharbour,
501+
note,
502+
],
503+
)
504+
505+
def count_recent_signups(self, ip: str, minutes: int = 30) -> int:
506+
"""Count signups from an IP in the last N minutes."""
507+
row = (
508+
self._cursor()
509+
.execute(
510+
"""
511+
SELECT count(*) FROM signups
512+
WHERE ip = ? AND timestamp > now() - INTERVAL (?) MINUTE
513+
""",
514+
[ip, minutes],
515+
)
516+
.fetchone()
419517
)
518+
return row[0] if row else 0
420519

421520
def close(self):
422521
self.conn.close()

src/where_the_plow/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ async def lifespan(app: FastAPI):
4040

4141

4242
app = FastAPI(
43-
title="Where the Plow",
43+
title="where the plow",
4444
description="Real-time and historical plow tracker for the City of St. John's. "
4545
"All geo endpoints return GeoJSON FeatureCollections with cursor-based pagination.\n\n"
4646
"**WARNING:** This API is not stable. Monitor the `openapi.json` file for breaking changes. "

0 commit comments

Comments
 (0)