145 lines
9.2 KiB
Markdown
145 lines
9.2 KiB
Markdown
# 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.
|