# 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`](docs/SOURCES.md) — the v1 source decision (candidates, evidence, choice) - [`docs/DESIGN.md`](docs/DESIGN.md) — architecture, components, data flow, deployment - [`docs/DATA-MODEL.md`](docs/DATA-MODEL.md) — database schema + sync semantics - [`docs/RANKING.md`](docs/RANKING.md) — cold-start + learned ranking design - [`docs/UI-SPEC.md`](docs/UI-SPEC.md) — screens, gestures, Tinder-fidelity goals - [`docs/PLAN.md`](docs/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). ```sh # 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[]` into `listings.imgs`; `img_url` stays the state `img` and falls back to `imgs[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`, then `img_url`); `i>0` serves `{id}_{n}.webp` with the CDN fallback from `imgs[n]`. Out-of-range/unknown → 404. - **Mirror:** `app/imgfetch.py` downloads every `imgs[n]`. The primary (n=0) lands at the legacy `data/img/{id}.webp` (one canonical file — no duplicate fetch); extras land at `data/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) and `last_photo_idx` (int|null). `POST /api/swipe` accepts optional `photo_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:22px` when 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.js` cache is `bike-tinder-v3`. - **FE walk (evidence):** `app/fe_walk_b1.py` — deterministic, headless: ```sh .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 with `imgs` (8/3-photo galleries; refresh `fetched_at` before an import gate test) · `b1/test-img/` = staged `{id}_{n}.webp` photos · `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.