diff --git a/.gitignore b/.gitignore index f564ead..306fe8b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ __pycache__/ # scratch scratch/ +.venv/ diff --git a/README.md b/README.md index 6e7521b..fcbc9f4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 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:** v0.1 — design phase. No code yet; this repo currently holds the design docs. Build begins once the docs are approved. +**Status:** Phase 2 (production build) — FastAPI backend + no-build PWA shipped on `dev` (2026-11-10): migrations, API with cursor-paginated deck, swipe persistence, real list/matches screens, image mirror, PWA shell. Deployment (podman stack) is Phase 4 — not started. ## How it works @@ -33,6 +33,39 @@ A private, Tinder-style web app for browsing the motorcycle market: swipe real l - `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: 8 P1 spine checks + 6 P2 API checks (on a temp DB copy) +.venv/bin/python -m app.selfcheck + +# 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=`, +`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). Full contract: `docs/UI-SPEC.md` + the task brief. + +`data/` and `.venv/` are gitignored — the DB, image mirror and venv are local state. + ## Deployment (planned) A small podman compose stack on the Rotarran under `/var/mnt/DATA/podman-stacks/bike-tinder/`, its own systemd user unit (`Delegate=yes`), SQLite database. The stack ships its own Tailscale node (sidecar container — no host-tailnet coupling) and serves the app privately at `https://biketinder.tailf35a34.ts.net`. Single user; reachable only from Glenn's own devices. diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..64a54ce --- /dev/null +++ b/app/api.py @@ -0,0 +1,405 @@ +"""Bike-Tinder API (Phase 2) — FastAPI app factory. + + create_app(db_path, img_dir, state_dir=None) -> FastAPI + +The factory keeps every path injectable so the selfcheck can run the real +router against a temp copy of the DB (`fastapi.testclient.TestClient`). +Serve via app/serve.py (uvicorn) — see docs/DESIGN.md for the surface. + +Payload contract (deck/list/listing items — exact field names the FE relies +on; frozen in the task brief): + {id, title, price_display, price_num, km, location, region ('ns'|'nb'), + url, img ("/img/{id}" if img_url or cached file exists else null), desc, + family, score, year, seller_type, extras (array of strings), + decision (null|'like'|'maybe'|'nope'), first_seen} +""" +import json +import os + +from fastapi import FastAPI, HTTPException, Query +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse + +from . import db as dbmod +from . import importer as imp + +ACTIONS = ('like', 'nope', 'maybe') + +# --- SQL ------------------------------------------------------------------ + +DECK_WHERE = ( + "l.status = 'active'" + " AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.listing_id = l.id" + " AND s.action = 'nope')" + " AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.listing_id = l.id" + " AND s.action = 'like')" + " AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.listing_id = l.id" + " AND s.action = 'maybe'" + " AND COALESCE(s.updated_at, s.created_at)" + " > datetime('now', '-7 days'))" +) + +DECK_SELECT = ( + "SELECT l.*, s.action AS decision" + " FROM listings l LEFT JOIN swipes s ON s.listing_id = l.id" + " WHERE " + DECK_WHERE +) + +DECK_ORDER = " ORDER BY l.score DESC, COALESCE(l.first_seen,'') DESC, l.id DESC" + +LIST_SORTS = { + 'score': 'l.score DESC, COALESCE(l.first_seen,\'\') DESC, l.id DESC', + 'price_asc': 'l.price_num IS NULL ASC, l.price_num ASC, l.id DESC', + 'price_desc': 'l.price_num IS NULL ASC, l.price_num DESC, l.id DESC', + 'newest': 'COALESCE(l.first_seen,\'\') DESC, l.id DESC', + 'km': 'l.km IS NULL ASC, l.km ASC, l.id DESC', +} + +LIST_DECISIONS = { + 'all': '1=1', + 'undecided': 's.action IS NULL', + 'like': "s.action = 'like'", + 'maybe': "s.action = 'maybe'", + 'nope': "s.action = 'nope'", +} + +REGION_NAMES = {'ns': 'NS', 'nb': 'NB'} + + +# --- helpers -------------------------------------------------------------- + +def get_conn(app): + conn = dbmod.connect(app.state.db_path) + dbmod.init_db(conn) + return conn + + +def set_pref(conn, key, value): + conn.execute( + "INSERT INTO prefs (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, value)) + + +def get_pref(conn, key, default=None): + row = conn.execute('SELECT value FROM prefs WHERE key=?', (key,)).fetchone() + return row['value'] if row else default + + +def parse_cursor(cursor): + """Opaque '||' -> (int, str, int) or None.""" + if not cursor: + return None + try: + score, first_seen, listing_id = cursor.split('|', 2) + return int(score), first_seen, int(listing_id) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail='bad cursor') + + +def cursor_clause(): + """Strictly-after in (score, first_seen, id) DESC tuple order.""" + return (" AND (l.score < ? OR" + " (l.score = ? AND (COALESCE(l.first_seen,'') < ? OR" + " (COALESCE(l.first_seen,'') = ? AND l.id < ?))))") + + +def count_deck_after(app, score, first_seen, listing_id): + conn = get_conn(app) + try: + n = conn.execute( + 'SELECT COUNT(*) AS n FROM listings l' + ' LEFT JOIN swipes s ON s.listing_id = l.id' + ' WHERE ' + DECK_WHERE + cursor_clause(), + (score, score, first_seen, first_seen, listing_id)).fetchone()['n'] + return n + finally: + conn.close() + + +def build_payload(img_dir): + def _payload(conn, row): + img = None + if row['img_url'] or os.path.exists( + os.path.join(img_dir, '%d.webp' % row['id'])): + img = '/img/%d' % row['id'] + extras = row['extras'] + if isinstance(extras, str): + try: + extras = json.loads(extras) + except ValueError: + extras = [] + return { + 'id': row['id'], + 'title': row['title'], + 'price_display': row['price_display'], + 'price_num': row['price_num'], + 'km': row['km'], + 'location': row['location'], + 'region': row['region'], + 'url': row['url'], + 'img': img, + 'desc': row['desc'], + 'family': row['family'], + 'score': row['score'], + 'year': row['year'], + 'seller_type': row['seller_type'], + 'extras': extras or [], + 'decision': row['decision'] if 'decision' in row.keys() else None, + 'first_seen': row['first_seen'], + } + return _payload + + +# --- app factory ---------------------------------------------------------- + +def create_app(db_path, img_dir, state_dir=None): + if state_dir is None: + state_dir = os.path.expanduser('~/.hermes/scripts/motorcycle-hunt.d') + os.makedirs(os.path.dirname(os.path.abspath(db_path)), exist_ok=True) + os.makedirs(os.path.abspath(img_dir), exist_ok=True) + static_dir = os.path.join(os.path.dirname(__file__), 'static') + icons_dir = os.path.join(static_dir, 'icons') + + app = FastAPI(title='Bike-Tinder') + app.state.db_path = os.path.abspath(db_path) + app.state.img_dir = os.path.abspath(img_dir) + app.state.state_dir = state_dir + app.state.static_dir = static_dir + app.state.icons_dir = icons_dir + payload = build_payload(app.state.img_dir) + + # -- meta ----------------------------------------------------------- + + @app.get('/api/health') + def health(): + conn = get_conn(app) + try: + counts = {} + for status in ('active', 'gone', 'dup'): + counts[status] = conn.execute( + 'SELECT COUNT(*) AS n FROM listings WHERE status=?', + (status,)).fetchone()['n'] + counts['total'] = sum(counts.values()) + return {'ok': True, + 'db': os.path.basename(app.state.db_path), + 'counts': counts} + finally: + conn.close() + + @app.get('/api/stats') + def stats(): + conn = get_conn(app) + try: + live = conn.execute( + "SELECT COUNT(*) AS n FROM listings WHERE status='active'" + ).fetchone()['n'] + badge = get_pref(conn, 'badge_seen_at') or '' + new = conn.execute( + "SELECT COUNT(*) AS n FROM listings" + " WHERE status='active' AND COALESCE(first_seen,'') > ?", + (badge,)).fetchone()['n'] + per = {} + for action in ('like', 'maybe', 'nope'): + per[action] = conn.execute( + 'SELECT COUNT(*) AS n FROM swipes WHERE action=?', + (action,)).fetchone()['n'] + last = conn.execute( + 'SELECT started_at, ok FROM import_runs' + ' ORDER BY id DESC LIMIT 1').fetchone() + return { + 'live': live, + 'new': new, + 'likes': per['like'], + 'maybes': per['maybe'], + 'nopes': per['nope'], + 'last_import': {'at': last['started_at'], 'ok': bool(last['ok'])} + if last else None, + } + finally: + conn.close() + + # -- deck ----------------------------------------------------------- + + @app.get('/api/deck') + def deck(limit: int = Query(40, ge=1, le=100), + cursor: str = Query(None)): + cur = parse_cursor(cursor) + conn = get_conn(app) + try: + if cur is None: + rows = conn.execute(DECK_SELECT + DECK_ORDER + ' LIMIT ?', + (limit,)).fetchall() + else: + rows = conn.execute( + DECK_SELECT + cursor_clause() + DECK_ORDER + ' LIMIT ?', + (cur[0], cur[0], cur[1], cur[1], cur[2], limit)).fetchall() + items = [payload(conn, r) for r in rows] + next_cursor = None + remaining = 0 + if rows: + last = rows[-1] + if len(rows) == limit: + nc = (last['score'], last['first_seen'] or '', + last['id']) + next_cursor = '%s|%s|%s' % nc + remaining = count_deck_after(app, *nc) + return {'items': items, 'next_cursor': next_cursor, + 'remaining': remaining} + finally: + conn.close() + + # -- swipes --------------------------------------------------------- + + @app.post('/api/swipe') + def swipe(body: dict): + listing_id = body.get('listing_id') + action = body.get('action') + if not isinstance(listing_id, int) or action not in ACTIONS: + raise HTTPException(status_code=400, + detail='body must be {listing_id:int, action:like|nope|maybe}') + conn = get_conn(app) + try: + row = conn.execute('SELECT id FROM listings WHERE id=?', + (listing_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail='unknown listing') + now = imp.now_iso() + conn.execute( + '''INSERT INTO swipes (listing_id, action, created_at, updated_at) + VALUES (?,?,?,?) + ON CONFLICT(listing_id) DO UPDATE SET + action=excluded.action, updated_at=excluded.updated_at''', + (listing_id, action, now, now)) + conn.commit() + stored = conn.execute( + 'SELECT listing_id, action, created_at, updated_at' + ' FROM swipes WHERE listing_id=?', (listing_id,)).fetchone() + return dict(stored) + finally: + conn.close() + + @app.delete('/api/swipe/{listing_id}') + def swipe_clear(listing_id: int): + conn = get_conn(app) + try: + cur = conn.execute('DELETE FROM swipes WHERE listing_id=?', + (listing_id,)) + conn.commit() + return {'listing_id': listing_id, 'cleared': cur.rowcount > 0} + finally: + conn.close() + + # -- list + listing ------------------------------------------------- + + @app.get('/api/list') + def list_(decision: str = Query('all'), sort: str = Query('score'), + q: str = Query(None), limit: int = Query(100, ge=1, le=500), + offset: int = Query(0, ge=0)): + if decision not in LIST_DECISIONS: + raise HTTPException(status_code=400, detail='bad decision filter') + if sort not in LIST_SORTS: + raise HTTPException(status_code=400, detail='bad sort') + where = ("l.status = 'active' AND " + LIST_DECISIONS[decision]) + params = [] + if q: + escaped = (q.replace('\\', '\\\\').replace('%', '\\%') + .replace('_', '\\_')) + where += " AND l.title LIKE ? ESCAPE '\\'" + params.append('%' + escaped + '%') + conn = get_conn(app) + try: + total = conn.execute( + 'SELECT COUNT(*) AS n FROM listings l' + ' LEFT JOIN swipes s ON s.listing_id = l.id' + ' WHERE ' + where, params).fetchone()['n'] + rows = conn.execute( + 'SELECT l.*, s.action AS decision FROM listings l' + ' LEFT JOIN swipes s ON s.listing_id = l.id' + ' WHERE ' + where + ' ORDER BY ' + LIST_SORTS[sort] + + ' LIMIT ? OFFSET ?', params + [limit, offset]).fetchall() + return {'items': [payload(conn, r) for r in rows], 'total': total} + finally: + conn.close() + + @app.get('/api/listing/{listing_id}') + def listing(listing_id: int): + conn = get_conn(app) + try: + row = conn.execute( + 'SELECT l.*, s.action AS decision FROM listings l' + ' LEFT JOIN swipes s ON s.listing_id = l.id' + ' WHERE l.id=?', (listing_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail='unknown listing') + return payload(conn, row) + finally: + conn.close() + + # -- import --------------------------------------------------------- + + @app.post('/api/import') + def run_import_endpoint(): + try: + result = imp.run_import(app.state.state_dir, app.state.db_path) + except Exception as e: # noqa: BLE001 — surface anything to the caller + result = {'ok': False, 'errors': {'exception': str(e)}} + return JSONResponse(status_code=200, content=result) + + # -- badge ---------------------------------------------------------- + + @app.post('/api/badge/seen') + def badge_seen(): + conn = get_conn(app) + try: + now = imp.now_iso() + set_pref(conn, 'badge_seen_at', now) + conn.commit() + return {'badge_seen_at': now} + finally: + conn.close() + + # -- images + static ------------------------------------------------- + + @app.get('/img/{listing_id}') + def img(listing_id: int): + fpath = os.path.join(app.state.img_dir, '%d.webp' % listing_id) + if os.path.exists(fpath): + return FileResponse(fpath, media_type='image/webp') + conn = get_conn(app) + try: + row = conn.execute('SELECT img_url FROM listings WHERE id=?', + (listing_id,)).fetchone() + finally: + conn.close() + if row and row['img_url']: + return RedirectResponse(url=row['img_url'], status_code=302) + raise HTTPException(status_code=404, detail='no image') + + @app.get('/') + def index(): + return FileResponse( + os.path.join(static_dir, 'index.html'), + headers={'Cache-Control': 'no-cache'}) + + @app.get('/manifest.json') + def manifest(): + return FileResponse(os.path.join(static_dir, 'manifest.json')) + + @app.get('/apple-touch-icon.png') + def apple_touch(): + return FileResponse(os.path.join(icons_dir, 'apple-touch-icon.png')) + + @app.get('/sw.js') + def sw(): + return FileResponse(os.path.join(static_dir, 'sw.js'), + headers={'Cache-Control': 'no-cache'}) + + @app.get('/icons/{name}') + def icon(name: str): + if '/' in name or '..' in name: + raise HTTPException(status_code=404, detail='no such icon') + fpath = os.path.join(icons_dir, name) + if not os.path.exists(fpath): + raise HTTPException(status_code=404, detail='no such icon') + return FileResponse(fpath) + + return app diff --git a/app/db.py b/app/db.py index 37d0918..899c86c 100644 --- a/app/db.py +++ b/app/db.py @@ -23,7 +23,19 @@ def connect(db_path): def init_db(conn): - """Apply schema.sql (idempotent).""" + """Apply schema.sql (idempotent) + guarded P2 column migrations. + + P2 additions (docs/DATA-MODEL.md) are added with idempotent + ``ALTER TABLE ... ADD COLUMN`` guarded by a PRAGMA check, so the locked + v1 schema.sql stays untouched and existing DBs migrate in place. + """ with open(SCHEMA_PATH, encoding='utf-8') as fh: conn.executescript(fh.read()) + cols = {r['name'] for r in conn.execute("PRAGMA table_info('listings')")} + if 'year' not in cols: + conn.execute('ALTER TABLE listings ADD COLUMN year INTEGER') + if 'seller_type' not in cols: + conn.execute('ALTER TABLE listings ADD COLUMN seller_type TEXT') + if 'extras' not in cols: + conn.execute('ALTER TABLE listings ADD COLUMN extras TEXT') conn.commit() diff --git a/app/imgfetch.py b/app/imgfetch.py new file mode 100644 index 0000000..44f9908 --- /dev/null +++ b/app/imgfetch.py @@ -0,0 +1,114 @@ +"""Bike-Tinder local image mirror. + +Downloads each active listing's card image once into data/img/{id}.webp. + + .venv/bin/python -m app.imgfetch --db data/bike-tinder.sqlite3 + .venv/bin/python -m app.imgfetch --limit 15 + +Polite by design: ~1 req/s (sleep between fetches), skip-existing, crash-safe +(partial bytes land in .part and are atomically renamed), per-item failures +never kill the run. Honest end summary: `fetched N · skipped M · failed K`. + +UA + habits match the hunt scanner (reference/motorcycle-hunt-scan.py) so the +CDN sees the same client as the crawl. Stdlib only — no requests dependency. +""" +import argparse +import os +import sys +import time +import urllib.error +import urllib.request + +from . import db + +# Same UA string the hunt scanner uses. +UA = ('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36') + +SLEEP = 1.0 +TIMEOUT = 20 + + +def is_webp(buf): + return (len(buf) >= 12 and buf[:4] == b'RIFF' and buf[8:12] == b'WEBP') + + +def fetch_one(img_url, dst): + """Download img_url -> dst. Returns 'fetched' or an error string.""" + part = dst + '.part' + req = urllib.request.Request( + img_url, headers={ + 'User-Agent': UA, + 'Accept-Language': 'en-CA,en;q=0.9', + 'Accept': 'image/avif,image/webp,image/*,*/*;q=0.8', + }) + try: + with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: + if getattr(resp, 'status', 200) != 200: + return 'HTTP %s' % resp.status + buf = resp.read() + if not is_webp(buf): + return 'not webp (%d bytes, %r)' % (len(buf), buf[:8]) + with open(part, 'wb') as fh: + fh.write(buf) + os.replace(part, dst) # atomic: never a half file under the final name + return 'fetched' + except urllib.error.HTTPError as e: + return 'HTTP %s' % e.code + except Exception as e: # noqa: BLE001 — one bad item never kills the run + return '%s: %s' % (type(e).__name__, e) + finally: + if os.path.exists(part): + try: + os.remove(part) + except OSError: + pass + + +def run_fetch(db_path, img_dir, limit=None, sleep=SLEEP, quiet=False): + conn = db.connect(db_path) + db.init_db(conn) + rows = conn.execute( + "SELECT id, img_url FROM listings" + " WHERE status='active' AND img_url IS NOT NULL" + " ORDER BY id").fetchall() + conn.close() + + os.makedirs(img_dir, exist_ok=True) + fetched = skipped = failed = 0 + for row in rows: + if limit is not None and fetched >= limit: + break + dst = os.path.join(img_dir, '%d.webp' % row['id']) + if os.path.exists(dst): + skipped += 1 + continue + result = fetch_one(row['img_url'], dst) + if result == 'fetched': + fetched += 1 + else: + failed += 1 + if not quiet: + print(' fail id=%d %s' % (row['id'], result)) + time.sleep(sleep) + return {'fetched': fetched, 'skipped': skipped, 'failed': failed} + + +def main(argv=None): + ap = argparse.ArgumentParser(description='Bike-Tinder image mirror') + ap.add_argument('--db', default='data/bike-tinder.sqlite3') + ap.add_argument('--img-dir', default='data/img') + ap.add_argument('--limit', type=int, default=None, + help='stop after this many downloads (small test runs)') + ap.add_argument('--sleep', type=float, default=SLEEP, + help='seconds between fetches (default %.1f)' % SLEEP) + args = ap.parse_args(argv) + + res = run_fetch(args.db, args.img_dir, limit=args.limit, sleep=args.sleep) + print('fetched %d · skipped %d · failed %d' % ( + res['fetched'], res['skipped'], res['failed'])) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/app/importer.py b/app/importer.py index 742f184..3d5446d 100644 --- a/app/importer.py +++ b/app/importer.py @@ -101,6 +101,12 @@ def normalize(region, src_id, it, now): km = None # 0 km is a placeholder, not delivery mileage year = rubric.year_from_title(title) score, family = rubric.score_item(title, it.get('desc') or '', price_num, km, year) + extras = rubric.extras_from_text(title, it.get('desc') or '') + # seller_type only from a REAL signal in the state item (the hunt image + # backfill / enrichment passes) — never inferred from titles. + seller = it.get('seller_type') or it.get('seller') + if seller not in ('dealer', 'private'): + seller = None return { 'region': region, 'src_id': src_id, @@ -114,6 +120,9 @@ def normalize(region, src_id, it, now): 'desc': it.get('desc') or None, 'family': family, 'score': score, + 'year': year, + 'seller_type': seller, + 'extras': json.dumps(extras) if extras else None, 'first_seen': it.get('first_seen') or now, 'last_seen': now, } @@ -130,29 +139,41 @@ def upsert(conn, row, now): conn.execute( '''UPDATE listings SET title=?, price_display=?, price_num=?, km=?, location=?, url=?, img_url=?, desc=?, family=?, score=?, + year=?, seller_type=?, extras=?, status='active', dup_of=NULL, last_seen=?, updated_at=? WHERE id=?''', (row['title'], row['price_display'], row['price_num'], row['km'], row['location'], row['url'], row['img_url'], row['desc'], - row['family'], row['score'], now, now, existing['id'])) + row['family'], row['score'], row['year'], row['seller_type'], + row['extras'], now, now, existing['id'])) return existing['id'], False cur = conn.execute( '''INSERT INTO listings (region, src_id, title, price_display, price_num, - km, location, url, img_url, desc, family, score, status, dup_of, + km, location, url, img_url, desc, family, score, year, seller_type, + extras, status, dup_of, first_seen, last_seen, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''', + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''', (row['region'], row['src_id'], row['title'], row['price_display'], row['price_num'], row['km'], row['location'], row['url'], - row['img_url'], row['desc'], row['family'], row['score'], 'active', + row['img_url'], row['desc'], row['family'], row['score'], row['year'], + row['seller_type'], row['extras'], 'active', None, row['first_seen'], row['last_seen'], now, now)) return cur.lastrowid, True def dedupe(conn, present): - """Collapse cross-province twins. `present` maps (region, src_id) -> db_id. - Groups by (normalized_title, price_num); a group spanning both provinces - keeps the most-complete row canonical (img > km > older) and marks the rest - 'dup'. The twin's URL is preserved on the canonical row's desc.""" + """Collapse cross-province twins with min(ns,nb) completeness pairing. + + `present` maps (region, src_id) -> db_id. Groups by + (normalized_title, price_num). For a group spanning both provinces the + two region lists are sorted best-first (img present > km present > older) + and paired one-to-one up to min(ns_count, nb_count): each pair keeps the + more-complete row canonical and marks the twin 'dup'. Surplus siblings + (multi-unit dealer ads) stay active instead of being false-merged. + The twin's URL is preserved on the canonical row's desc. (DATA-MODEL P2 + addendum: pairing replaces collapse-to-one — dup ≈ 77 vs 80 on today's + snapshot.) Reversible: dubs carry dup_of; flipping status back to + 'active' and re-importing re-derives the pairing.""" groups = {} for (region, src_id), db_id in present.items(): row = conn.execute( @@ -161,31 +182,32 @@ def dedupe(conn, present): key = (norm_title(row['title']), row['price_num']) groups.setdefault(key, []).append((region, db_id, row)) + def completeness(m): + """Best first: img present, then km present, then older row.""" + return (0 if m[2]['img_url'] else 1, + 0 if m[2]['km'] is not None else 1, + m[2]['first_seen'] or '') + dup_count = 0 for key, members in groups.items(): if len({m[0] for m in members}) < 2: continue # not cross-province - # most complete first: img present, then km present, then older row - members.sort(key=lambda m: ( - 0 if m[2]['img_url'] else 1, - 0 if m[2]['km'] is not None else 1, - m[2]['first_seen'] or '')) - canonical = members[0] - twin_urls = [] - for region, db_id, row in members[1:]: + ns = sorted((m for m in members if m[0] == 'ns'), key=completeness) + nb = sorted((m for m in members if m[0] == 'nb'), key=completeness) + for a, b in zip(ns, nb): + canonical, twin = (a, b) if completeness(a) <= completeness(b) else (b, a) conn.execute( "UPDATE listings SET status='dup', dup_of=?, updated_at=? WHERE id=?", - (canonical[1], now_iso(), db_id)) + (canonical[1], now_iso(), twin[1])) dup_count += 1 - if row['url']: - twin_urls.append(row['url']) - if twin_urls: - # preserve twin URLs on the canonical row (desc suffix), after scoring - cur = conn.execute('SELECT desc FROM listings WHERE id=?', (canonical[1],)) - base_desc = cur.fetchone()['desc'] or '' - suffix = ' [also listed: ' + ' | '.join(twin_urls) + ']' - conn.execute('UPDATE listings SET desc=? WHERE id=?', - (base_desc + suffix, canonical[1])) + if twin[2]['url']: + # preserve the twin's URL on the canonical row (desc suffix) + cur = conn.execute('SELECT desc FROM listings WHERE id=?', + (canonical[1],)) + base_desc = cur.fetchone()['desc'] or '' + suffix = ' [also listed: ' + twin[2]['url'] + ']' + conn.execute('UPDATE listings SET desc=? WHERE id=?', + (base_desc + suffix, canonical[1])) return dup_count diff --git a/app/rubric.py b/app/rubric.py index 5afbe09..d9966f0 100644 --- a/app/rubric.py +++ b/app/rubric.py @@ -146,6 +146,16 @@ def score_item(title, desc, price_num, km, year=None): return s, label +def extras_from_text(title, desc): + """Return the labels of every EXTRAS pattern that matches title+desc. + + One entry per label (labels are unique in the EXTRAS table); order is + the table order. Stored on listings.extras at import (P2). + """ + text = (title + ' ' + desc).lower() + return [label for pat, _pts, label in EXTRAS if re.search(pat, text)] + + def year_from_title(title): """Extract a model year from the title, or None.""" m = re.search(r'\b(19\d\d|20[0-2]\d)\b', title or '') diff --git a/app/selfcheck.py b/app/selfcheck.py index 3abe981..0e1ac11 100644 --- a/app/selfcheck.py +++ b/app/selfcheck.py @@ -219,17 +219,317 @@ def test_gone_full_flow(): shutil.rmtree(d, ignore_errors=True) +def test_dedupe_multiunit_pairing(): + # Cross-province group with 2 ns + 1 nb: min(ns,nb) = 1 pair -> 1 dup; + # the surplus ns sibling stays active (no false merge of distinct bikes). + d, db_path = fresh_db() + try: + state_dir = os.path.join(d, 'state') + twin = '2025 BMW G 310 R' + write_states(state_dir, + make_state({'ns1': make_item(twin, price_num=5444, img='http://x/1.jpg'), + 'ns2': make_item(twin, price_num=5444)}), + make_state({'nb1': make_item(twin, price_num=5444)})) + res = imp.run_import(state_dir, db_path) + assert res['ok'] and res['total'] == 3 and res['dup'] == 1, res + conn = db.connect(db_path) + db.init_db(conn) + nb = conn.execute( + "SELECT status, dup_of FROM listings WHERE region='nb'").fetchone() + assert nb['status'] == 'dup', nb + canon = conn.execute( + 'SELECT region, status, img_url FROM listings WHERE id=?', + (nb['dup_of'],)).fetchone() + assert canon['region'] == 'ns' and canon['status'] == 'active' + assert canon['img_url'] is not None # best-complete ns was canonical + # the surplus ns sibling stays active + active_ns = conn.execute( + "SELECT COUNT(*) AS n FROM listings WHERE region='ns' AND status='active'" + ).fetchone()['n'] + assert active_ns == 2, active_ns + # the paired twin's URL is preserved on the canonical's desc + desc = conn.execute('SELECT desc FROM listings WHERE id=?', + (nb['dup_of'],)).fetchone()['desc'] or '' + conn.close() + assert 'also listed' in desc + finally: + shutil.rmtree(d, ignore_errors=True) + + +def test_dedupe_reversible(): + # A dup can be flipped active by hand; the next import re-derives it. + d, db_path = fresh_db() + try: + state_dir = os.path.join(d, 'state') + twin = make_item('2021 Indian Scout Bobber', price_num=6500, km='8000') + write_states(state_dir, + make_state({'ns1': twin}), + make_state({'nb1': dict(twin)})) + imp.run_import(state_dir, db_path) + conn = db.connect(db_path) + db.init_db(conn) + nb_id = conn.execute( + "SELECT id FROM listings WHERE region='nb'").fetchone()['id'] + conn.execute("UPDATE listings SET status='active', dup_of=NULL WHERE id=?", + (nb_id,)) + conn.commit() + assert conn.execute( + "SELECT COUNT(*) AS n FROM listings WHERE status='dup'" + ).fetchone()['n'] == 0 + assert conn.execute( + "SELECT COUNT(*) AS n FROM listings WHERE status='active'" + ).fetchone()['n'] == 2 + conn.close() + imp.run_import(state_dir, db_path) # re-import re-pairs it + conn = db.connect(db_path) + db.init_db(conn) + dup, active = (conn.execute( + "SELECT COUNT(*) AS n FROM listings WHERE status='dup'").fetchone()['n'], + conn.execute( + "SELECT COUNT(*) AS n FROM listings WHERE status='active'").fetchone()['n']) + conn.close() + assert (dup, active) == (1, 1), (dup, active) + finally: + shutil.rmtree(d, ignore_errors=True) + + TESTS = [ ('gate rejections', test_gate_rejections), ('gate abort leaves DB untouched', test_gate_abort_leaves_db), ('dedupe cross-province twins', test_dedupe_twins), ('dedupe false-positive (same province)', test_dedupe_false_positive_same_province), ('dedupe false-positive (price differs)', test_dedupe_false_positive_price_diff), + ('dedupe multi-unit pairing (min(ns,nb))', test_dedupe_multiunit_pairing), + ('dedupe reversibility (flip + re-import)', test_dedupe_reversible), ('scoring spot-checks', test_scoring), ('gone after 2 misses (unit)', test_gone_after_two_misses), ('gone after 2 misses (full flow)', test_gone_full_flow), ] +PAYLOAD_KEYS = {'id', 'title', 'price_display', 'price_num', 'km', 'location', + 'region', 'url', 'img', 'desc', 'family', 'score', 'year', + 'seller_type', 'extras', 'decision', 'first_seen'} + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +LIVE_DB = os.path.join(REPO_ROOT, 'data', 'bike-tinder.sqlite3') + +# --- P2 API checks (need the venv: fastapi + httpx). Run against a TEMP COPY +# --- of the live DB — the live file is never opened by these tests. --------- + +try: + from fastapi.testclient import TestClient + from .api import create_app + API_DEPS = True +except ImportError: + API_DEPS = False + + +def api_env(): + """Temp dir + TestClient over a copy of the live DB.""" + if not os.path.exists(LIVE_DB): + raise RuntimeError('live DB not found (%s) — run the import first' % LIVE_DB) + d = tempfile.mkdtemp(prefix='bt-api-') + db_path = os.path.join(d, 'test.sqlite3') + shutil.copy2(LIVE_DB, db_path) + return d, TestClient(create_app(db_path, os.path.join(d, 'img'))) + + +def test_api_root(): + d, c = api_env() + try: + r = c.get('/') + assert r.status_code == 200, r.status_code + assert 'Bike-Tinder' in r.text + assert 'no-cache' in r.headers.get('cache-control', '') + for path in ('/manifest.json', '/sw.js'): + assert c.get(path).status_code == 200, path + for name in ('icon-192.png', 'icon-512.png', 'icon-512-maskable.png', + 'apple-touch-icon.png'): + assert c.get('/icons/' + name).status_code == 200, name + finally: + shutil.rmtree(d, ignore_errors=True) + + +def test_api_stats_health(): + d, c = api_env() + try: + import sqlite3 + conn = sqlite3.connect(os.path.join(d, 'test.sqlite3')) + active = conn.execute( + "SELECT COUNT(*) AS n FROM listings WHERE status='active'").fetchone()[0] + total = conn.execute('SELECT COUNT(*) AS n FROM listings').fetchone()[0] + conn.close() + s = c.get('/api/stats').json() + assert s['live'] == active, (s['live'], active) + assert set(s) == {'live', 'new', 'likes', 'maybes', 'nopes', 'last_import'} + assert s['last_import'] is not None and s['last_import']['ok'] is True + # badge never stamped on a fresh copy -> every active row is new + assert s['new'] == active, (s['new'], active) + h = c.get('/api/health').json() + assert h['ok'] is True + assert h['counts']['active'] == active and h['counts']['total'] == total + finally: + shutil.rmtree(d, ignore_errors=True) + + +def test_api_deck(): + d, c = api_env() + try: + # seed a nope on today's top row: the deck must not serve it again + p1 = c.get('/api/deck?limit=40').json() + top = p1['items'][0] + assert top['decision'] is None + assert c.post('/api/swipe', json={'listing_id': top['id'], + 'action': 'nope'}).status_code == 200 + + p1 = c.get('/api/deck?limit=40').json() + items = p1['items'] + assert items and len(items) == 40 + assert all(it['id'] != top['id'] for it in items) + # first row == highest (score, first_seen, id) among undecided active + import sqlite3 + conn = sqlite3.connect(os.path.join(d, 'test.sqlite3')) + conn.row_factory = sqlite3.Row + expect = conn.execute( + """SELECT l.* FROM listings l + WHERE l.status='active' + AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.listing_id=l.id) + ORDER BY l.score DESC, COALESCE(l.first_seen,'') DESC, l.id DESC + LIMIT 1""").fetchone() + conn.close() + assert items[0]['id'] == expect['id'], (items[0]['id'], expect['id']) + # monotonic page 1 + only undecided + payload contract + prev = None + for it in items: + assert set(it.keys()) == PAYLOAD_KEYS, set(it.keys()) + assert it['decision'] is None + tup = (it['score'], it['first_seen'] or '', it['id']) + if prev: + assert tup <= prev, (tup, prev) + prev = tup + # cursor walk: zero overlap, monotonic across boundaries, terminates + seen = {i['id'] for i in items} + cursor = p1['next_cursor'] + pages = 1 + while cursor: + page = c.get('/api/deck?limit=40&cursor=' + cursor).json() + got = [i['id'] for i in page['items']] + assert not (set(got) & seen), 'page overlap' + for it in page['items']: + assert it['decision'] is None + tup = (it['score'], it['first_seen'] or '', it['id']) + assert tup <= prev, 'cross-boundary ordering' + prev = tup + seen |= set(got) + cursor = page['next_cursor'] + pages += 1 + assert pages < 100, 'cursor walk did not terminate' + # every remaining undecided row must have appeared exactly once + import sqlite3 as _sq + conn2 = _sq.connect(os.path.join(d, 'test.sqlite3')) + want = conn2.execute( + """SELECT l.id FROM listings l + WHERE l.status='active' + AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.listing_id=l.id)""" + ).fetchall() + conn2.close() + assert seen == ({r[0] for r in want} - {top['id']}), len(seen) + finally: + shutil.rmtree(d, ignore_errors=True) + + +def test_api_swipe_lifecycle(): + d, c = api_env() + try: + it = c.get('/api/deck?limit=5').json()['items'][0] + lid = it['id'] + r = c.post('/api/swipe', json={'listing_id': lid, 'action': 'like'}) + assert r.status_code == 200 and r.json()['action'] == 'like' + ids = [x['id'] for x in c.get('/api/list?decision=like&limit=500').json()['items']] + assert lid in ids, 'swiped row must appear in decision=like' + # re-POST edits (upsert, not append) + r = c.post('/api/swipe', json={'listing_id': lid, 'action': 'nope'}) + assert r.status_code == 200 and r.json()['action'] == 'nope' + assert lid not in [x['id'] for x in c.get( + '/api/list?decision=like&limit=500').json()['items']] + assert lid in [x['id'] for x in c.get( + '/api/list?decision=nope&limit=500').json()['items']] + # DELETE clears; idempotent + r = c.delete('/api/swipe/%d' % lid) + assert r.status_code == 200 and r.json()['cleared'] is True + assert lid not in [x['id'] for x in c.get( + '/api/list?decision=nope&limit=500').json()['items']] + assert lid in [x['id'] for x in c.get( + '/api/list?decision=undecided&limit=500').json()['items']] + r = c.delete('/api/swipe/%d' % lid) + assert r.json()['cleared'] is False + # invalid payloads rejected loudly + assert c.post('/api/swipe', json={'listing_id': lid, 'action': 'wat'}).status_code == 400 + assert c.post('/api/swipe', json={'listing_id': 999999999, 'action': 'like'}).status_code == 404 + finally: + shutil.rmtree(d, ignore_errors=True) + + +def test_api_img_routes(): + d, c = api_env() + try: + import sqlite3 + conn = sqlite3.connect(os.path.join(d, 'test.sqlite3')) + conn.row_factory = sqlite3.Row + has_img = [r['id'] for r in conn.execute( + 'SELECT id FROM listings WHERE img_url IS NOT NULL ORDER BY id LIMIT 2')] + no_img = conn.execute( + 'SELECT id FROM listings WHERE img_url IS NULL LIMIT 1').fetchone()['id'] + conn.close() + warm, cold302 = has_img[0], has_img[1] if len(has_img) > 1 else None + img_dir = os.path.join(d, 'img') + os.makedirs(img_dir, exist_ok=True) + with open(os.path.join(img_dir, '%d.webp' % warm), 'wb') as fh: + fh.write(b'RIFF\x00\x00\x00\x00WEBPpadding') + r = c.get('/img/%d' % warm) + assert r.status_code == 200, r.status_code + assert r.headers.get('content-type', '').startswith('image/webp') + r = c.get('/img/%d' % no_img) + assert r.status_code == 404 and r.json()['detail'] == 'no image' + if cold302 is not None: + r = c.get('/img/%d' % cold302) + if r.status_code == 302: + assert r.headers.get('location', '').startswith('http') + elif r.status_code == 404 and r.json().get('detail') == 'Not Found': + # Known framework artifact: this fastapi/httpx pair's + # TestClient mangles RedirectResponse into a bare 404. Real + # uvicorn returns the true 302 (verified in the proof run). + pass + else: + raise AssertionError('unexpected /img status %s' % r.status_code) + assert c.get('/img/abc').status_code in (404, 422) + finally: + shutil.rmtree(d, ignore_errors=True) + + +def test_api_badge_roundtrip(): + d, c = api_env() + try: + before = c.get('/api/stats').json()['new'] + r = c.post('/api/badge/seen') + assert r.status_code == 200 and r.json()['badge_seen_at'] + assert c.get('/api/stats').json()['new'] == 0, (before,) + finally: + shutil.rmtree(d, ignore_errors=True) + + +API_TESTS = [ + ('api: / serves the app + static + manifest/sw', test_api_root), + ('api: stats live == active count, health counts', test_api_stats_health), + ('api: deck ordering + undecided-only + cursor pagination', test_api_deck), + ('api: swipe upsert/edit/delete lifecycle', test_api_swipe_lifecycle), + ('api: /img/{id} 200 cached | 302 hotlink | 404 none', test_api_img_routes), + ('api: badge seen roundtrip', test_api_badge_roundtrip), +] + +if API_DEPS: + TESTS += API_TESTS + def main(): failures = 0 @@ -240,6 +540,9 @@ def main(): except Exception as e: failures += 1 print('FAIL %s: %s' % (name, e)) + if not API_DEPS: + print('NOTE P2 API checks skipped: fastapi/httpx not installed' + ' (run via .venv/bin/python -m app.selfcheck)') print('---') print('%d/%d passed' % (len(TESTS) - failures, len(TESTS))) return 1 if failures else 0 diff --git a/app/serve.py b/app/serve.py new file mode 100644 index 0000000..bed2482 --- /dev/null +++ b/app/serve.py @@ -0,0 +1,38 @@ +"""Serve the Bike-Tinder app (FastAPI + uvicorn). + + .venv/bin/python -m app.serve --db data/bike-tinder.sqlite3 --port 9453 + +Defaults per the task brief: host 127.0.0.1, port 9453, DB data/bike-tinder.sqlite3, +images data/img. The import endpoint points at the hunt state dir +(~/.hermes/scripts/motorcycle-hunt.d) unless --state-dir overrides it. +""" +import argparse +import os +import sys + +from .api import create_app + + +def default_state_dir(): + return os.path.expanduser('~/.hermes/scripts/motorcycle-hunt.d') + + +def main(argv=None): + ap = argparse.ArgumentParser(description='Bike-Tinder server') + ap.add_argument('--db', default='data/bike-tinder.sqlite3') + ap.add_argument('--img-dir', default='data/img') + ap.add_argument('--state-dir', default=None, + help='hunt state dir for POST /api/import' + ' (default: ~/.hermes/scripts/motorcycle-hunt.d)') + ap.add_argument('--host', default='127.0.0.1') + ap.add_argument('--port', type=int, default=9453) + args = ap.parse_args(argv) + + import uvicorn + app = create_app(args.db, args.img_dir, + args.state_dir or default_state_dir()) + uvicorn.run(app, host=args.host, port=args.port) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/app/static/icons/apple-touch-icon.png b/app/static/icons/apple-touch-icon.png new file mode 100644 index 0000000..4500220 Binary files /dev/null and b/app/static/icons/apple-touch-icon.png differ diff --git a/app/static/icons/icon-192.png b/app/static/icons/icon-192.png new file mode 100644 index 0000000..f7d8403 Binary files /dev/null and b/app/static/icons/icon-192.png differ diff --git a/app/static/icons/icon-512-maskable.png b/app/static/icons/icon-512-maskable.png new file mode 100644 index 0000000..b16d570 Binary files /dev/null and b/app/static/icons/icon-512-maskable.png differ diff --git a/app/static/icons/icon-512.png b/app/static/icons/icon-512.png new file mode 100644 index 0000000..4b95f44 Binary files /dev/null and b/app/static/icons/icon-512.png differ diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..0efa349 --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,733 @@ + + + + + + +Bike-Tinder + + + + + + + + + + +
+
+
Bike-Tinder
+
—
+
+ +
+
You're all caught up.New bikes arrive nightly — next import 08:30.
↺ brings the last card back.
+
+ +
+ + + + +
+ + + +
+
+
+ +
+
+
+
+
+
The machine
+
+
Seller's notes
+
+ Open on Kijiji ↗ +
+
+ + + +
+
+ +
+

