merge: Phase 2 swipe app (verified) — FastAPI API + PWA deck/list/matches, image mirror, dedupe refinement

This commit is contained in:
AlexanderRozhenko
2026-11-10 21:43:34 -04:00
24 changed files with 2366 additions and 72 deletions

1
.gitignore vendored
View File

@@ -10,3 +10,4 @@ __pycache__/
# scratch
scratch/
.venv/

View File

@@ -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.

405
app/api.py Normal file
View File

@@ -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 '<score>|<first_seen>|<id>' -> (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

View File

@@ -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()

114
app/imgfetch.py Normal file
View File

@@ -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())

View File

@@ -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

View File

@@ -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 '')

View File

@@ -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

38
app/serve.py Normal file
View File

@@ -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())

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

733
app/static/index.html Normal file
View File

@@ -0,0 +1,733 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="robots" content="noindex">
<title>Bike-Tinder</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0b0b0e">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Bike-Tinder">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<style>
:root{
--bg:#0b0b0e; --card:#141419; --text:#fff; --mut:rgba(255,255,255,.62);
--accent1:#ff7854; --accent2:#fd267d; --like:#46d369; --nope:#fd4d5c; --maybe:#ffb43a;
}
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
html,body{height:100%}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
background:var(--bg);color:var(--text);overflow:hidden;-webkit-font-smoothing:antialiased}
.app{position:relative;display:flex;flex-direction:column;height:100%;max-width:520px;margin:0 auto}
/* header */
header{height:calc(50px + env(safe-area-inset-top,0px));display:flex;align-items:center;justify-content:space-between;padding:env(safe-area-inset-top,0px) 16px 0}
.brand{display:flex;align-items:center;gap:8px;font-weight:800;font-size:16px;letter-spacing:.2px}
.flame{width:18px;height:18px;border-radius:6px;background:linear-gradient(135deg,var(--accent1),var(--accent2))}
.pill{font-size:12px;color:var(--mut);background:#1b1b21;border:1px solid #26262e;border-radius:999px;padding:5px 10px}
.pill b{color:#fff}
/* stage + cards */
.stage{position:relative;flex:1;padding:6px 14px 2px;min-height:0}
.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}
.nophoto{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:repeating-linear-gradient(45deg,#141419 0 14px,#18181f 14px 28px);color:#55555f;font-size:13px;text-align:center;padding:24px;line-height:1.6}
.card .photo{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
.card .grad{position:absolute;left:0;right:0;bottom:0;height:52%;background:linear-gradient(180deg,rgba(0,0,0,0) 0%,rgba(0,0,0,.55) 55%,rgba(0,0,0,.88) 100%)}
.card .info{position:absolute;left:16px;right:16px;bottom:14px;z-index:3}
.chips{display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap}
.chip{font-size:11px;font-weight:600;letter-spacing:.4px;padding:4px 9px;border-radius:999px;background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.22)}
.title{font-size:23px;font-weight:800;line-height:1.15;text-shadow:0 1px 8px rgba(0,0,0,.5);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.price{font-size:20px;font-weight:600;margin-top:2px}
.sub{font-size:13px;color:var(--mut);margin-top:3px}
.stamp{position:absolute;top:26px;z-index:4;font-weight:800;letter-spacing:2px;font-size:26px;padding:6px 12px;border:3px solid;border-radius:10px;opacity:0;pointer-events:none}
.stamp.like{left:20px;transform:rotate(-14deg);color:var(--like);border-color:var(--like);background:rgba(70,211,105,.08)}
.stamp.nope{right:20px;transform:rotate(14deg);color:var(--nope);border-color:var(--nope);background:rgba(253,77,92,.08)}
.stamp.maybe{left:50%;top:auto;bottom:130px;transform:translateX(-50%);color:var(--maybe);border-color:var(--maybe);background:rgba(255,180,58,.08)}
/* actions */
.actions{height:86px;display:flex;align-items:center;justify-content:center;gap:16px}
.act{border:0;border-radius:50%;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:transform .12s}
.act:active{transform:scale(.92)}
.undo{width:42px;height:42px;background:#1d1d24;color:#f5c451;font-size:18px}
.x{width:58px;height:58px;background:#fff;color:#fd5068;font-size:26px;font-weight:700;box-shadow:0 6px 18px rgba(0,0,0,.4)}
.s{width:58px;height:58px;background:#1d1d24;color:var(--maybe);font-size:24px;border:1px solid #2a2a33}
.h{width:62px;height:62px;background:linear-gradient(135deg,var(--accent1),var(--accent2));color:#fff;font-size:26px;box-shadow:0 8px 22px rgba(253,38,125,.35)}
/* tabs */
nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items:stretch;border-top:1px solid #1d1d24;background:#0d0d10;padding-bottom:calc(6px + env(safe-area-inset-bottom,0px));position:relative;z-index:60}
.tab{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;font-size:10px;letter-spacing:.3px;color:#6f6f7a;cursor:pointer}
.tab.on{color:#fff}
.tab svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
/* fullscreen detail dossier — iOS-style sheet: top gap + grabber, drag down to close */
.dback{position:absolute;inset:0;z-index:69;background:rgba(0,0,0,.38);opacity:0;pointer-events:none;transition:opacity .28s}
.dback.on{opacity:1}
.detail{position:absolute;top:26px;left:0;right:0;bottom:0;z-index:70;background:#0f0f13;border-radius:18px 18px 0 0;box-shadow:0 -14px 40px rgba(0,0,0,.45);transform:translateY(103%);transition:transform .3s cubic-bezier(.2,.8,.3,1);display:flex;flex-direction:column;overflow:hidden}
.detail.on{transform:translateY(0)}
.dgrab{flex:none;height:26px;display:flex;align-items:center;justify-content:center;background:#0f0f13;cursor:grab;touch-action:none}
.dgrab i{width:42px;height:5px;border-radius:3px;background:rgba(255,255,255,.3);display:block}
.dclose{position:absolute;top:38px;left:14px;width:36px;height:36px;border-radius:50%;background:rgba(0,0,0,.55);color:#fff;border:0;font-size:17px;z-index:5;cursor:pointer}
.dhero{position:relative;height:34%;min-height:190px;flex:none;background:#17171c;touch-action:none}
.dphoto{width:100%;height:100%;object-fit:cover;display:block}
.dphoto.ph{opacity:.25}
.dherograd{position:absolute;left:0;right:0;bottom:0;height:70px;background:linear-gradient(transparent,rgba(15,15,19,.95))}
.dbody{flex:1;overflow-y:auto;padding:14px 18px 20px}
.dtitle{font-size:20px;font-weight:800;line-height:1.2}
.drow{display:flex;align-items:baseline;gap:10px;margin-top:5px}
.dprice{font-size:19px;font-weight:700}
.dsub{font-size:12.5px;color:var(--mut)}
.dchips{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap}
.dsec{font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:#7b7b88;margin:18px 0 8px}
.specs{border-top:1px solid #1f1f27}
.spec{display:flex;justify-content:space-between;gap:14px;padding:9px 2px;border-bottom:1px solid #1f1f27;font-size:13.5px}
.spec .k{color:#8f8f9c}
.spec .v{font-weight:600;text-align:right}
.spec .v.z{color:#5a5a66;font-weight:500}
.dnotes{font-size:13.5px;line-height:1.5;color:#b9b9c2}
.dopen{display:inline-block;margin-top:16px;color:#ff9a76;font-weight:600;font-size:14px;text-decoration:none}
.dacts{flex:none;display:flex;gap:10px;padding:12px 16px calc(14px + env(safe-area-inset-bottom, 0px));border-top:1px solid #1d1d24;background:#0d0d10}
.dact{flex:1;height:48px;border-radius:14px;border:1px solid #2a2a33;background:#16161b;color:#fff;font-size:14px;font-weight:700;cursor:pointer}
.dact:active{transform:scale(.97)}
.dact.no{color:#fd7b89}
.dact.mb{color:var(--maybe)}
.dact.lk{background:linear-gradient(135deg,var(--accent1),var(--accent2));border:0;box-shadow:0 8px 22px rgba(253,38,125,.3)}
.ibtn{position:absolute;top:14px;right:14px;width:32px;height:32px;border-radius:50%;background:rgba(0,0,0,.45);border:1px solid rgba(255,255,255,.25);color:#fff;font-size:14px;z-index:4;cursor:pointer}
/* empty */
.empty{position:absolute;inset:6px 14px 2px;display:none;flex-direction:column;align-items:center;justify-content:center;gap:8px;border:1px dashed #2a2a33;border-radius:18px;color:var(--mut);text-align:center;padding:24px;font-size:13.5px}
.empty.on{display:flex}
.empty b{color:#fff;font-size:16px}
/* list screen */
.list{position:absolute;inset:0;display:none;flex-direction:column;background:var(--bg);z-index:50;padding-bottom:68px}
.list.on{display:flex}
.lhead{padding:14px 16px 8px}
.lhead h1{font-size:20px;font-weight:800}
.lhead .sub{margin-top:2px}
.filters{display:flex;gap:7px;padding:4px 16px 10px;overflow-x:auto}
.fchip{font-size:12px;padding:6px 11px;border-radius:999px;background:#1b1b21;border:1px solid #26262e;color:var(--mut);white-space:nowrap;cursor:pointer}
.fchip.on{background:#fff;color:#111;border-color:#fff;font-weight:600}
.lrows{flex:1;overflow-y:auto;padding:0 10px 12px}
.row{display:flex;gap:11px;align-items:center;padding:9px 8px;border-radius:14px;cursor:pointer}
.row + .row{border-top:1px solid #1a1a1f}
.thumb{width:56px;height:56px;border-radius:12px;object-fit:cover;background:#1a1a20;flex:none}
.thumb.ph{display:flex;align-items:center;justify-content:center;color:#4a4a55;font-size:20px}
.rmain{flex:1;min-width:0}
.rtitle{font-size:14.5px;font-weight:650;line-height:1.25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.rsub{font-size:12px;color:var(--mut);margin-top:2px}
.rright{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
.rprice{font-size:14px;font-weight:700}
.rc{display:flex;gap:5px}
.rbtn{width:26px;height:26px;border-radius:50%;border:1px solid #2a2a33;background:#16161b;color:#8a8a95;font-size:12px;display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0}
.rbtn.on.like{background:rgba(70,211,105,.15);border-color:var(--like);color:var(--like)}
.rbtn.on.maybe{background:rgba(255,180,58,.15);border-color:var(--maybe);color:var(--maybe)}
.rbtn.on.nope{background:rgba(253,77,92,.15);border-color:var(--nope);color:var(--nope)}
/* matches screen (production) */
.matches{position:absolute;inset:0;display:none;flex-direction:column;background:var(--bg);z-index:50;padding-bottom:68px}
.matches.on{display:flex}
.msec{font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:#7b7b88;margin:10px 18px 0}
.mempty{margin:10px 18px;font-size:13.5px;color:var(--mut)}
/* stub screens */
.stub{position:absolute;inset:0;display:none;flex-direction:column;align-items:center;justify-content:center;gap:10px;background:var(--bg);z-index:50;color:var(--mut);text-align:center;padding:40px;font-size:13.5px}
.stub.on{display:flex}
.stub b{color:#fff;font-size:17px}
/* production additions (UI-SPEC §7 deltas) */
.search{margin:0 16px 8px;width:calc(100% - 32px);background:#1b1b21;border:1px solid #26262e;border-radius:10px;color:#fff;font-size:14px;padding:9px 14px;outline:none}
.search::placeholder{color:#6f6f7a}
.toast{position:fixed;left:50%;bottom:150px;transform:translateX(-50%);background:#1d1d24;border:1px solid #2a2a33;color:#fff;padding:10px 16px;border-radius:12px;font-size:13px;z-index:80;opacity:0;transition:opacity .25s;pointer-events:none;white-space:nowrap;max-width:92vw;overflow:hidden;text-overflow:ellipsis}
.toast.on{opacity:1}
</style>
</head>
<body>
<div class="app">
<header>
<div class="brand"><span class="flame"></span>Bike-Tinder</div>
<div class="pill" id="pill">—</div>
</header>
<div class="stage" id="stage">
<div class="empty" id="empty"><b>You're all caught up.</b>New bikes arrive nightly — next import 08:30.<br>↺ brings the last card back.</div>
</div>
<div class="actions" id="actions">
<button class="act undo" id="btnUndo" title="Undo">↺</button>
<button class="act x" id="btnNope" title="Nope">✕</button>
<button class="act s" id="btnMaybe" title="Maybe">★</button>
<button class="act h" id="btnLike" title="Like">♥</button>
</div>
<nav>
<div class="tab on" data-tab="deck"><svg viewBox="0 0 24 24"><rect x="4" y="4" width="13" height="16" rx="2.5"/><path d="M8.5 2.5h9A2.5 2.5 0 0 1 20 5v12"/></svg>Deck</div>
<div class="tab" data-tab="list"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg>List</div>
<div class="tab" data-tab="matches"><svg viewBox="0 0 24 24"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>Matches</div>
<div class="tab" data-tab="trends"><svg viewBox="0 0 24 24"><path d="M5 20V10M12 20V4M19 20v-7"/></svg>Trends</div>
</nav>
<div class="dback" id="dback"></div>
<div class="detail" id="detail">
<div class="dgrab" id="dgrab"><i></i></div>
<button class="dclose" id="dclose">⌄</button>
<div class="dhero"><img class="dphoto" id="dphoto" alt=""><div class="dherograd"></div></div>
<div class="dbody" id="dbody">
<div class="dtitle" id="dtitle"></div>
<div class="drow"><div class="dprice" id="dprice"></div><div class="dsub" id="dsub"></div></div>
<div class="dchips" id="dchips"></div>
<div class="dsec">The machine</div>
<div class="specs" id="dspecs"></div>
<div class="dsec">Seller's notes</div>
<div class="dnotes" id="dnotes"></div>
<a class="dopen" id="dopen" href="#" target="_blank" rel="noopener">Open on Kijiji ↗</a>
</div>
<div class="dacts">
<button class="dact no" id="dNo">✕ Nope</button>
<button class="dact mb" id="dMb">★ Maybe</button>
<button class="dact lk" id="dLk">♥ Like</button>
</div>
</div>
<div class="list" id="listScreen">
<div class="lhead"><h1>All bikes</h1><div class="sub" id="lsub">—</div></div>
<input class="search" id="lsearch" type="search" placeholder="Search titles" autocomplete="off">
<div class="filters" id="fdec">
<div class="fchip on" data-d="all">All</div><div class="fchip" data-d="like">Liked</div><div class="fchip" data-d="maybe">Maybe</div><div class="fchip" data-d="nope">Noped</div><div class="fchip" data-d="undecided">Undecided</div>
</div>
<div class="filters" id="fsort">
<div class="fchip on" data-s="score">Score</div><div class="fchip" data-s="price" id="fprice">Price ↕</div><div class="fchip" data-s="newest">Newest</div><div class="fchip" data-s="km">km</div>
</div>
<div class="lrows" id="lrows"></div>
</div>
<div class="matches" id="matchesScreen">
<div class="lhead"><h1>Your shortlist</h1></div>
<div class="msec" id="mlikeSec">♥ Liked (0)</div>
<div class="lrows" id="mlike"></div>
<div class="msec" id="mmaybeSec">★ Maybe (0)</div>
<div class="lrows" id="mmaybe"></div>
<div class="mempty" id="mempty">No likes yet — swipe right on the deck.</div>
</div>
<div class="stub" id="stubTrends"><b>Trends — Phase 3</b>The deck learns your taste as you swipe.</div>
</div>
<div class="toast" id="toast"></div>
<script>
const $=id=>document.getElementById(id);
const stage=$('stage');
const DEPTH=5; // a 5-card live stack at all times
const SC=.026, TY=16, DIM=.08; // per-depth step: scale, y-offset, dimming
let pile=[]; // top-first [{d,el}] — every card its own swipable object
let hist=[]; // undo history: full payloads
let dragEl=null, sx=0, sy=0, dx=0, dy=0, moved=false;
let buffer=[]; // deck feed from GET /api/deck (payloads)
let deckCursor=null, deckLoaded=false, deckLoading=false;
/* ---------- production wiring: API + retry queue + toast ---------- */
let toastTimer=null;
function toast(msg){
const t=$('toast'); t.textContent=msg; t.classList.add('on');
clearTimeout(toastTimer); toastTimer=setTimeout(()=>t.classList.remove('on'),2500);
}
async function getJSON(url){
try{
const res=await fetch(url);
if(!res.ok){ if(!navigator.onLine) toast('Offline — retrying'); return null; }
return await res.json();
}catch(e){ if(!navigator.onLine) toast('Offline — retrying'); return null; }
}
/* swipe persistence: optimistic, never waits; failures retry 2s/5s/10s then
every 15s while the tab lives. Last write per listing wins; the queue runs
serially so reorderings can't land out of sequence. */
const pend=new Map(); // listing_id -> {act, fails, warned}
let qRunning=false, qTimer=null, qWarned=false;
function enqueue(listingId, act){ // act: 'like'|'nope'|'maybe'|null (delete)
pend.set(listingId,{act,fails:0});
scheduleQueue();
}
function queDelay(op){ return op.fails===1?2000:op.fails===2?5000:op.fails===3?10000:15000; }
function scheduleQueue(){
if(qRunning||!pend.size||qTimer) return;
qTimer=setTimeout(runQueue,0);
}
async function runQueue(){
qTimer=null; qRunning=true;
try{
const ops=[...pend.entries()];
let minDelay=0;
for(const [id,op] of ops){
if(!pend.has(id)) continue; // replaced mid-batch
const p=pend.get(id);
if(p!==op) continue;
try{
const res= p.act==null
? await fetch('/api/swipe/'+id,{method:'DELETE'})
: await fetch('/api/swipe',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({listing_id:id,action:p.act})});
if(res.ok){ pend.delete(id); }
else { p.fails++; if(!qWarned){ qWarned=true; toast("Couldn't save — retrying"); } minDelay=Math.max(minDelay,queDelay(p)); }
}catch(e){
p.fails++; if(!qWarned){ qWarned=true; toast("Couldn't save — retrying"); }
minDelay=Math.max(minDelay,queDelay(p));
}
}
}finally{ qRunning=false; }
if(pend.size){ qTimer=setTimeout(runQueue,Math.max(minDelay||15000,1000)); }
}
/* optimistic decision set for list/matches + deck commits share this */
function setDecision(id, act){
updateRowDecision(id, act); // optimistic paint
enqueue(id, act);
}
/* ---------- payload -> display helpers ---------- */
const REGION_NAMES={ns:'NS',nb:'NB'};
function fmtNum(n){ return n==null?null:Number(n).toLocaleString('en-US'); }
function cardChips(it){ // max 3: seller chip + first 2 extras
const out=[];
if(it.seller_type==='dealer') out.push('Dealer');
out.push(...(it.extras||[]).slice(0,2));
return out.slice(0,3);
}
function subFor(it){
const parts=[];
if(it.location) parts.push(it.location);
parts.push(REGION_NAMES[it.region]||it.region||'');
let s=parts.filter(Boolean).join(', ');
if(it.seller_type==='dealer') s+=' · dealer';
else if(it.seller_type==='private') s+=' · private';
return s;
}
function priceFor(it){
if(it.price_display) return it.price_display;
if(it.price_num!=null) return '$'+fmtNum(it.price_num);
return '—';
}
function relSeen(fs){
if(!fs) return '—';
const d=new Date(fs), now=new Date();
if(isNaN(d)) return '—';
const days=Math.floor((now-d)/86400000);
return days<=0?'Today':(days===1?'1 day ago':days+' days ago');
}
function ccFrom(it){
const m=((it.title||'')+' '+((it.desc||'').split(' [also listed:')[0])).match(/(\d[\d,]*)\s*cc/i);
return m?(fmtNum(m[1].replace(/,/g,''))+' cc'):null;
}
function catFromUrl(url){
const m=(url||'').match(/\/v-([^/]+)\//);
if(!m) return null;
const words=m[1].split('-').filter(Boolean).map(w=>w[0].toUpperCase()+w.slice(1));
return words.length?words.join(' '):null;
}
/* ---------- docs/mockup core: cards, layer, pile ---------- */
function isTop(el){ return pile[0]&&pile[0].el===el; }
function setStamps(el,a,b,c){
el.querySelector('.stamp.like').style.opacity=a;
el.querySelector('.stamp.nope').style.opacity=b;
el.querySelector('.stamp.maybe').style.opacity=c;
}
function cardNode(item){
const el=document.createElement('div');
el.className='card';
el.innerHTML=`${item.img?`<img class="photo" src="${item.img}" alt="" draggable="false">`:`<div class="nophoto">Photo pending — thumbnail backfill queued</div>`}
<div class="grad"></div>
<div class="stamp like">LIKE</div>
<div class="stamp nope">NOPE</div>
<div class="stamp maybe">MAYBE</div>
<button class="ibtn">ⓘ</button>
<div class="info"><div class="chips">${cardChips(item).map(x=>`<span class="chip">${x}</span>`).join('')}</div><div class="title"></div><div class="price"></div><div class="sub"></div></div>`;
// text set via textContent — data is untrusted (titles carry junk chars)
const info=el.querySelector('.info');
info.querySelector('.title').textContent=item.title||'';
info.querySelector('.price').textContent=priceFor(item);
info.querySelector('.sub').textContent=subFor(item);
const ph=el.querySelector('.photo');
if(ph) ph.addEventListener('error',()=>{
ph.remove();
el.insertAdjacentHTML('afterbegin','<div class="nophoto">Photo pending — thumbnail backfill queued</div>');
});
el.addEventListener('pointerdown',down);
el.addEventListener('pointermove',move);
el.addEventListener('pointerup',upH);
el.addEventListener('pointercancel',upH);
const ib=el.querySelector('.ibtn');
ib.addEventListener('pointerdown',e=>e.stopPropagation());
ib.addEventListener('click',e=>{ e.stopPropagation(); if(isTop(el)) openDetail(item,{type:'deck'}); });
return {d:item,el};
}
function layer(el,d,anim){
el.classList.toggle('topcard',d===0);
el.style.transition=anim?'transform .22s cubic-bezier(.2,.8,.3,1), filter .22s ease, opacity .22s ease':'none';
el.style.transform=`translateY(${TY*d}px) scale(${1-SC*d})`;
el.style.filter=`brightness(${1-DIM*d})`;
el.style.opacity='1';
el.style.zIndex=10-d;
el.style.pointerEvents=d===0?'auto':'none';
}
function relayer(){ pile.forEach((c,d)=>layer(c.el,d,true)); }
function fillPile(){
while(pile.length<DEPTH&&buffer.length){
const item=buffer.shift();
const entry=cardNode(item), d=pile.length;
entry.el.style.transition='none';
entry.el.style.transform=`translateY(${TY*(d+2)}px) scale(${1-SC*(d+2)})`;
entry.el.style.opacity='0';
stage.appendChild(entry.el);
pile.push(entry);
void entry.el.offsetWidth;
layer(entry.el,d,true);
}
relayer();
}
/* deck feed: cursor pages of 40; refill whenever the buffer thins */
async function loadDeck(){
if(deckLoading||deckLoaded) return;
deckLoading=true;
try{
const res=await getJSON('/api/deck?limit=40'+(deckCursor?('&cursor='+encodeURIComponent(deckCursor)):''));
if(!res) { setTimeout(()=>loadDeck(),15000); return; }
if(res.items&&res.items.length){
buffer.push(...res.items);
fillPile();
}
deckCursor=res.next_cursor||null;
if(!deckCursor) deckLoaded=true;
}finally{ deckLoading=false; }
}
function maybeRefill(){
if(buffer.length<DEPTH&&deckCursor&&!deckLoaded) loadDeck();
if(!pile.length&&!buffer.length&&(deckLoaded||(!deckCursor&&!deckLoading))) $('empty').classList.add('on');
}
/* ---------- docs/mockup core: commit / fly / undo — no lock ---------- */
function commit(el,dir){
if(!isTop(el)) return;
const t=pile.shift();
hist.push(t.d);
setDecision(t.d.id, dir==='maybe'?'maybe':dir); // optimistic persist
flyOut(el,dir);
fillPile(); relayer();
maybeRefill();
}
function flyOut(el,dir){
const x= dir==='maybe'?0:(dir==='like'?620:-620);
const y= dir==='maybe'?-780:40;
const rot= dir==='maybe'?0:(dir==='like'?26:-26);
setStamps(el, dir==='like'?1:0, dir==='nope'?1:0, dir==='maybe'?1:0);
el.classList.remove('topcard');
el.style.zIndex=12; el.style.pointerEvents='none';
el.style.transition='transform .3s ease-in, opacity .3s';
el.style.transform=`translate(${x}px,${y}px) rotate(${rot}deg)`;
el.style.opacity=.15;
setTimeout(()=>{ el.remove(); },420);
}
function undo(){
if(!hist.length) return;
const item=hist.pop();
setDecision(item.id, null); // DELETE through the queue
const entry=cardNode(item);
entry.el.style.transition='none';
entry.el.style.transform='translateY(-46px) scale(1.02)';
entry.el.style.opacity='0';
stage.appendChild(entry.el);
pile.unshift(entry);
if(pile.length>DEPTH){ const drop=pile.pop(); drop.el.remove(); buffer.unshift(drop.d); }
void entry.el.offsetWidth;
relayer();
$('empty').classList.remove('on');
}
$('btnLike').onclick=()=>{ if(pile[0]) commit(pile[0].el,'like'); };
$('btnNope').onclick=()=>{ if(pile[0]) commit(pile[0].el,'nope'); };
$('btnMaybe').onclick=()=>{ if(pile[0]) commit(pile[0].el,'maybe'); };
$('btnUndo').onclick=undo;
/* ---------- docs/mockup core: drag — only the top card is grabbable ---------- */
function down(e){
const el=e.currentTarget;
if(!isTop(el)) return;
dragEl=el; moved=false; sx=e.clientX; sy=e.clientY; dx=0; dy=0;
try{ el.setPointerCapture(e.pointerId) }catch(_){}
el.style.transition='none';
}
function move(e){
const el=e.currentTarget;
if(dragEl!==el) return;
dx=e.clientX-sx; dy=e.clientY-sy;
if(Math.abs(dx)>6||Math.abs(dy)>6) moved=true;
const rot=Math.max(-12,Math.min(12,dx/12));
el.style.transform=`translate(${dx}px,${dy}px) rotate(${rot}deg)`;
setStamps(el, Math.max(0,Math.min(1,dx/120)), Math.max(0,Math.min(1,-dx/120)), dy<-20?Math.max(0,Math.min(1,-dy/130)):0);
}
function upH(e){
const el=e.currentTarget;
if(dragEl!==el) return;
dragEl=null;
const w=stage.clientWidth||360;
el.style.transition='transform .3s cubic-bezier(.2,.8,.3,1)';
if(dx>w*0.35) commit(el,'like');
else if(dx<-w*0.35) commit(el,'nope');
else if(dy<-140) commit(el,'maybe');
else { el.style.transform=''; setStamps(el,0,0,0); if(!moved && e.type==='pointerup' && isTop(el)) openDetail(pile[0].d,{type:'deck'}); }
}
/* ---------- docs/mockup core: fullscreen dossier ---------- */
let dCtx=null;
function openDetail(data,ctx){
dCtx=ctx||null;
const ph=$('dphoto');
if(data.img){ ph.src=data.img; ph.classList.remove('ph'); }
else { ph.removeAttribute('src'); ph.classList.add('ph'); }
ph.onerror=()=>{ ph.removeAttribute('src'); ph.classList.add('ph'); };
$('dtitle').textContent=data.title||'';
$('dprice').textContent=priceFor(data);
$('dsub').textContent=subFor(data);
$('dchips').innerHTML=cardChips(data).map(x=>`<span class="chip">${x}</span>`).join('');
const rows=[
["Year",data.year!=null?String(data.year):null],
["Odometer",data.km!=null?(fmtNum(data.km)+' km'):null],
["Engine",ccFrom(data)],
["Family",data.family],
["Category",catFromUrl(data.url)],
["Location",subFor(data).split(' · ')[0]],
["Seller",data.seller_type==='dealer'?'Dealer':(data.seller_type==='private'?'Private':null)],
["Source","Kijiji"],
["First seen",relSeen(data.first_seen)],
];
$('dspecs').innerHTML=rows.map(r=>`<div class="spec"><span class="k">${r[0]}</span><span class="v${(!r[1]||r[1]==='—')?' z':''}">${r[1]||'—'}</span></div>`).join('');
$('dnotes').textContent=(data.desc||'').split(' [also listed:')[0]||'';
$('dopen').href=data.url||'#';
$('detail').classList.add('on'); $('dback').classList.add('on');
$('dbody').scrollTop=0;
}
function closeDetail(){ const d=$('detail'); d.style.transition=''; d.style.transform=''; d.classList.remove('on'); $('dback').classList.remove('on'); dCtx=null; }
$('dclose').onclick=closeDetail;
$('dclose').addEventListener('pointerdown',e=>e.stopPropagation());
/* pull-down-to-close (grabber bar + photo) */
let dDragY=null;
function dStart(e){ dDragY=e.clientY; $('detail').style.transition='none'; try{e.currentTarget.setPointerCapture(e.pointerId)}catch(_){} }
function dMoveH(e){ if(dDragY===null) return; const dy=Math.max(0,e.clientY-dDragY); $('detail').style.transform=`translateY(${dy}px)`; }
function dEndH(e){ if(dDragY===null) return; const dy=Math.max(0,e.clientY-dDragY); dDragY=null;
if(dy>110){ closeDetail(); }
else { const d=$('detail'); d.style.transition='transform .25s cubic-bezier(.2,.8,.3,1)'; d.style.transform=''; } }
[document.querySelector('.dgrab'),document.querySelector('.dhero')].forEach(el=>{ if(!el) return; el.addEventListener('pointerdown',dStart); el.addEventListener('pointermove',dMoveH); el.addEventListener('pointerup',dEndH); el.addEventListener('pointercancel',dEndH); });
function dAction(a){
const ctx=dCtx; closeDetail();
if(!ctx) return;
if(ctx.type==='deck'){
if(pile[0]) commit(pile[0].el, a==='like'?'like':(a==='nope'?'nope':'maybe'));
} else {
setDecision(ctx.it.id, a==='like'?'like':(a==='nope'?'nope':'maybe'));
if(ctx.screen==='list') renderList();
else renderMatches();
}
}
$('dNo').onclick=()=>dAction('nope'); $('dMb').onclick=()=>dAction('maybe'); $('dLk').onclick=()=>dAction('like');
/* ---------- list screen (real filters + search) ---------- */
let listItems=[];
const listCtl={decision:'all',sort:'score',q:'',priceDir:1};
async function fetchList(){
let items=[],total=0,offset=0;
do{
const q=listCtl.q?'&q='+encodeURIComponent(listCtl.q):'';
const res=await getJSON(`/api/list?decision=${listCtl.decision}&sort=${listCtl.sort}${q}&limit=200&offset=${offset}`);
if(!res) break;
total=res.total;
items.push(...res.items);
offset+=res.items.length;
if(!res.items.length||offset>=total||offset>=500) break;
}while(true);
listItems=items;
renderList();
}
function thumbFor(it){
if(it.img) return `<img class="thumb" src="/img/${it.id}" alt="" data-i="${it.id}">`;
return `<div class="thumb ph">◍</div>`;
}
function rowHTML(it){
const on=act=>it.decision===act;
return `
<div class="row" data-i="${it.id}">
${thumbFor(it)}
<div class="rmain"><div class="rtitle"></div><div class="rsub"></div></div>
<div class="rright"><div class="rprice"></div>
<div class="rc" data-i="${it.id}">
<button class="rbtn like${on('like')?' on':''}" data-s="like">♥</button>
<button class="rbtn maybe${on('maybe')?' on':''}" data-s="maybe">★</button>
<button class="rbtn nope${on('nope')?' on':''}" data-s="nope">✕</button>
</div>
</div>
</div>`;
}
function attachThumbFallbacks(box){
box.querySelectorAll('img.thumb').forEach(im=>im.addEventListener('error',()=>{
const d=document.createElement('div'); d.className='thumb ph'; d.textContent='◍';
im.replaceWith(d);
}));
}
function renderList(){
$('lrows').innerHTML=listItems.map(rowHTML).join('');
attachThumbFallbacks($('lrows'));
listItems.forEach(it=>{
const el=$('lrows').querySelector(`.row[data-i="${it.id}"]`);
if(!el) return;
el.querySelector('.rtitle').textContent=it.title||'';
el.querySelector('.rsub').textContent=subFor(it);
el.querySelector('.rprice').textContent=priceFor(it);
});
if(!listItems.length) $('lrows').innerHTML='<div class="mempty">No bikes match.</div>';
}
/* optimistic decision updates for whichever screens show this listing */
function updateRowDecision(id, act){
const find=[listItems];
for(const arr of find){
const it=arr.find(x=>x.id===id);
if(it) it.decision=act;
}
const el=$('lrows').querySelector(`.rc[data-i="${id}"]`);
if(el) el.querySelectorAll('.rbtn').forEach(b=>b.classList.toggle('on',b.dataset.s===act));
['mlike','mmaybe'].forEach(sec=>{
const box=$(sec);
if(box.querySelector(`.rc[data-i="${id}"]`)) refreshRowStateInMatches(id,act);
});
}
function refreshRowStateInMatches(id,act){
// a changed decision may move the row between sections — refetch quietly
fetchMatches();
}
$('lrows').addEventListener('click',e=>{
const b=e.target.closest('.rbtn');
if(!b){
const row=e.target.closest('.row'); if(!row) return;
const id=+row.dataset.i;
const it=listItems.find(x=>x.id===id);
if(it) openDetail(it,{type:'row',it,screen:'list'});
return;
}
const id=+b.closest('.rc').dataset.i, s=b.dataset.s;
const it=listItems.find(x=>x.id===id);
if(!it) return;
const next= it.decision===s?null:s; // tapping the lit button clears
setDecision(id,next);
if(next===null && (listCtl.decision===s)) renderList(); // row leaves a filtered view
});
$('fdec').addEventListener('click',e=>{
const chip=e.target.closest('.fchip'); if(!chip) return;
$('fdec').querySelectorAll('.fchip').forEach(c=>c.classList.toggle('on',c===chip));
listCtl.decision=chip.dataset.d;
fetchList();
});
$('fsort').addEventListener('click',e=>{
const chip=e.target.closest('.fchip'); if(!chip) return;
const s=chip.dataset.s;
if(s==='price'){
if(listCtl.sort==='price_asc'||listCtl.sort==='price_desc'){
listCtl.priceDir= listCtl.sort==='price_asc'?1:-1; // toggle on re-tap
if(listCtl.priceDir===1) listCtl.sort='price_desc';
else listCtl.sort='price_asc';
} else { listCtl.sort='price_asc'; listCtl.priceDir=0; }
} else { listCtl.sort=s; }
$('fsort').querySelectorAll('.fchip').forEach(c=>{
const cs=c.dataset.s;
c.classList.toggle('on', cs==='score'?listCtl.sort==='score':cs==='newest'?listCtl.sort==='newest':cs==='km'?listCtl.sort==='km':(listCtl.sort==='price_asc'||listCtl.sort==='price_desc'));
});
$('fprice').textContent= listCtl.sort==='price_asc'?'Price ↑':listCtl.sort==='price_desc'?'Price ↓':'Price ↕';
fetchList();
});
let searchTimer=null;
$('lsearch').addEventListener('input',e=>{
clearTimeout(searchTimer);
searchTimer=setTimeout(()=>{ listCtl.q=e.target.value.trim(); fetchList(); },300);
});
/* ---------- matches screen (production: REAL) ---------- */
let mItems={like:[],maybe:[]};
async function fetchMatches(){
mItems.like=[];
mItems.maybe=[];
const [l,m]=await Promise.all([
getJSON('/api/list?decision=like&limit=500'),
getJSON('/api/list?decision=maybe&limit=500'),
]);
if(l) mItems.like=l.items;
if(m) mItems.maybe=m.items;
renderMatches();
}
function renderMatches(){
$('mlike').innerHTML=mItems.like.map(rowHTML).join('');
$('mmaybe').innerHTML=mItems.maybe.map(rowHTML).join('');
attachThumbFallbacks($('mlike'));
attachThumbFallbacks($('mmaybe'));
mItems.like.concat(mItems.maybe).forEach(it=>{
[['mlike',mItems.like],['mmaybe',mItems.maybe]].forEach(([boxId])=>{
const el=$(boxId).querySelector(`.row[data-i="${it.id}"]`);
if(!el) return;
el.querySelector('.rtitle').textContent=it.title||'';
el.querySelector('.rsub').textContent=subFor(it);
el.querySelector('.rprice').textContent=priceFor(it);
});
});
$('mlikeSec').textContent='♥ Liked ('+mItems.like.length+')';
$('mmaybeSec').textContent='★ Maybe ('+mItems.maybe.length+')';
$('mempty').style.display= (mItems.like.length||mItems.maybe.length)?'none':'';
}
['mlike','mmaybe'].forEach(sec=>{
$(sec).addEventListener('click',e=>{
const b=e.target.closest('.rbtn');
if(!b){
const row=e.target.closest('.row'); if(!row) return;
const id=+row.dataset.i;
const it=mItems.like.concat(mItems.maybe).find(x=>x.id===id);
if(it) openDetail(it,{type:'row',it,screen:'matches'});
return;
}
const id=+b.closest('.rc').dataset.i, s=b.dataset.s;
const it=mItems.like.concat(mItems.maybe).find(x=>x.id===id);
if(!it) return;
const next= it.decision===s?null:s;
setDecision(id,next);
fetchMatches();
});
});
/* ---------- header + badge ---------- */
let badgeFired=false;
async function loadStats(){
const res=await getJSON('/api/stats');
if(!res) return;
$('pill').innerHTML=`<b>${res.live}</b> live`+(res.new>0?` · <b>${res.new}</b> new`:'');
$('lsub').innerHTML=`${res.live} active · `+(res.new>0?`<b style="color:#fff">${res.new} new</b>`:res.new+' new')+' since your last visit';
if(!badgeFired){
badgeFired=true;
fetch('/api/badge/seen',{method:'POST'}).catch(()=>{});
}
}
/* ---------- tabs ---------- */
document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{
const name=t.dataset.tab;
document.querySelectorAll('.tab').forEach(x=>x.classList.toggle('on',x===t));
$('listScreen').classList.toggle('on',name==='list');
$('matchesScreen').classList.toggle('on',name==='matches');
$('stubTrends').classList.toggle('on',name==='trends');
if(name==='list') fetchList(); // fresh decisions every visit
else if(name==='matches') fetchMatches();
});
/* ---------- desktop keyboard arrows (DESIGN.md: v1 frontend) ---------- */
document.addEventListener('keydown',e=>{
if(e.target.tagName==='INPUT'||$('detail').classList.contains('on')) return;
if(e.key==='ArrowRight'){ if(pile[0]) commit(pile[0].el,'like'); }
else if(e.key==='ArrowLeft'){ if(pile[0]) commit(pile[0].el,'nope'); }
else if(e.key==='ArrowUp'){ if(pile[0]) commit(pile[0].el,'maybe'); }
else if(e.key==='Backspace'){ undo(); }
});
/* ---------- boot ---------- */
loadStats();
loadDeck();
/* PWA */
if('serviceWorker' in navigator && (location.protocol==='https:'||location.hostname==='localhost'||location.hostname==='127.0.0.1')){
navigator.serviceWorker.register('/sw.js').catch(()=>{});
}
</script>
</body>
</html>

28
app/static/manifest.json Normal file
View File

@@ -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"
}
]
}

67
app/static/sw.js Normal file
View File

@@ -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('/'))
)
);
});

15
deploy/Dockerfile Normal file
View File

@@ -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"]

View File

@@ -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 `"<score>|<first_seen>|<id>"` 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.

View File

@@ -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)

View File

@@ -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

View File

@@ -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.

View File

@@ -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-<slug>/` → 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.

View File

@@ -0,0 +1,429 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="robots" content="noindex">
<title>Bike-Tinder · Mockup A — Pure</title>
<style>
:root{
--bg:#0b0b0e; --card:#141419; --text:#fff; --mut:rgba(255,255,255,.62);
--accent1:#ff7854; --accent2:#fd267d; --like:#46d369; --nope:#fd4d5c; --maybe:#ffb43a;
}
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
html,body{height:100%}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
background:var(--bg);color:var(--text);overflow:hidden;-webkit-font-smoothing:antialiased}
.app{position:relative;display:flex;flex-direction:column;height:100%}
/* header */
header{height:50px;display:flex;align-items:center;justify-content:space-between;padding:0 16px}
.brand{display:flex;align-items:center;gap:8px;font-weight:800;font-size:16px;letter-spacing:.2px}
.flame{width:18px;height:18px;border-radius:6px;background:linear-gradient(135deg,var(--accent1),var(--accent2))}
.pill{font-size:12px;color:var(--mut);background:#1b1b21;border:1px solid #26262e;border-radius:999px;padding:5px 10px}
.pill b{color:#fff}
/* stage + cards */
.stage{position:relative;flex:1;padding:6px 14px 2px;min-height:0}
.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}
.nophoto{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:repeating-linear-gradient(45deg,#141419 0 14px,#18181f 14px 28px);color:#55555f;font-size:13px;text-align:center;padding:24px;line-height:1.6}
.card .photo{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
.card .grad{position:absolute;left:0;right:0;bottom:0;height:52%;background:linear-gradient(180deg,rgba(0,0,0,0) 0%,rgba(0,0,0,.55) 55%,rgba(0,0,0,.88) 100%)}
.card .info{position:absolute;left:16px;right:16px;bottom:14px;z-index:3}
.chips{display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap}
.chip{font-size:11px;font-weight:600;letter-spacing:.4px;padding:4px 9px;border-radius:999px;background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.22)}
.title{font-size:23px;font-weight:800;line-height:1.15;text-shadow:0 1px 8px rgba(0,0,0,.5);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.price{font-size:20px;font-weight:600;margin-top:2px}
.sub{font-size:13px;color:var(--mut);margin-top:3px}
.stamp{position:absolute;top:26px;z-index:4;font-weight:800;letter-spacing:2px;font-size:26px;padding:6px 12px;border:3px solid;border-radius:10px;opacity:0;pointer-events:none}
.stamp.like{left:20px;transform:rotate(-14deg);color:var(--like);border-color:var(--like);background:rgba(70,211,105,.08)}
.stamp.nope{right:20px;transform:rotate(14deg);color:var(--nope);border-color:var(--nope);background:rgba(253,77,92,.08)}
.stamp.maybe{left:50%;top:auto;bottom:130px;transform:translateX(-50%);color:var(--maybe);border-color:var(--maybe);background:rgba(255,180,58,.08)}
/* actions */
.actions{height:86px;display:flex;align-items:center;justify-content:center;gap:16px}
.act{border:0;border-radius:50%;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:transform .12s}
.act:active{transform:scale(.92)}
.undo{width:42px;height:42px;background:#1d1d24;color:#f5c451;font-size:18px}
.x{width:58px;height:58px;background:#fff;color:#fd5068;font-size:26px;font-weight:700;box-shadow:0 6px 18px rgba(0,0,0,.4)}
.s{width:58px;height:58px;background:#1d1d24;color:var(--maybe);font-size:24px;border:1px solid #2a2a33}
.h{width:62px;height:62px;background:linear-gradient(135deg,var(--accent1),var(--accent2));color:#fff;font-size:26px;box-shadow:0 8px 22px rgba(253,38,125,.35)}
/* tabs */
nav{height:68px;display:flex;align-items:stretch;border-top:1px solid #1d1d24;background:#0d0d10;padding-bottom:6px;position:relative;z-index:60}
.tab{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;font-size:10px;letter-spacing:.3px;color:#6f6f7a;cursor:pointer}
.tab.on{color:#fff}
.tab svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
/* fullscreen detail dossier — iOS-style sheet: top gap + grabber, drag down to close */
.dback{position:absolute;inset:0;z-index:69;background:rgba(0,0,0,.38);opacity:0;pointer-events:none;transition:opacity .28s}
.dback.on{opacity:1}
.detail{position:absolute;top:26px;left:0;right:0;bottom:0;z-index:70;background:#0f0f13;border-radius:18px 18px 0 0;box-shadow:0 -14px 40px rgba(0,0,0,.45);transform:translateY(103%);transition:transform .3s cubic-bezier(.2,.8,.3,1);display:flex;flex-direction:column;overflow:hidden}
.detail.on{transform:translateY(0)}
.dgrab{flex:none;height:26px;display:flex;align-items:center;justify-content:center;background:#0f0f13;cursor:grab;touch-action:none}
.dgrab i{width:42px;height:5px;border-radius:3px;background:rgba(255,255,255,.3);display:block}
.dclose{position:absolute;top:38px;left:14px;width:36px;height:36px;border-radius:50%;background:rgba(0,0,0,.55);color:#fff;border:0;font-size:17px;z-index:5;cursor:pointer}
.dhero{position:relative;height:34%;min-height:190px;flex:none;background:#17171c;touch-action:none}
.dphoto{width:100%;height:100%;object-fit:cover;display:block}
.dphoto.ph{opacity:.25}
.dherograd{position:absolute;left:0;right:0;bottom:0;height:70px;background:linear-gradient(transparent,rgba(15,15,19,.95))}
.dbody{flex:1;overflow-y:auto;padding:14px 18px 20px}
.dtitle{font-size:20px;font-weight:800;line-height:1.2}
.drow{display:flex;align-items:baseline;gap:10px;margin-top:5px}
.dprice{font-size:19px;font-weight:700}
.dsub{font-size:12.5px;color:var(--mut)}
.dchips{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap}
.dsec{font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:#7b7b88;margin:18px 0 8px}
.specs{border-top:1px solid #1f1f27}
.spec{display:flex;justify-content:space-between;gap:14px;padding:9px 2px;border-bottom:1px solid #1f1f27;font-size:13.5px}
.spec .k{color:#8f8f9c}
.spec .v{font-weight:600;text-align:right}
.spec .v.z{color:#5a5a66;font-weight:500}
.dnotes{font-size:13.5px;line-height:1.5;color:#b9b9c2}
.dopen{display:inline-block;margin-top:16px;color:#ff9a76;font-weight:600;font-size:14px;text-decoration:none}
.dacts{flex:none;display:flex;gap:10px;padding:12px 16px calc(14px + env(safe-area-inset-bottom, 0px));border-top:1px solid #1d1d24;background:#0d0d10}
.dact{flex:1;height:48px;border-radius:14px;border:1px solid #2a2a33;background:#16161b;color:#fff;font-size:14px;font-weight:700;cursor:pointer}
.dact:active{transform:scale(.97)}
.dact.no{color:#fd7b89}
.dact.mb{color:var(--maybe)}
.dact.lk{background:linear-gradient(135deg,var(--accent1),var(--accent2));border:0;box-shadow:0 8px 22px rgba(253,38,125,.3)}
.ibtn{position:absolute;top:14px;right:14px;width:32px;height:32px;border-radius:50%;background:rgba(0,0,0,.45);border:1px solid rgba(255,255,255,.25);color:#fff;font-size:14px;z-index:4;cursor:pointer}
/* empty */
.empty{position:absolute;inset:6px 14px 2px;display:none;flex-direction:column;align-items:center;justify-content:center;gap:8px;border:1px dashed #2a2a33;border-radius:18px;color:var(--mut);text-align:center;padding:24px;font-size:13.5px}
.empty.on{display:flex}
.empty b{color:#fff;font-size:16px}
/* list screen */
.list{position:absolute;inset:0;display:none;flex-direction:column;background:var(--bg);z-index:50;padding-bottom:68px}
.list.on{display:flex}
.lhead{padding:14px 16px 8px}
.lhead h1{font-size:20px;font-weight:800}
.lhead .sub{margin-top:2px}
.filters{display:flex;gap:7px;padding:4px 16px 10px;overflow-x:auto}
.fchip{font-size:12px;padding:6px 11px;border-radius:999px;background:#1b1b21;border:1px solid #26262e;color:var(--mut);white-space:nowrap;cursor:pointer}
.fchip.on{background:#fff;color:#111;border-color:#fff;font-weight:600}
.lrows{flex:1;overflow-y:auto;padding:0 10px 12px}
.row{display:flex;gap:11px;align-items:center;padding:9px 8px;border-radius:14px}
.row + .row{border-top:1px solid #1a1a1f}
.thumb{width:56px;height:56px;border-radius:12px;object-fit:cover;background:#1a1a20;flex:none}
.thumb.ph{display:flex;align-items:center;justify-content:center;color:#4a4a55;font-size:20px}
.rmain{flex:1;min-width:0}
.rtitle{font-size:14.5px;font-weight:650;line-height:1.25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.rsub{font-size:12px;color:var(--mut);margin-top:2px}
.rright{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
.rprice{font-size:14px;font-weight:700}
.rc{display:flex;gap:5px}
.rbtn{width:26px;height:26px;border-radius:50%;border:1px solid #2a2a33;background:#16161b;color:#8a8a95;font-size:12px;display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0}
.rbtn.on.like{background:rgba(70,211,105,.15);border-color:var(--like);color:var(--like)}
.rbtn.on.maybe{background:rgba(255,180,58,.15);border-color:var(--maybe);color:var(--maybe)}
.rbtn.on.nope{background:rgba(253,77,92,.15);border-color:var(--nope);color:var(--nope)}
/* stub screens */
.stub{position:absolute;inset:0;display:none;flex-direction:column;align-items:center;justify-content:center;gap:10px;background:var(--bg);z-index:50;color:var(--mut);text-align:center;padding:40px;font-size:13.5px}
.stub.on{display:flex}
.stub b{color:#fff;font-size:17px}
</style>
</head>
<body>
<div class="app">
<header>
<div class="brand"><span class="flame"></span>Bike-Tinder</div>
<div class="pill"><b>560</b> live · <b>3</b> new</div>
</header>
<div class="stage" id="stage">
<div class="empty" id="empty"><b>You're all caught up.</b>New bikes arrive nightly — next import 08:30.<br>↺ brings the last card back.</div>
</div>
<div class="actions" id="actions">
<button class="act undo" id="btnUndo" title="Undo">↺</button>
<button class="act x" id="btnNope" title="Nope">✕</button>
<button class="act s" id="btnMaybe" title="Maybe">★</button>
<button class="act h" id="btnLike" title="Like">♥</button>
</div>
<nav>
<div class="tab on" data-tab="deck"><svg viewBox="0 0 24 24"><rect x="4" y="4" width="13" height="16" rx="2.5"/><path d="M8.5 2.5h9A2.5 2.5 0 0 1 20 5v12"/></svg>Deck</div>
<div class="tab" data-tab="list"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg>List</div>
<div class="tab" data-tab="matches"><svg viewBox="0 0 24 24"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>Matches</div>
<div class="tab" data-tab="trends"><svg viewBox="0 0 24 24"><path d="M5 20V10M12 20V4M19 20v-7"/></svg>Trends</div>
</nav>
<div class="dback" id="dback"></div>
<div class="detail" id="detail">
<div class="dgrab" id="dgrab"><i></i></div>
<button class="dclose" id="dclose">⌄</button>
<div class="dhero"><img class="dphoto" id="dphoto" alt=""><div class="dherograd"></div></div>
<div class="dbody" id="dbody">
<div class="dtitle" id="dtitle"></div>
<div class="drow"><div class="dprice" id="dprice"></div><div class="dsub" id="dsub"></div></div>
<div class="dchips" id="dchips"></div>
<div class="dsec">The machine</div>
<div class="specs" id="dspecs"></div>
<div class="dsec">Seller's notes</div>
<div class="dnotes" id="dnotes"></div>
<a class="dopen" id="dopen" href="#" target="_blank" rel="noopener">Open on Kijiji ↗</a>
</div>
<div class="dacts">
<button class="dact no" id="dNo">✕ Nope</button>
<button class="dact mb" id="dMb">★ Maybe</button>
<button class="dact lk" id="dLk">♥ Like</button>
</div>
</div>
<div class="list" id="listScreen">
<div class="lhead"><h1>All bikes</h1><div class="sub">560 active · <b style="color:#fff">3 new</b> since your last visit</div></div>
<div class="filters">
<div class="fchip on">Score</div><div class="fchip">Price ↕</div><div class="fchip">Newest</div><div class="fchip">km</div><div class="fchip">Filters ⚙</div>
</div>
<div class="lrows" id="lrows"></div>
</div>
<div class="stub" id="stubMatches"><b>Your shortlist lives here</b>Liked and Maybe bikes — once the build lands.</div>
<div class="stub" id="stubTrends"><b>Trends — Phase 3</b>The deck learns your taste as you swipe.</div>
</div>
<script>
const CARDS=[
{title:"2025 Indian Scout Classic Limited",price:"$21,099",sub:"Moncton, NB · dealer",chips:["Dealer","Cruise control"],
year:"2025",km:"—",cc:"1,250 cc",family:"Indian Scout",style:"Street cruiser",loc:"Moncton, NB",seller:"Dealer",seen:"Today",
img:"https://media.kijiji.ca/api/v1/ca-prod-dealer-ads/images/b0/b01e9e9b-63d6-4cf6-bd58-ef7f70ef8df2?rule=kijijica-960-webp",
desc:"Welcome to Rallye Motoplex & Marine. Save $1,102 today with rates as low as 3.99%. Call today and ask about our in-store incentives.",
url:"https://www.kijiji.ca/v-street-cruisers-choppers/moncton/2025-indian-scout-classic-limited-tech-ghost-white-metallic/1729602020"},
{title:"2013 Kawasaki Vulcan 900 Classic",price:"$4,800",sub:"Fredericton, NB · dealer",chips:[],
year:"2013",km:"24,800 km",cc:"903 cc",family:"Vulcan 900",style:"Street cruiser",loc:"Fredericton, NB",seller:"Dealer",seen:"6 days ago",
img:"https://media.kijiji.ca/api/v1/ca-prod-dealer-ads/images/88/882c012d-8682-4616-882b-03bfc4c880eb?rule=kijijica-960-webp",
desc:"MULTI-POINT INSPECTED & RECONDITIONED. Financing + extended warranty options available. No freight or PDI on used bikes.",
url:"https://www.kijiji.ca/v-street-cruisers-choppers/fredericton/2013-kawasaki-vulcan-900-classic-used/1743203823"},
{title:"2026 Kawasaki Vulcan 900 Classic LT",price:"$11,949",sub:"Fredericton, NB · dealer",chips:["Demo"],
year:"2026",km:"—",cc:"903 cc",family:"Vulcan 900",style:"Street cruiser",loc:"Fredericton, NB",seller:"Dealer",seen:"Today",
img:"https://media.kijiji.ca/api/v1/ca-prod-dealer-ads/images/2e/2eb55a55-1648-4e1f-8ca0-ba3fcdeb78d7?rule=kijijica-960-webp",
desc:"Plus 1 free year Kawasaki Protection Plus (KPP). Limited time offer.",
url:"https://www.kijiji.ca/v-street-cruisers-choppers/fredericton/2026-kawasaki-vulcan-900-classic-lt-demo-save-800-00-plus-1/1736278655"},
{title:"2019 Honda Rebel 500",price:"$5,700",sub:"Yarmouth, NS · private",chips:["Windshield"],
year:"2019",km:"8,900 km",cc:"471 cc",family:"Rebel 500",style:"Street cruiser",loc:"Yarmouth, NS",seller:"Private",seen:"2 days ago",
img:"https://media.kijiji.ca/api/v1/ca-prod-fsbo-ads/images/a3/a3c41571-2a33-4102-843a-149a76d0c3b5?rule=kijijica-960-webp",
desc:"Great bike with a professionally painted tank. Comes with gel insert in seat, Kappa back rack, windshield and Givi back box. Light and nimble with a low seat.",
url:"https://www.kijiji.ca/v-street-cruisers-choppers/yarmouth/2019-honda-rebel-500/1745749820"},
{title:"2005 Yamaha Vstar 650 Custom Bobber",price:"$5,400",sub:"Bedford, NS · private",chips:["MVI"],
year:"2005",km:"46,500 km",cc:"649 cc",family:"V-Star 650",style:"Street cruiser",loc:"Bedford, NS",seller:"Private",seen:"12 days ago",
img:"https://media.kijiji.ca/api/v1/ca-prod-fsbo-ads/images/a3/a349dd47-f2cb-472b-a469-330bad7fa061?rule=kijijica-960-webp",
desc:"(Bike is ready to ride) Kept inside and in heated storage. MVI: inspection is good for another year. Horn, lights and gauges all work.",
url:"https://www.kijiji.ca/v-street-cruisers-choppers/bedford/2005-yamaha-vstar-650-custom-bobber/1745767295"},
{title:"2026 Indian Scout Bobber",price:"$17,199",sub:"Halifax, NS · dealer",chips:["Dealer","Bobber"],
year:"2026",km:"—",cc:"1,250 cc",family:"Indian Scout",style:"Street cruiser",loc:"Halifax, NS",seller:"Dealer",seen:"Today",img:null,
desc:"Maroon metallic finish. ABS, LED lighting, 1250 cc V-twin. The long-keep one — saddlebags and extended passenger seat planned.",
url:"https://www.kijiji.ca/"},
{title:"2024 Royal Enfield Super Meteor 650",price:"$5,993",sub:"Halifax, NS · dealer",chips:["Dealer","Warranty"],
year:"2024",km:"3,900 km",cc:"648 cc",family:"Super Meteor 650",style:"Street cruiser",loc:"Halifax, NS",seller:"Dealer",seen:"Today",img:null,
desc:"2024 Super Meteor in Celestial Red. Dealer maintained, one season ridden.",
url:"https://www.kijiji.ca/"},
{title:"2020 Honda 750 Shadow",price:"$2,250",sub:"Bedford, NS · private",chips:["Project"],
year:"2020",km:"38,000 km",cc:"745 cc",family:"Shadow 750",style:"Street cruiser",loc:"Bedford, NS",seller:"Private",seen:"3 weeks ago",img:null,
desc:"Reliable commuter Shadow. Honest bike, needs a carb clean and new rear tire.",
url:"https://www.kijiji.ca/"}
];
const ROWS=CARDS.map(c=>({...c,st:0}));
const $=id=>document.getElementById(id);
const stage=$('stage');
const DEPTH=5; // a 5-card live stack at all times
const SC=.026, TY=16, DIM=.08; // per-depth step: scale, y-offset, dimming
let pile=[]; // top-first [{i,d,el}] — every card its own swipable object
let nextIdx=0, hist=[];
let dragEl=null, sx=0, sy=0, dx=0, dy=0, moved=false;
function isTop(el){ return pile[0]&&pile[0].el===el; }
function setStamps(el,a,b,c){
el.querySelector('.stamp.like').style.opacity=a;
el.querySelector('.stamp.nope').style.opacity=b;
el.querySelector('.stamp.maybe').style.opacity=c;
}
function cardNode(item,i){
const el=document.createElement('div');
el.className='card';
el.innerHTML=`${item.img?`<img class="photo" src="${item.img}" alt="" draggable="false">`:`<div class="nophoto">Photo pending — thumbnail backfill queued</div>`}
<div class="grad"></div>
<div class="stamp like">LIKE</div>
<div class="stamp nope">NOPE</div>
<div class="stamp maybe">MAYBE</div>
<button class="ibtn">ⓘ</button>
<div class="info"><div class="chips">${(item.chips||[]).map(x=>`<span class="chip">${x}</span>`).join('')}</div><div class="title">${item.title}</div><div class="price">${item.price}</div><div class="sub">${item.sub}</div></div>`;
el.addEventListener('pointerdown',down);
el.addEventListener('pointermove',move);
el.addEventListener('pointerup',upH);
el.addEventListener('pointercancel',upH);
const ib=el.querySelector('.ibtn');
ib.addEventListener('pointerdown',e=>e.stopPropagation());
ib.addEventListener('click',e=>{ e.stopPropagation(); if(isTop(el)) openDetail(item,{type:'deck'}); });
return {i,d:item,el};
}
function layer(el,d,anim){
el.classList.toggle('topcard',d===0);
el.style.transition=anim?'transform .22s cubic-bezier(.2,.8,.3,1), filter .22s ease, opacity .22s ease':'none';
el.style.transform=`translateY(${TY*d}px) scale(${1-SC*d})`;
el.style.filter=`brightness(${1-DIM*d})`;
el.style.opacity='1';
el.style.zIndex=10-d;
el.style.pointerEvents=d===0?'auto':'none';
}
function relayer(){ pile.forEach((c,d)=>layer(c.el,d,true)); }
function fillPile(){
while(pile.length<DEPTH&&nextIdx<CARDS.length){
const entry=cardNode(CARDS[nextIdx],nextIdx), d=pile.length;
entry.el.style.transition='none';
entry.el.style.transform=`translateY(${TY*(d+2)}px) scale(${1-SC*(d+2)})`;
entry.el.style.opacity='0';
stage.appendChild(entry.el);
pile.push(entry); nextIdx++;
void entry.el.offsetWidth;
layer(entry.el,d,true);
}
relayer();
}
/* --- list rows --- */
function renderList(){
$('lrows').innerHTML=ROWS.map((r,i)=>`
<div class="row">
${r.img?`<img class="thumb" src="${r.img}" alt="">`:`<div class="thumb ph">◍</div>`}
<div class="rmain"><div class="rtitle">${r.title}</div><div class="rsub">${r.sub}</div></div>
<div class="rright"><div class="rprice">${r.price}</div>
<div class="rc" data-i="${i}">
<button class="rbtn like" data-s="1">♥</button>
<button class="rbtn maybe" data-s="2">★</button>
<button class="rbtn nope" data-s="3">✕</button>
</div>
</div>
</div>`).join('');
}
$('lrows').addEventListener('click',e=>{
const b=e.target.closest('.rbtn');
if(!b){
const row=e.target.closest('.row'); if(!row) return;
const i=+row.querySelector('.rc').dataset.i;
openDetail(ROWS[i],{type:'row',i}); return;
}
const i=+b.closest('.rc').dataset.i, s=+b.dataset.s;
ROWS[i].st = (ROWS[i].st===s)?0:s;
const ctrl=b.closest('.rc');
ctrl.querySelectorAll('.rbtn').forEach(btn=>btn.classList.toggle('on',+btn.dataset.s===ROWS[i].st));
});
/* --- fullscreen dossier --- */
let dCtx=null;
function openDetail(data,ctx){
dCtx=ctx||null;
const ph=$('dphoto');
if(data.img){ ph.src=data.img; ph.classList.remove('ph'); } else { ph.removeAttribute('src'); ph.classList.add('ph'); }
$('dtitle').textContent=data.title; $('dprice').textContent=data.price; $('dsub').textContent=data.sub;
$('dchips').innerHTML=(data.chips||[]).map(x=>`<span class="chip">${x}</span>`).join('');
const rows=[["Year",data.year],["Odometer",data.km],["Engine",data.cc],["Family",data.family],["Category",data.style],["Location",data.loc],["Seller",data.seller],["Source","Kijiji"],["First seen",data.seen]];
$('dspecs').innerHTML=rows.map(r=>`<div class="spec"><span class="k">${r[0]}</span><span class="v${(!r[1]||r[1]==='—')?' z':''}">${r[1]||'—'}</span></div>`).join('');
$('dnotes').textContent=data.desc||'';
$('dopen').href=data.url||'#';
$('detail').classList.add('on'); $('dback').classList.add('on');
$('dbody').scrollTop=0;
}
function closeDetail(){ const d=$('detail'); d.style.transition=''; d.style.transform=''; d.classList.remove('on'); $('dback').classList.remove('on'); dCtx=null; }
$('dclose').onclick=closeDetail;
$('dclose').addEventListener('pointerdown',e=>e.stopPropagation());
/* pull-down-to-close (grabber bar + photo) */
let dDragY=null;
function dStart(e){ dDragY=e.clientY; $('detail').style.transition='none'; try{e.currentTarget.setPointerCapture(e.pointerId)}catch(_){} }
function dMoveH(e){ if(dDragY===null) return; const dy=Math.max(0,e.clientY-dDragY); $('detail').style.transform=`translateY(${dy}px)`; }
function dEndH(e){ if(dDragY===null) return; const dy=Math.max(0,e.clientY-dDragY); dDragY=null;
if(dy>110){ closeDetail(); }
else { const d=$('detail'); d.style.transition='transform .25s cubic-bezier(.2,.8,.3,1)'; d.style.transform=''; } }
[document.querySelector('.dgrab'),document.querySelector('.dhero')].forEach(el=>{ if(!el) return; el.addEventListener('pointerdown',dStart); el.addEventListener('pointermove',dMoveH); el.addEventListener('pointerup',dEndH); el.addEventListener('pointercancel',dEndH); });
function dAction(a){
const ctx=dCtx; closeDetail();
if(!ctx) return;
if(ctx.type==='deck'){
if(pile[0]) commit(pile[0].el, a==='like'?'like':(a==='nope'?'nope':'maybe'));
} else {
const i=ctx.i, s= a==='like'?1:(a==='maybe'?2:3);
ROWS[i].st=(ROWS[i].st===s)?0:s;
const ctrl=document.querySelector(`.rc[data-i="${i}"]`);
if(ctrl) ctrl.querySelectorAll('.rbtn').forEach(btn=>btn.classList.toggle('on',+btn.dataset.s===ROWS[i].st));
}
}
$('dNo').onclick=()=>dAction('nope'); $('dMb').onclick=()=>dAction('maybe'); $('dLk').onclick=()=>dAction('like');
/* ⓘ buttons are wired per-card inside cardNode() */
/* --- swipe engine: commit + fly are per-card; nothing locks the deck --- */
function commit(el,dir){
if(!isTop(el)) return;
const t=pile.shift();
hist.push(t.i);
flyOut(el,dir);
fillPile(); relayer();
if(!pile.length) $('empty').classList.add('on');
}
function flyOut(el,dir){
const x= dir==='maybe'?0:(dir==='like'?620:-620);
const y= dir==='maybe'?-780:40;
const rot= dir==='maybe'?0:(dir==='like'?26:-26);
setStamps(el, dir==='like'?1:0, dir==='nope'?1:0, dir==='maybe'?1:0);
el.classList.remove('topcard');
el.style.zIndex=12; el.style.pointerEvents='none';
el.style.transition='transform .3s ease-in, opacity .3s';
el.style.transform=`translate(${x}px,${y}px) rotate(${rot}deg)`;
el.style.opacity=.15;
setTimeout(()=>{ el.remove(); },420);
}
function undo(){
if(!hist.length) return;
const i=hist.pop();
const entry=cardNode(CARDS[i],i);
entry.el.style.transition='none';
entry.el.style.transform='translateY(-46px) scale(1.02)';
entry.el.style.opacity='0';
stage.appendChild(entry.el);
pile.unshift(entry);
if(pile.length>DEPTH){ const drop=pile.pop(); drop.el.remove(); nextIdx=drop.i; }
void entry.el.offsetWidth;
relayer();
$('empty').classList.remove('on');
}
$('btnLike').onclick=()=>{ if(pile[0]) commit(pile[0].el,'like'); };
$('btnNope').onclick=()=>{ if(pile[0]) commit(pile[0].el,'nope'); };
$('btnMaybe').onclick=()=>{ if(pile[0]) commit(pile[0].el,'maybe'); };
$('btnUndo').onclick=undo;
/* --- drag: only the top card is grabbable; every other card stays live --- */
function down(e){
const el=e.currentTarget;
if(!isTop(el)) return;
dragEl=el; moved=false; sx=e.clientX; sy=e.clientY; dx=0; dy=0;
try{ el.setPointerCapture(e.pointerId) }catch(_){}
el.style.transition='none';
}
function move(e){
const el=e.currentTarget;
if(dragEl!==el) return;
dx=e.clientX-sx; dy=e.clientY-sy;
if(Math.abs(dx)>6||Math.abs(dy)>6) moved=true;
const rot=Math.max(-12,Math.min(12,dx/12));
el.style.transform=`translate(${dx}px,${dy}px) rotate(${rot}deg)`;
setStamps(el, Math.max(0,Math.min(1,dx/120)), Math.max(0,Math.min(1,-dx/120)), dy<-20?Math.max(0,Math.min(1,-dy/130)):0);
}
function upH(e){
const el=e.currentTarget;
if(dragEl!==el) return;
dragEl=null;
const w=stage.clientWidth||360;
el.style.transition='transform .3s cubic-bezier(.2,.8,.3,1)';
if(dx>w*0.35) commit(el,'like');
else if(dx<-w*0.35) commit(el,'nope');
else if(dy<-140) commit(el,'maybe');
else { el.style.transform=''; setStamps(el,0,0,0); if(!moved && e.type==='pointerup' && isTop(el)) openDetail(pile[0].d,{type:'deck'}); }
}
/* --- tabs --- */
document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{
const name=t.dataset.tab;
document.querySelectorAll('.tab').forEach(x=>x.classList.toggle('on',x===t));
$('listScreen').classList.toggle('on',name==='list');
$('stubMatches').classList.toggle('on',name==='matches');
$('stubTrends').classList.toggle('on',name==='trends');
});
renderList(); fillPile();
</script>
</body>
</html>

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
# Bike-Tinder runtime deps (plain uvicorn — no [standard] extras needed).
fastapi
uvicorn
httpx