Bike-Tinder
A private, Tinder-style web app for browsing the motorcycle market: swipe real listings (like / nope / maybe), see everything on one editable list, and let the deck learn your taste. Built for Glenn; runs on the Rotarran, published to the tailnet only.
Status: Live. Phases 1–3 are deployed (sections — wildcards + second looks + new; taste engine with match points; tank gauges; dossier "Why this rank"; real Trends tab; agent pass mock / live / fallback), B1 is live (photo galleries, deck flips, fullscreen viewer), and v1.1.0 (QOL patrol) was deployed + released on 2026-11-13. The podman stack has served since 2026-11-12 under podman-stack-bike-tinder.service, with the nightly import + agent read + DB backup armed; the private phone URL (https://biketinder.tailf35a34.ts.net) was authorized (Tailscale sidecar) on 2026-11-12 and is in use from Glenn's phone.
B1 (photo experience v2) shipped 2026-11-11: full photo galleries mirrored locally, deck flips (tap left/right thirds, clamped), segment bars, a fullscreen viewer (swipe nav, pinch/double-tap zoom, pull-down close), and repeat rotation (a re-shown card leads at its next photo). See UI-SPEC §11 + the "B1 develop notes" section below.
How it works
[single-source crawler] → [nightly importer] → [app DB] → [API + PWA deck] → Glenn's thumb
↓
[swipe history] → [ranking]
- Crawl — Kijiji (NS + NB; decided 2026-11-10 — see
docs/SOURCES.md) is swept nightly by the existing hunt crawler; a script-only importer folds the results in. No LLM involved, zero token cost. - Import — the importer upserts the source's catalog into the app database: new listings appear, changes update, vanished listings retire after repeated misses (never a blocked page, never a guess).
- Deck — a Tinder-like PWA: card stack, drag to swipe, big buttons. Like / Nope / Maybe. "It's a match" moment when you like something.
- List & edit — every entry in one table; re-file any call later (a nope can become a like).
- Ranking — cold start uses the hunt's rubric score; a cheap, fully explainable swipe-boost lands first (v1.x), then a small learned model (Phase 3, retrained nightly) orders the deck by your taste. "Because you liked X" chips, never a black box.
Docs
docs/SOURCES.md— the v1 source decision (candidates, evidence, choice)docs/DESIGN.md— architecture, components, data flow, deploymentdocs/DATA-MODEL.md— database schema + sync semanticsdocs/RANKING.md— cold-start + learned ranking designdocs/UI-SPEC.md— screens, gestures, Tinder-fidelity goalsdocs/PLAN.md— phases, acceptance criteria, status
Branches
main— stable trunk (docs + verified code); the default branch.dev— active development; build work lands here first.release— deployable cuts; the Rotarran stack tracks this branch.
Run & develop
Everything runs from the repo root (Python 3.13+, stdlib where possible).
# one-time setup
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt # fastapi + uvicorn (+ httpx for tests)
# import the hunt state into the DB (idempotent; safe to re-run any time)
python3 -m app.importer --state-dir ~/.hermes/scripts/motorcycle-hunt.d --db data/bike-tinder.sqlite3
# mirror card images into data/img/ (polite: ~1 req/s, skip-existing, resumable)
.venv/bin/python -m app.imgfetch --db data/bike-tinder.sqlite3
.venv/bin/python -m app.imgfetch --limit 15 # small test runs
# serve the app (127.0.0.1:9453 by default)
.venv/bin/python -m app.serve --db data/bike-tinder.sqlite3 --port 9453
# self-check: P1–P3 + B1 + v1.1 (35 checks, temp DB copies only)
.venv/bin/python -m app.selfcheck
# P3: agent pass — mock is deterministic + offline (tests, offline forge);
# --live needs OPENROUTER_API_KEY (+ optional BT_AGENT_MODEL); --dry-run
# validates the payload and writes nothing; --print-summary prints exactly
# the strike summary a live pass would be sent.
.venv/bin/python -m app.agentpass --db /tmp/bt-copy.sqlite3 --mock
.venv/bin/python -m app.agentpass --db /tmp/bt-copy.sqlite3 --print-summary --dry-run
# P3: run a scratch server against a COPY (NEVER point a dev server at
# data/bike-tinder.sqlite3 while the live service owns it — use port 9454).
cp data/bike-tinder.sqlite3 /tmp/bt-copy.sqlite3
.venv/bin/python -m app.serve --db /tmp/bt-copy.sqlite3 --img-dir data/img --port 9454
curl -s http://127.0.0.1:9454/api/section # first read composes a section
curl -s -X POST http://127.0.0.1:9454/api/section/forge \
-H 'Content-Type: application/json' -d '{"size": 8}' # mock pass + compose
# proof CLI: ranked deck + counts (read-only)
python3 -m app.deck --db data/bike-tinder.sqlite3 --top 15
Key routes: / (PWA), /api/health, /api/stats, /api/deck?limit=&cursor= (read-only
legacy fallback), POST /api/swipe / DELETE /api/swipe/{id}, /api/list?decision=&sort=&q=&limit=&offset=,
/api/listing/{id}, POST /api/import, POST /api/badge/seen, /img/{id}
(cached file → 302 source CDN → 404 placeholder).
P3 (Phase 3): GET /api/section, POST /api/section/forge {size},
GET /api/trends, POST /api/agent/run {source: mock|live},
POST /api/prefs {key: forge_mode, value: ondemand|strict}. Listing
payloads carry points, tank {value, band}, slot, round_next,
points_breakdown. Full contract: docs/UI-SPEC.md §10 + docs/RANKING.md
Stage 2 + the task brief.
data/ and .venv/ are gitignored — the DB, image mirror and venv are local state.
B1 develop notes (photo experience v2)
-
Columns (guarded in
db.init_db):listings.imgs TEXT(JSON array of gallery URLs, page order; entry 0 = the primary =img_url) ·swipes.last_photo_idx INTEGER(photo on screen at swipe time; drives §11.5 rotation(last+1) % n; NULL = photo 0). -
Importer copies the hunt state's
imgs[]intolistings.imgs;img_urlstays the stateimgand falls back toimgs[0]. Re-imports are byte-stable (selfcheck proves it on the 640-row copy). -
Route:
GET /img/{id}?i=n— mirrored file → CDN 302 → 404.i=0(default) keeps the legacy chain ({id}.webp, then{id}_0.webp, thenimg_url);i>0serves{id}_{n}.webpwith the CDN fallback fromimgs[n]. Out-of-range/unknown → 404. -
Mirror:
app/imgfetch.pydownloads everyimgs[n]. The primary (n=0) lands at the legacydata/img/{id}.webp(one canonical file — no duplicate fetch); extras land atdata/img/{id}_{n}.webp. Same politeness as P2 (~1 req/s, skip-existing,.part-atomic, per-file CLI progress). -
Payload: deck/section/list/listing items add
n_photos(int) andlast_photo_idx(int|null).POST /api/swipeaccepts optionalphoto_idx(non-negative int; persisted; absent key preserves, explicit null clears). -
Frontend: tap LEFT/RIGHT third = photo flip (clamped, no wrap, commits nothing), CENTER third = fullscreen viewer (ⓘ still wins); segment bars per photo (Tinder-style; band chips drop to
top:22pxwhen segments exist); viewer = swipe nav + pinch 1×–4× + double-tap 1×↔2.5× + ✕/pull-down close (returns to the same card/photo); re-shown cards lead at(last+1) % n; preloads the top card's next photo + next card's lead;sw.jscache isbike-tinder-v3. -
FE walk (evidence):
app/fe_walk_b1.py— deterministic, headless:.venv/bin/python -m app.fe_walk_b1 setup # fixtures: copy DB + 701-706 synthetics + local photos env -u OPENROUTER_API_KEY .venv/bin/python -m app.serve \ --db /var/home/admin/dev/bike-tinder-b1/test-walk.sqlite3 \ --img-dir /var/home/admin/dev/bike-tinder-b1/test-img \ --host 127.0.0.1 --port 9454 # scratch server (mock forge) .venv/bin/python -m app.fe_walk_b1 # 390 DOM checks -
Test data:
b1/test-state/= hunt-state sample withimgs(8/3-photo galleries; refreshfetched_atbefore an import gate test) ·b1/test-img/= staged{id}_{n}.webpphotos ·b1/test-copy.sqlite3= pristine live-DB snapshot (selfcheck copies it; never write to it).
Deployment (live)
The app runs as a small podman compose stack on the Rotarran under /var/mnt/DATA/podman-stacks/bike-tinder/ (a checkout of release), under its own systemd user unit (podman-stack-bike-tinder.service, Delegate=yes), SQLite database — deployed 2026-11-12. The stack ships its own Tailscale node (sidecar container — no host-tailnet coupling); the node was authorized on 2026-11-12, and the app serves privately at https://biketinder.tailf35a34.ts.net — in use from Glenn's phone. Single user; reachable only from Glenn's own devices.
The nightly chain (bike-tinder-import, 08:30 daily) keeps it current: catalog import → live agent read (taste profile) → SQLite backup into the stack's data/backup/ (rotation 14 deep).
Crew
- Glenn — owner; the thumb that swipes.
- Alex — resident agent; architecture, orchestration, docs.
- Torres — engineer; builds and maintains this repo.