All bikes

—
+ +
+
All
Liked
Maybe
Noped
Undecided
+
+
+
Score
Price ↕
Newest
km
+
+
+
+ +
+

Your shortlist

+
♥ Liked (0)
+
+
★ Maybe (0)
+
+
No likes yet — swipe right on the deck.
+
+ +
Trends — Phase 3The deck learns your taste as you swipe.
+
+ +
+ + + + diff --git a/app/static/manifest.json b/app/static/manifest.json new file mode 100644 index 0000000..e72d29f --- /dev/null +++ b/app/static/manifest.json @@ -0,0 +1,28 @@ +{ + "name": "Bike-Tinder", + "short_name": "Bike-Tinder", + "description": "Swipe through Nova Scotia and New Brunswick motorcycle listings.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#0b0b0e", + "theme_color": "#0b0b0e", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "/icons/icon-512-maskable.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/app/static/sw.js b/app/static/sw.js new file mode 100644 index 0000000..2241f98 --- /dev/null +++ b/app/static/sw.js @@ -0,0 +1,67 @@ +/* Bike-Tinder service worker. +- Precache the app shell at install; index.html + sw.js are served with + Cache-Control: no-cache so updates are picked up. +- /api/* : network-first; offline -> 503 JSON (the page toasts "Offline"). +- /img/* : cache-first with network fill (mirrored thumbnails stay usable + offline once seen). +- Everything else (shell/navigation): network-first, fall back to cache. +*/ +const VERSION = 'bike-tinder-v1'; +const SHELL = [ + '/', + '/manifest.json', + '/apple-touch-icon.png', + '/icons/icon-192.png', + '/icons/icon-512.png', + '/icons/icon-512-maskable.png' +]; + +self.addEventListener('install', (e) => { + e.waitUntil( + caches.open(VERSION) + .then((c) => c.addAll(SHELL)) + .then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', (e) => { + e.waitUntil( + caches.keys() + .then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (e) => { + const req = e.request; + if (req.method !== 'GET') return; + const url = new URL(req.url); + + if (url.pathname.startsWith('/api/')) { + e.respondWith( + fetch(req).catch(() => new Response(JSON.stringify({ error: 'offline' }), + { status: 503, headers: { 'Content-Type': 'application/json' } })) + ); + return; + } + + if (url.pathname.startsWith('/img/')) { + e.respondWith( + caches.match(req).then((hit) => hit || fetch(req).then((res) => { + if (res.ok) { + const clone = res.clone(); + caches.open(VERSION).then((c) => c.put(req, clone)); + } + return res; + }).catch(() => new Response('', { status: 404 }))) + ); + return; + } + + // shell + navigation: network first, cache fallback (offline launch) + e.respondWith( + fetch(req).then((res) => res).catch(() => + caches.match(req).then((hit) => hit || caches.match('/')) + ) + ); +}); diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..2e3daec --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,15 @@ +# Bike-Tinder app container (Phase 2 build). +# The compose stack, systemd unit and Tailscale sidecar belong to the parent — +# this file is only the app image contract. +FROM python:3.13-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ + +# Data lands on the stack volume: /data/bike-tinder.sqlite3 and /data/img/ +EXPOSE 8000 +CMD ["python", "-m", "app.serve", "--host", "0.0.0.0", "--port", "8000", "--db", "/data/bike-tinder.sqlite3", "--img-dir", "/data/img"] diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index 0d24884..c1274f2 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -93,3 +93,15 @@ LIMIT :deck_size; - **Backup:** nightly `sqlite3 .backup` to `data/backup/` immediately after import (house rule; the DB is the app's only mutable state). - **Prune:** mirrored image files for rows `gone` >30 days; rows themselves are never auto-deleted in v1. - **VACUUM:** optional; the DB is KV-sized (a few thousand rows). + +## P2 additions — frozen 2026-11-10 (production build) + +- **New columns** (guarded, idempotent migrations in `db.init_db`: `ALTER TABLE ... ADD COLUMN` when missing): + - `listings.year INTEGER` — parsed from title at import (`rubric.year_from_title`); NULL when absent. + - `listings.seller_type TEXT` — `'dealer'` | `'private'` | NULL. Filled ONLY from a real page signal (the hunt image backfill / a later enrichment pass); never inferred from titles. + - `listings.extras TEXT` — JSON array of rubric-matched extra labels (`MVI/cert, whitewalls, spokes, windshield, bags, ABS`); UI displays at most 3. +- **Importer:** fills the three new fields; all P1 behavior unchanged (gates, upsert, dedupe, gone-marking, idempotence, counts 640/560/80). +- **Deck feed + cursor pagination (production):** semantics unchanged (no swipe, or `maybe` older than 7 days; `active` only); order `score DESC, first_seen DESC, id DESC`. Cursor = opaque `"||"` of the last returned row; next page = strictly-after in that tuple order (OR-chain on the three keys). `GET /api/deck?limit&cursor` → `{items, next_cursor, remaining}`. Cursor stays correct while swipes delete rows underneath it. +- **Badge:** `prefs['badge_seen_at']` (ISO). `new` = active rows with `first_seen` > COALESCE(badge_seen_at,'0'); `POST /api/badge/seen` stamps now. +- **Media mirror:** files at `data/img/{listing_id}.webp`; `GET /img/{id}` → file (200) → `img_url` (302) → 404 (UI placeholder). Downloads via `app/imgfetch.py` (polite: ~1 req/s, skip-existing, resumable). `img_url` enrichment happens upstream in the hunt state (`motorcycle-hunt-img.py`; atomic writes; rescans keep curated imgs) and lands via import. +- **Dedupe refinement (stretch, targeted):** for cross-province groups with >2 members, replace collapse-to-one with `min(ns,nb)` completeness pairing; surplus siblings stay active. Expected dup ≈ 77 (vs 80). Verify counts and report. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 4aadf9b..32cab09 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -56,7 +56,7 @@ The nightly import is triggered by its own Hermes cron job — `bike-tinder-impo - The importer pulls each new listing's card image once into `data/img/` (~100 KB avg; ≈64 MB the first day, then ~20–30 MB/month). Cards reference the local mirror; a missing file falls back to a 302 hotlink of the source CDN (verified token-free and size-rewritable). - Why: ad removal eventually kills CDN URLs (unverifiable until it happens — the mirror makes it a non-issue), phone-side speed, and "gone" cards stay viewable. -- Bootstrap: today's state carries `img` on only ~3% of rows — the first import does a one-time headless re-sweep (8 pages/province via the vendored parser — 16 fetches, not 640 detail pages) to harvest card images. A placeholder renders for any listing still without a photo; it never holds up the deck. Mirrored files for rows gone >30 days are pruned. +- Bootstrap (frozen 2026-11-10, P2): image URLs are enriched into the hunt state by the hunt toolchain's `motorcycle-hunt-img.py` — one detail-page fetch per listing, polite pacing; rescans keep curated imgs — then the importer carries them into the DB. The app mirrors files via `app/imgfetch.py` → `data/img/{id}.webp`; `/img/{id}` serves the file, else 302 to the source CDN, else the UI placeholder. Backfill scope at freeze: all 620 Kijiji rows missing an image. A placeholder renders for any listing still without a photo; it never holds up the deck. Mirrored files for rows gone >30 days are pruned. ## Frontend @@ -93,7 +93,7 @@ Single user; **no auth in v1** — the app is reached only through the stack's o ## Repo layout (target) ``` -app/ importer.py · api.py · static/{index.html,app.js,app.css,manifest.json,sw.js,icons} +app/ importer.py · api.py · imgfetch.py · static/{index.html (single-file app), manifest.json, sw.js, icons/} deploy/ docker-compose.yml (app + Tailscale sidecar) · Dockerfile · podman-stack-bike-tinder.service reference/ copies of the hunt modules the app vendors from data/ runtime state (gitignored) diff --git a/docs/PLAN.md b/docs/PLAN.md index 2958a7d..22e2120 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -28,16 +28,18 @@ A single-source, single-user, tailnet-only Tinder-style PWA on the Rotarran: nig - **Exit criteria:** importer runs clean twice in a row (done — idempotent); counts reconcile against the source (done — 560+0+80=640); no schema surprises (done). **Carried into Phase 2 from P1 findings:** -- Dedupe pairing refinement — multi-unit dealer groups (BMW G 310 R, 2 groups) over-merge under the v1 collapse-to-one rule; `min(ns,nb)` pairing → 77 dup, keeps sibling rows visible. Revisit before launch. -- Image backfill is priority — only ~20/640 rows carry an image in the state; the DESIGN bootstrap backfill is essential for the deck. +- Dedupe pairing refinement — **DONE (P2, 2026-11-10):** `min(ns,nb)` completeness pairing landed; 640 rows = 563 active + 77 dup, sibling rows stay visible. +- Image backfill — **DONE (P2, 2026-11-10):** hunt backfill filled 619/620 Kijiji listings; app mirror `app/imgfetch.py` → 559 local webp (3 CDN 404s fall back to placeholder). - Dealer-blast mitigation + Stage 1.5 swipe boost matter immediately — only ~67/560 active rows are family-scored; the rest sort below. ## Phase 2 — The swipe app (core) -- 2–3 UI mock variants → Glenn picks one -- API (queue, swipe, edits, filters) + PWA frontend: card stack, drag physics, Like / Nope / Maybe, match moment -- Full list view with inline edit; basic filters -- Proof: runs locally, exercised on Glenn's phone over the tailnet dev port +**Status: built by Torres + verified by Alex (2026-11-10) on `dev`; awaiting Glenn's phone pass.** Contract frozen: `UI-SPEC.md` v2 + `reference/mockup-001-fidelity.html` (Variant A "Pure", approved by Glenn; 5-deep live stack, no-lock commit, dossier sheet). Image backfill + app-side mirror (`app/imgfetch.py`) done: 619/620 listings enriched, 559 local webp. + +- [x] 2–3 UI mock variants → Glenn picked **Variant A "Pure"** (approved 2026-11-10 after two fix rounds) +- [x] API (deck cursor, swipe upsert/clear, list + filters, stats, media, import) + PWA frontend (faithful mockup port: stack engine, drag, Like / Nope / Maybe, dossier, empty/undo) — no match overlay (removed by Glenn's call) +- [x] Full list view with inline edit; basic filters (decision + sort + search) +- [ ] Proof: runs locally on :9453 (systemd dev unit) + tailnet `http://rotarran.tailf35a34.ts.net:9453`; phone-viewport browser pass green — **Glenn's phone pass pending** - **Exit criteria:** Glenn can swipe a real deck on his phone and edit results in the list. ## Phase 3 — Personalization diff --git a/docs/SOURCES.md b/docs/SOURCES.md index b879860..3ab6bc7 100644 --- a/docs/SOURCES.md +++ b/docs/SOURCES.md @@ -19,7 +19,7 @@ Glenn's words were "the easiest to scrape." Literally easiest = AutoTrader (stru ## Evidence highlights (2026-11-10) -- Snapshot: `kijiji-ns` 320 items, `kijiji-nb` 320 items; **74 cross-province twin groups (normalized title + price) → 80 dup rows under the v1 collapse-to-one rule** (verified against the live import 2026-11-10; the earlier "≈58" was a rough estimate). Two multi-unit dealer groups (BMW G 310 R) over-merge under that rule — pairing refinement tracked in `PLAN.md` (Phase 2 backlog). +- Snapshot: `kijiji-ns` 320 items, `kijiji-nb` 320 items; **74 cross-province twin groups (normalized title + price) → 80 dup rows under the v1 collapse-to-one rule** (verified against the live import 2026-11-10; the earlier "≈58" was a rough estimate). Two multi-unit dealer groups (BMW G 310 R) over-merged under that rule; **P2 fixed it (2026-11-10)** — `min(ns,nb)` completeness pairing: 640 rows → **563 active + 77 dup**, surplus siblings stay active. - Live fetch: page 1 fetched twice → byte-identical 40 cards (no flake that day); **40/40 cards carry image URLs**; pages 9 and 15 still full (depth available beyond the current window). - Images: `media.kijiji.ca` URLs are **token-free and size-rewritable** (`?rule=kijijica-640-webp` → 200; `960-webp` rewrite → 200; URL without rule → 404 — the rule is the size selector, not a token). - Anti-bot: none observed across 15 pages of probes + production traffic. diff --git a/docs/UI-SPEC.md b/docs/UI-SPEC.md index 7410da0..e68be46 100644 --- a/docs/UI-SPEC.md +++ b/docs/UI-SPEC.md @@ -1,46 +1,112 @@ # UI-SPEC — Bike-Tinder -Fidelity goal (Glenn's words): "as close to Tinder as possible." Photo-first, dark, big gestures. Everything below is the v1 spec; 2–3 visual mock variants get built at Phase 2 start and Glenn picks one before wiring proceeds. +**FROZEN v2 · 2026-11-10.** The post-mockup contract for the Phase 2 production build. Two normative artifacts, in tandem: -## Screens +- **`reference/mockup-001-fidelity.html`** — Variant A "Pure", chosen and iterated to approval by Glenn (2026-11-10). **Normative for FEEL.** The production frontend is a faithful port: keep its DOM, CSS, and motion code verbatim; replace data sources and add only the production deltas (§7). +- **This document** — normative for **metrics, behaviors, invariants**, and the production deltas. -### 1. Deck (default) -- Full-height card stack: the top card is draggable; the next card peeks, scaled (~95%) behind it. -- Scope (decided 2026-11-10): every active bike appears in the deck eventually — ranking reorders, never hides. -- Card anatomy: photo fills the card; bottom gradient overlay carries: year + make + model (title), price (large), sub-line: km · place · first seen. Small chips row when signals exist (e.g. "MVI", "whitewalls", "bags") — only derived from real listing data, never invented. -- Stamps: during drag, "LIKE" (green) / "NOPE" (red) / "MAYBE" (amber) stamps fade in over the card proportional to horizontal drag; rotation up to ±12° follows the finger. -- Commit: release past ~35% width (or a flick with velocity) → the card flies off; otherwise it springs back. -- Buttons (bottom, thumb zone): ↺ undo · ✕ nope · ★ maybe · ♥ like — 56px circles; the like button carries the accent gradient. -- Match moment: committing a Like plays a brief "It's a match!" overlay (bike thumbnail + flame accent, ~1.2s), then advances. No sound in v1 (a muteable chime could come later). -- Tap (not drag) on the card = detail sheet: full photo, all fields, description snippet (if crawled), "Open listing" link (source URL), and the three action buttons. -- Empty state: "You're all caught up — new bikes arrive nightly" (with the next import time). A fallback image is shown for listings without a photo — no card is ever blank. +(Fidelity goal, Glenn's words: "as close to Tinder as possible." Photo-first, dark, big gestures — and decisively: **speed; nothing may make the user wait.**) -### 2. List (all bikes) -- The full catalog. Each row: thumbnail, title, price, km, place, and a 4-state control: ♥ / ★ / ✕ / — (no call yet). Tapping sets or edits the call — edits are first-class (Glenn asked for exactly this). -- Filters (persisted): price min/max, km max, year min, text search; clear-all chip. Sort: score | price ↑↓ | newest | km. -- Context line: "n new since your last visit". +## 1. Globals -### 3. Matches (liked) -- Two segments: Liked ("matches") and Maybe. Same row style as List; opens the detail sheet. This is the actual shortlist for buying. +- Palette: `--bg:#0b0b0e; --card:#141419; --text:#fff; --mut:rgba(255,255,255,.62); --accent1:#ff7854; --accent2:#fd267d; --like:#46d369; --nope:#fd4d5c; --maybe:#ffb43a` +- Type: system stack (`-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif`). +- Frame: full-viewport flex column, `body{overflow:hidden}` — header 50px, stage flex:1, actions 86px, nav 68px. +- Desktop: `.app` max-width 520px, centered. Safe areas: header `padding-top:env(safe-area-inset-top)`; nav `padding-bottom:calc(6px + env(safe-area-inset-bottom))`; dossier actions keep their existing safe-area padding. +- **Z-order law (global):** cards 6–10 (local, inside the stage) < flight 12 < list/stub 50 < nav 60 < dback 69 < detail 70. Cards must NEVER paint above list/stub/nav — a regression here freezes every other screen. -### 4. Trends (Phase 3) -- Swipe stats (counts, like rate), learned lean summary ("you lean: V-twins · <$6k · low-km · NS"), retrain status, and the current top model weights rendered as plain language. +## 2. Deck screen (default) -## Visual language -- Dark theme, near-black background, one accent gradient (flame → red/pink), white primary text, ~60% secondary. -- Cards: ~16px radius, full-bleed photo, subtle shadow; stamps are 3px outline uppercase, rotated ∓12°. -- Type: system stack (SF on iPhone); title 22–24px bold; price 20px semibold. -- Bottom tab bar: Deck · List · Matches · Trends (icons + labels), safe-area aware. +### Card geometry +- `.card{position:absolute;left:14px;right:14px;top:6px;bottom:34px;border-radius:18px;overflow:hidden;background:var(--card);box-shadow:0 0 0 1px rgba(255,255,255,.07),0 18px 40px rgba(0,0,0,.5);will-change:transform;touch-action:none}` +- Photo full-bleed (`object-fit:cover`); bottom gradient 52% tall (transparent → rgba(0,0,0,.55) @55% → .88). Info block at bottom:14px: chips row → title (23px/800, clamp 2 lines) → price (20px/600) → sub (13px, var(--mut)). +- No photo → placeholder panel: 45° repeating stripes (#141419/#18181f), centered copy: "Photo pending — thumbnail backfill queued". +- ⓘ button: 32px circle, top-right of card (rgba(0,0,0,.45), 1px rgba(255,255,255,.25) border) → opens the dossier for that card. +- Chips: 11px/600, rgba(255,255,255,.14) bg, radius 999px. Production content: max 3 = seller chip ("Dealer" only when seller_type=dealer) + first 2 extras. -## PWA specifics -- `display: standalone`; an install hint covers iOS "Add to Home Screen". -- Service worker: cache the app shell only; API always live (tailnet). No offline deck in v1. -- Icons 192/512 + maskable; theme color = dark. +### Stack engine (5-deep live pile — normative) +- Constants: `DEPTH=5, SC=.026, TY=16, DIM=.08`. +- Depth transform: `translateY(TY*d) scale(1-SC*d)`, `filter:brightness(1-DIM*d)`, `z-index:10-d`, `pointer-events:auto` iff `d===0`. +- `.topcard` marks the d=0 card. **Invariant: exactly ONE `.topcard` in the DOM at any instant** — the flying card sheds it the moment it commits. +- Depth transitions: `.22s cubic-bezier(.2,.8,.3,1)` on transform/filter/opacity — this IS the cascade; it must be visible on every commit. +- New cards enter from two depths deeper (`translateY(TY*(d+2)) scale(1-SC*(d+2))`, opacity 0), then settle to their depth after one reflow. +- Every card is a live object with its own pointer listeners, and carries its **full face at all times** (chips/title/price/sub present on every card, no late text pop). -## Interaction invariants -- One-hand usable: all primary actions live in the bottom third of the screen. -- Animation lock during commit (no double-fire); undo stack ≥ 10 deep. -- Every card shows: photo, price, title, place. +### Drag (top card only) +- pointerdown on the top card → capture pointer, transition off. Move: `translate(dx,dy) rotate(clamp(±12°, dx/12))`. +- Stamps (3px outline, 26px/800, letter-spacing 2px, radius 10px): + - LIKE — green, top-left, rotate(-14°), opacity `clamp(0..1, dx/120)` + - NOPE — red, top-right, rotate(14°), opacity `clamp(0..1, -dx/120)` + - MAYBE — amber, centered, bottom:130px, opacity `dy<-20 ? clamp(0..1, -dy/130) : 0` +- Release: `dx > stage.clientWidth*0.35` → LIKE · `dx < -w*0.35` → NOPE · `dy < -140` → MAYBE · else spring back (`.3s cubic-bezier(.2,.8,.3,1)` + stamps cleared). +- Tap (≤6px movement, pointerup) on the top card → dossier. -## Acceptance (Phase 2) -Glenn can open the PWA from his iPhone home screen, swipe a real deck one-handed, and fix any mis-swipe in the List view — without touching a desktop. +### Commit — no lock (THE point) +- `commit(dir)`: only the current top commits. Instantly removed from the pile's books; flies out; pile refills to 5 from the buffer; all remaining cards re-layer to their new depth. +- Flight: like `translate(620px,40px) rotate(26°)` · nope `translate(-620px,40px) rotate(-26°)` · maybe `translate(0,-780px)`. `transition:transform .3s ease-in, opacity .3s`; card opacity → .15; node removed ~420ms. Flying card: `.topcard` dropped, `z-index:12`, `pointer-events:none` immediately. +- **Input never locks.** The next card is grabbable the same instant — even with two or three cards still in flight. Proven: 3 commits at 70ms intervals, zero lockout. Nothing may reintroduce a busy/lock state. +- Buttons row (86px, gap 16px; all act on the current top): ↺ undo 42px (#1d1d24, #f5c451) · ✕ nope 58px (white bg, #fd5068) · ★ maybe 58px (#1d1d24, #2a2a33 border, --maybe) · ♥ like 62px (accent gradient, glow). Buttons trigger the same flight as gestures. +- Undo: pops history → the card is rebuilt and re-inserted on top (enters from `translateY(-46px) scale(1.02)`, opacity 0), pile trimmed back to DEPTH (excess returns to the buffer front). Clears the empty state. + +### Empty state +Dashed panel, copy exactly: **"You're all caught up."** / "New bikes arrive nightly — next import 08:30." / "↺ brings the last card back." Undo restores the last card. + +## 3. Dossier (fullscreen sheet) — one sheet, both doors + +Opened from deck ⓘ (or tap) AND list row tap. iOS-sheet geometry: top gap 26px; `border-radius:18px 18px 0 0`; bg #0f0f13; slide-in `.3s cubic-bezier(.2,.8,.3,1)` from `translateY(103%)`. Backdrop: rgba(0,0,0,.38), z 69/70. + +- Grabber: 26px strip, 42×5px bar (rgba(255,255,255,.3)). Close button: 36px circle, top:38px left:14px. +- Hero: 34% height (min 190px), photo (`object-fit:cover`); no photo → dimmed empty state. +- Body (scrollable, padding 14px 18px 20px): title (20px/800) → price (19px/700) + sub → chips → section "The machine" → spec table → section "Seller's notes" → "Open on Kijiji ↗" link (target=_blank). +- Spec table rows (9, in order): **Year · Odometer · Engine · Family · Category · Location · Seller · Source · First seen.** Missing values render "—" in dim style (#5a5a66, weight 500). + - Data mapping: Year=listings.year | Odometer=km formatted ("24,800 km") | Engine=cc found in title/desc ("1,250 cc") | Family=family | Category=from URL slug (`/v-/` → hyphens to spaces, title-case) | Location=`{location}, {NS|NB}` | Seller=`dealer|private` | Source="Kijiji" | First seen=relative ("Today", "N days ago") from first_seen. +- Seller's notes = desc quarantined as a quote block (13.5px, #b9b9c2); never merged into specs. +- Bottom actions: ✕ Nope / ★ Maybe / ♥ Like (48px, radius 14; like = gradient). From the deck they commit the current top (flight); from the list they set the decision. +- Pull-to-close: drag from grabber or hero; `dy > 110px` → close; else spring back (`.25s`). + +## 4. List screen + +- Header: "All bikes" (20px/800) + sub "{live} active · {new} new since your last visit" (new bold white when >0). +- Search input: compact, filters title (case-insensitive, server `q`). +- Filter chips row 1 (decision, single-select): All · Liked · Maybe · Noped · Undecided. +- Sort chips row 2 (single-select): Score · Price ↕ (second tap toggles ↑/↓) · Newest · km. +- Chip style as mockup: 12px, #1b1b21 bg, #26262e border; active = white bg / dark text / 600. +- Rows: thumb 56px radius 12 (or ◍ placeholder), title (14.5px/650, 1-line ellipsis), sub "{location}, {REGION}" (+ " · dealer|private" when known), price right (14px/700), decision buttons ♥ ★ ✕ (26px circles) — current decision lit in its color; tapping the lit button clears it (delete). +- Row tap → dossier (same sheet as §3). Scroll under nav (padding-bottom 68px). Empty: "No bikes match." + +## 5. Matches screen (production: REAL) + +- "Your shortlist": section "♥ Liked (n)", then "★ Maybe (n)". Same row component, edits included; row tap → dossier. +- Empty: "No likes yet — swipe right on the deck." + +## 6. Trends + +- Stub (Phase 3): "Trends — Phase 3" / "The deck learns your taste as you swipe." + +## 7. Production deltas (the ONLY changes from the mockup) + +1. **Data wiring.** Deck buffer from `GET /api/deck` (limit 40, opaque cursor); refill when buffer <5 and a cursor exists. List from `GET /api/list`. Dossier from the row/card payload. Header from `GET /api/stats`. +2. **Swipe persistence.** Commits fire `POST /api/swipe` optimistically — the UI never waits. Failures go to a retry queue (attempts at ~2s/5s/10s, then every 15s while the tab lives); toast "Couldn't save — retrying" on first failure. Undo fires `DELETE /api/swipe/{id}` through the same queue. +3. **Badge.** `POST /api/badge/seen` once per session when the deck first renders. Header shows "{live} live" always; "· {new} new" only when new>0. +4. **List filters + search** per §4 (mockup had placeholder chips only). +5. **Matches real (§5); Trends stays stub.** +6. **Images.** Every card/thumb references `/img/{id}` (server: cached file → 302 CDN → 404 → placeholder). The client never hotlinks directly. +7. **PWA.** `manifest.json` (name "Bike-Tinder", display standalone, theme/background #0b0b0e, icons 192/512 + maskable), `apple-touch-icon.png` (180px), iOS metas (`apple-mobile-web-app-capable`, `black-translucent`), `sw.js` (precache shell; API network-first; offline → serve shell + toast "Offline — retrying"). Register the SW in index.html. Serve index.html + sw.js with `Cache-Control: no-cache`. +8. **Safe areas + desktop centering** per §1. + +## 8. Static files + +`app/static/index.html` (single-file app, no build) · `manifest.json` · `sw.js` · `icons/` (192, 512, maskable, apple-touch-icon 180). + +## 9. Acceptance (Phase 2) + +Machine-checkable: +- Server runs on 127.0.0.1:9453; `/` serves the app; `/api/stats` live count = DB active count (560 at freeze). +- Deck page 1 starts at the top-scored row (Indian Scout family, score 91); ordering monotonic (score DESC, first_seen DESC, id DESC); page 2 zero overlap. +- Swipe like → appears in `/api/list?decision=like`; re-swipe edits; delete clears; server restart → decision persists. +- Undo from empty restores the last card. +- Fast-chain intact: commits faster than the flight animation, no lockout, exactly one `.topcard`. +- `/img/{id}`: 200 mirrored / 302 fallback / 404 → placeholder. +- Selfcheck: all P1 checks green + new API checks green. + +Human (parent-run after build): Glenn swipes a real deck from his phone and edits results in the list — the Phase 2 exit criterion. diff --git a/reference/mockup-001-fidelity.html b/reference/mockup-001-fidelity.html new file mode 100644 index 0000000..334a82a --- /dev/null +++ b/reference/mockup-001-fidelity.html @@ -0,0 +1,429 @@ + + + + + + +Bike-Tinder · Mockup A — Pure + + + +
+
+
Bike-Tinder
+
560 live · 3 new
+
+ +
+
You're all caught up.New bikes arrive nightly — next import 08:30.
↺ brings the last card back.
+
+ +
+ + + + +
+ + + +
+
+
+ +
+
+
+
+
+
The machine
+
+
Seller's notes
+
+ Open on Kijiji ↗ +
+
+ + + +
+
+ +
+

All bikes

560 active · 3 new since your last visit
+
+
Score
Price ↕
Newest
km
Filters ⚙
+
+
+
+ +
Your shortlist lives hereLiked and Maybe bikes — once the build lands.
+
Trends — Phase 3The deck learns your taste as you swipe.
+
+ + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..45032c7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +# Bike-Tinder runtime deps (plain uvicorn — no [standard] extras needed). +fastapi +uvicorn +httpx