diff --git a/app/api.py b/app/api.py index 38ca71b..d070939 100644 --- a/app/api.py +++ b/app/api.py @@ -238,6 +238,14 @@ def create_app(db_path, img_dir, state_dir=None): live = conn.execute( "SELECT COUNT(*) AS n FROM listings WHERE status='active'" ).fetchone()['n'] + # item 7: fresh = active with no swipe row (the deck's new-pool + # predicate, verbatim). A swipe of ANY age removes freshness. + fresh = conn.execute( + "SELECT COUNT(*) AS n FROM listings l" + " WHERE l.status='active'" + " AND NOT EXISTS (SELECT 1 FROM swipes s" + " WHERE s.listing_id = l.id)" + ).fetchone()['n'] badge = get_pref(conn, 'badge_seen_at') or '' new = conn.execute( "SELECT COUNT(*) AS n FROM listings" @@ -253,6 +261,7 @@ def create_app(db_path, img_dir, state_dir=None): ' ORDER BY id DESC LIMIT 1').fetchone() return { 'live': live, + 'fresh': fresh, 'new': new, 'likes': per['like'], 'maybes': per['maybe'], @@ -451,11 +460,20 @@ def create_app(db_path, img_dir, state_dir=None): sec = conn.execute('SELECT * FROM sections WHERE id=?', (sec['id'],)).fetchone() mode = get_pref(conn, 'forge_mode', 'ondemand') + decided_ids = secmod.section_decided_ids(conn, sec) items = [p3_payload(conn, engine, r, slot=r['slot'], include_breakdown=True) for r in data['items']] + for p in items: + # item 5: decided IN this section (real verdict), not the joined + # lifetime swipes decision — present on EVERY item so the FE's + # local-intent branch can't misfire + p['decided_this_section'] = p['id'] in decided_ids data['items'] = items data['mode'] = mode + data['recipe'] = (json.loads(sec['recipe']) if sec['recipe'] else {}) + data['pool'] = secmod.pool_counts(conn) + data['prefs'] = {'rubric_debug': get_pref(conn, 'rubric_debug', 'off')} data['can_forge'] = (mode != 'strict' or secmod.fresh_profile_exists(conn, sec)) data['counts'] = secmod.section_counts(conn, sec) @@ -483,6 +501,22 @@ def create_app(db_path, img_dir, state_dir=None): conn, engine, size=25, profile_version=prof['version'] if prof else None, note='cold compose (first read)') + else: + # item 8: an empty composed section (the catalog was fully + # drained) recomposes on the read once stock exists — the + # nightly-import recovery path. Zero items + zero pool + # keeps serving the empty section (loud empty2). + n_items = conn.execute( + 'SELECT COUNT(*) AS n FROM section_items' + ' WHERE section_id=?', (sec['id'],)).fetchone()['n'] + if n_items == 0 and any(secmod.pool_counts(conn).values()): + prof = secmod.latest_profile(conn) + engine = secmod.build_engine(conn, prof, now=utcnow()) + secmod.close_section(conn, sec, utcnow(), status='closed') + sec = secmod.compose_section( + conn, engine, size=25, + profile_version=prof['version'] if prof else None, + note='refill compose (stock returned)') return section_state(conn, sec) finally: conn.close() @@ -522,6 +556,11 @@ def create_app(db_path, img_dir, state_dir=None): conn, engine, size=size, profile_version=prof['version'] if prof else None, note=note) + # v1.1 item 6: label the dry-catalog state on the forge note + recipe = (json.loads(newsec['recipe']) + if newsec['recipe'] else {}) + if recipe.get('new') == 0: + note = '%s · catalog fully reviewed' % (note or '') return section_state(conn, newsec, note=note) finally: conn.close() @@ -653,7 +692,8 @@ def create_app(db_path, img_dir, state_dir=None): def set_pref_endpoint(body: dict): key = body.get('key') value = body.get('value') - allowed = {'forge_mode': ('ondemand', 'strict')} + allowed = {'forge_mode': ('ondemand', 'strict'), + 'rubric_debug': ('off', 'on')} if key not in allowed or value not in allowed[key]: raise HTTPException(status_code=400, detail='unknown or invalid pref') diff --git a/app/fe_walk_v11.py b/app/fe_walk_v11.py new file mode 100644 index 0000000..a4ebb25 --- /dev/null +++ b/app/fe_walk_v11.py @@ -0,0 +1,849 @@ +"""v1.1 FE walk — headless-chromium proof of every v1.1 item on the real page. + +Self-contained: stages its own temp DBs (never the live file), boots the +scratch server on 127.0.0.1:9455 (never 9453), runs the walks, kills the +server. Mock forge only (OPENROUTER_API_KEY is not consulted — the server +is started with the key unset). + + cd /var/home/admin/dev/bike-tinder-v11/build + .venv/bin/python -m app.fe_walk_v11 setup # (auto-runs at walk start) + .venv/bin/python -m app.fe_walk_v11 # full walk + +Phases: + WALK1 (rich: pristine catalog + synthetic multi-photo fixtures 701-706, + 705 a liked return) — items 1, 2, 3, 4, 7, 10, B1 §11 regressions + (flip clamps / one .topcard / no-lock swipe storm), full P3 loop + (compose -> strike all -> report -> forge), item 6 non-dry. + WALK2 (returns-only: every active pre-swiped maybe) — item 5 counter + (0/N -> 1/N -> undo -> sweep -> 25/25 hot -> report counts), + item 6 dry caption + dry forge status. + WALK3 (0/0/0: returns one-shot-burned) — item 8 loud empty2, nightly- + import recovery (a fresh listing re-lights the deck). +""" +import json +import os +import re +import shutil +import sqlite3 +import subprocess +import sys +import time +import urllib.request + +ARENA = '/var/home/admin/dev/bike-tinder-v11' +REPO = os.path.join(ARENA, 'build') +PRISTINE = os.path.join(ARENA, 'scratch-v11-pristine.sqlite3') +WALK1 = os.path.join(ARENA, 'scratch-v11-walk1.sqlite3') +WALK2 = os.path.join(ARENA, 'scratch-v11-walk2.sqlite3') +WALK3 = os.path.join(ARENA, 'scratch-v11-walk3.sqlite3') +IMG = os.path.join(ARENA, 'img-v11') +MEDIA = os.path.join(ARENA, 'media-sample-v11') +SHOTS = os.path.join(ARENA, 'shots-v11') +BASE_URL = 'http://127.0.0.1:9455' +PORT = 9455 + +CDN = ('https://media.kijiji.ca/api/v1/ca-prod-dealer-ads/images/x/%s' + '?rule=kijijica-640-webp') + +FIX = { + 701: (8, 'gallery-8'), + 702: (3, 'gallery-3'), + 703: (2, 'gallery-2'), + 704: (1, 'single-photo'), + 705: (8, 'return-rotation'), + 706: (0, 'zero-photo'), +} +SCORE_BASE = 2000 + +server_proc = None +page = None + + +# --- staging --------------------------------------------------------------- + +def stage_img_files(): + os.makedirs(IMG, exist_ok=True) + names = sorted(m for m in os.listdir(MEDIA) if m.endswith('.webp')) + assert len(names) == 12, names + k = 0 + for lid in sorted(FIX): + n = FIX[lid][0] + if n == 0: + continue + if n == 1: + shutil.copy2(os.path.join(MEDIA, names[k % len(names)]), + os.path.join(IMG, '%d.webp' % lid)) + k += 1 + continue + for i in range(n): + shutil.copy2(os.path.join(MEDIA, names[k % len(names)]), + os.path.join(IMG, '%d_%d.webp' % (lid, i))) + k += 1 + + +def seeds(): + """Fixture rows + return candidates on a temp copy; returns the maybe-pair.""" + from app import db as dbmod + conn = sqlite3.connect(WALK1) + conn.row_factory = sqlite3.Row + try: + dbmod.init_db(conn) # guarded B1/P3 migrations + for lid in sorted(FIX): + n = FIX[lid][0] + imgs = [CDN % ('x%d-%d' % (lid, i)) for i in range(n)] + img = imgs[0] if imgs else None + score = SCORE_BASE + (750 - lid) + conn.execute("DELETE FROM listings WHERE id=?", (lid,)) + conn.execute( + "INSERT INTO listings (id, region, src_id, title, url, status," + " img_url, imgs, score, first_seen, desc) VALUES" + " (?, 'ns', ?, ?, ?, 'active', ?, ?, ?," + " '2026-11-11T00:00:00Z', ?)", + (lid, 'v11fix%d' % lid, 'V1.1 test %s' % FIX[lid][1], + 'https://www.kijiji.ca/v-motorcycles/v11/%d' % lid, + img, json.dumps(imgs) if imgs else None, score, + 'V1.1 fixture')) + conn.execute( + "INSERT OR REPLACE INTO swipes (listing_id, action, created_at," + " last_photo_idx) VALUES (705, 'like', '2026-11-08T00:00:00Z', 0)") + # fresh fixtures: round-1 events keep them OUT of the wildcard pool + for lid in FIX: + conn.execute( + "INSERT INTO swipe_events (listing_id, action, round," + " section_id) VALUES (?, ?, 1, NULL)", + (lid, 'like' if lid == 705 else 'maybe')) + # second reserved maybe-return: the highest-scored existing swiped + # active swings to maybe so 705 keeps its reserved return slot + x = conn.execute( + "SELECT s.listing_id FROM swipes s" + " JOIN listings l ON l.id = s.listing_id" + " WHERE l.status='active' ORDER BY l.score DESC LIMIT 1" + ).fetchone()['listing_id'] + conn.execute("UPDATE swipes SET action='maybe' WHERE listing_id=?", (x,)) + conn.execute( + "UPDATE swipe_events SET action='maybe' WHERE listing_id=?" + " AND round=1", (x,)) + conn.execute("UPDATE sections SET status='closed'" + " WHERE status!='closed'") + conn.commit() + return x + finally: + conn.close() + + +def setup(): + assert os.path.exists(PRISTINE), 'pristine copy missing' + os.makedirs(SHOTS, exist_ok=True) + stage_img_files() + shutil.copy2(PRISTINE, WALK1) + seeds() + shutil.copy2(PRISTINE, WALK2) + conn = sqlite3.connect(WALK2) + try: + conn.execute('DELETE FROM section_items') + conn.execute("UPDATE sections SET status='closed'" + " WHERE status!='closed'") + conn.execute( + """INSERT INTO swipes (listing_id, action, created_at) + SELECT id, 'maybe', '2026-11-01T00:00:00Z' FROM listings + WHERE status='active' + AND id NOT IN (SELECT listing_id FROM swipes)""") + conn.execute( + """INSERT INTO swipe_events (listing_id, action, round, section_id) + SELECT id, 'maybe', 1, NULL FROM listings + WHERE status='active' + AND id NOT IN (SELECT listing_id FROM swipe_events)""") + conn.commit() + finally: + conn.close() + print('setup: walk DBs staged (%s, %s)' % (WALK1, WALK2)) + + +# --- server ---------------------------------------------------------------- + +def start_server(db_path): + global server_proc + env = dict(os.environ) + env.pop('OPENROUTER_API_KEY', None) # mock forge pass only + server_proc = subprocess.Popen( + [os.path.join(REPO, '.venv/bin/python'), '-m', 'app.serve', + '--db', db_path, '--img-dir', IMG, '--host', '127.0.0.1', + '--port', str(PORT), '--state-dir', + os.path.join(ARENA, 'state-v11')], + cwd=REPO, env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(60): + try: + with urllib.request.urlopen(BASE_URL + '/api/health', + timeout=2) as r: + if r.status == 200: + return + except Exception: + pass + time.sleep(0.25) + raise RuntimeError('scratch server did not come up') + + +def stop_server(): + global server_proc + if server_proc: + server_proc.terminate() + try: + server_proc.wait(timeout=5) + except subprocess.TimeoutExpired: + server_proc.kill() + server_proc = None + + +# --- walk helpers ---------------------------------------------------------- + +CHECKS = [0, 0] + + +def wait(selector, timeout=10000): + page.wait_for_selector(selector, timeout=timeout) + + +def ok(msg): + CHECKS[0] += 1 + print('ok ' + msg) + + +def bad(msg): + CHECKS[1] += 1 + print('FAIL ' + msg) + page.screenshot(path=os.path.join(SHOTS, 'fail-%s.png' % int(time.time()))) + raise SystemExit(1) + + +def check(cond, msg): + if cond: + ok(msg) + else: + bad(msg) + + +def top_state(): + return page.evaluate("""() => { + const t=document.querySelector('.topcard'); + const r={topcards:document.querySelectorAll('.topcard').length, + photo:null, segOn:-1, segs:0, band:null, bandClass:null}; + if(!t) return r; + const s=t.querySelector('.segments'); + if(s){ r.segs=s.children.length; + [...s.children].forEach((x,k)=>{ if(x.classList.contains('on')) + r.segOn=k; }); } + const p=t.querySelector('.phwrap img.photo'); + if(p) r.photo=p.getAttribute('src'); + const b=t.querySelector('.band'); + if(b){ r.band=b.textContent; r.bandClass=b.className; } + return r; }""") + + +def top_id(): + t = top_state() + if not t['photo']: + return None + m = re.search(r'/img/(\d+)(?:\?i=\d+)?$', t['photo'] or '') + return int(m.group(1)) if m else None + + +def sect_pill(): + return page.evaluate("() => ({txt:document.getElementById('sect').textContent," + " hot:document.getElementById('sect').classList.contains('hot')})") + + +def pile_len(): + return page.evaluate("() => document.querySelectorAll('.topcard').length +" + " + [...document.querySelectorAll('#stage .card')].filter(" + " e=>!e.classList.contains('topcard')).length") + + +def key(k): + page.keyboard.press(k) + time.sleep(0.08) + + +def api(path): + with urllib.request.urlopen(BASE_URL + path, timeout=10) as r: + return json.loads(r.read()) + + +def api_post(path, body): + req = urllib.request.Request(BASE_URL + path, + data=json.dumps(body).encode(), + headers={'Content-Type': 'application/json'}) + with urllib.request.urlopen(req, timeout=30) as r: + return json.loads(r.read()) + + +def section_body(): + return api('/api/section') + + +def flick(ms=350): + time.sleep(ms / 1000.0) + + +def tap_center(): + b = page.evaluate("""() => { const el=document.querySelector('.topcard'); + const r=el.getBoundingClientRect(); + return {x:r.x+r.width/2,y:r.y+r.height*0.72}; }""") + page.mouse.click(b['x'], b['y']) + time.sleep(0.08) + + +def tap_side(side): + b = page.evaluate("""() => { const el=document.querySelector('.topcard'); + const r=el.getBoundingClientRect(); + return {x:r.x, w:r.width, y:r.y+r.height*0.72}; }""") + x = b['x'] + (0.16 if side == 'left' else 0.84) * b['w'] + page.mouse.click(x, b['y']) + time.sleep(0.08) + + +def taps(clicks, gap=40): + # real commit storm through the like button: the no-lock law must + # survive button-speed swipes + for _ in range(clicks): + page.evaluate("() => document.getElementById('btnLike').click()") + time.sleep(gap / 1000.0) + + +def dossier_on(): + return page.evaluate( + "() => document.getElementById('detail').classList.contains('on')") + + +def viewer_on(): + return page.evaluate( + "() => document.getElementById('viewer').classList.contains('on')") + + +def dhero_click(): + b = page.evaluate("""() => { const el=document.getElementById('dhero')|| + document.querySelector('.dhero'); + const r=el.getBoundingClientRect(); + return {x:r.x+r.width/2,y:r.y+r.height/2}; }""") + page.mouse.click(b['x'], b['y']) + time.sleep(0.15) + + +# --- WALK1: rich deck ------------------------------------------------------ + +def walk_rich(): + check(page.url.startswith(BASE_URL), 'page served from the app root') + wait('.topcard') + flick(800) + body = section_body() + check(len(body['items']) == 25, 'rich compose serves 25 items') + check(body['recipe']['new'] == 21 and body['recipe']['return'] == 2 + and body['recipe']['wildcard'] == 2, + 'recipe 21/2/2 on the rich pool (the sign-off shape)') + by_id = {i['id']: i for i in body['items']} + for lid in (701, 702, 703, 704, 706): + check(lid in by_id and by_id[lid]['slot'] == 'new', + 'fixture %s sits in the fresh slots' % lid) + check(by_id[705]['slot'] == 'return', '705 composes into a return slot') + + t = top_state() + check(t['topcards'] == 1, 'exactly one .topcard on load') + check(top_id() == 701, 'top card is the highest-scored fixture (701)') + + # item 3: computed styles on the top card (fit + backdrop) + css = page.evaluate("""() => { const c=document.querySelector('.topcard'); + const p=c.querySelector('.phwrap img.photo'); + const b=c.querySelector('.phwrap .phblur'); + return {fit:getComputedStyle(p).objectFit, + blur:!!b, bg:b?b.style.backgroundImage:null, + src:p.getAttribute('src')}; }""") + check(css['fit'] == 'contain', 'item 3: card photo object-fit == contain') + check(bool(css['blur']) and css['src'] in (css['bg'] or ''), + 'item 3: card backdrop exists and mirrors the photo URL') + page.screenshot(path=os.path.join(SHOTS, 'item3-card-fit.png')) + + # --- item 5+1: strikes through the real key map ------------------------ + check(sect_pill()['txt'] == '0/25', 'fresh rich section starts 0/25') + key('ArrowUp') + check(sect_pill()['txt'] == '1/25', 'ArrowUp (maybe) -> 1/25') + key('ArrowDown') + check(sect_pill()['txt'] == '0/25', 'ArrowDown (undo) -> 0/25') + key('ArrowUp') + check(sect_pill()['txt'] == '1/25', 're-decide -> 1/25') + key('Backspace') + check(sect_pill()['txt'] == '0/25', 'Backspace is the undo alias') + pill_before = sect_pill()['txt'] + # scroll prevention spy + page.evaluate("""() => { window.__prev=[]; document.addEventListener( + 'keydown',e=>window.__prev.push([e.key,e.defaultPrevented])); }""") + key('ArrowLeft') + key('ArrowRight') + prevented = page.evaluate("""() => window.__prev.filter( + p=>p[0].startsWith('Arrow')).map(p=>p[1])""") + check(all(prevented), 'item 1: arrows defaultPrevented (no page scroll)') + pn = sect_pill()['txt'] + check(int(pn.split('/')[0]) == int(pill_before.split('/')[0]) + 2, + 'ArrowLeft/ArrowRight commit through the normal path (%s -> %s)' + % (pill_before, pn)) + + # --- item 1: text-input guard (list search) --------------------------- + page.evaluate("() => document.querySelector('.tab[data-tab=list]').click()") + wait('#lsearch') + page.evaluate("""() => { const s=document.getElementById('lsearch'); + s.focus(); }""") + page.keyboard.type('abc') + before_search = page.evaluate( + "() => document.getElementById('lsearch').value") + check(before_search == 'abc', 'search input accepts typing (focus guard on)') + for kk in ('ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'): + page.keyboard.press(kk) + flick(60) + check(page.evaluate( + "() => document.getElementById('lsearch').value") == 'abc', + 'arrows inside the search input never swipe (text + value unchanged)') + check(sect_pill()['txt'] == pn, + 'input-focused arrows leave the section counter untouched') + page.keyboard.press('Backspace') + check(page.evaluate( + "() => document.getElementById('lsearch').value") == 'ab', + 'Backspace edits text while an input owns focus (no undo fired)') + page.evaluate("() => document.querySelector('.tab[data-tab=deck]').click()") + flick(300) + + # --- item 2: center tap -> dossier -> hero -> viewer; Esc/Es ---------- + tid_before = top_id() + src_before = top_state()['photo'] + pill_pre_tap = sect_pill()['txt'] + tap_center() + check(sect_pill()['txt'] == pill_pre_tap, + 'item 2: center tap commits nothing') + check(dossier_on(), 'item 2: center tap opens the dossier') + check(not viewer_on(), 'item 2: no viewer on center tap') + check(bool(page.evaluate( + "() => document.getElementById('dtitle').textContent")), + 'dossier carries the card title') + dhero_click() + check(viewer_on(), 'dossier hero tap opens the viewer (photo 0)') + vidx = page.evaluate("""() => { const im=document.getElementById('vimg'); + return {src:im.getAttribute('src'), + fot:getComputedStyle(im).objectFit}; }""") + check(vidx['fot'] == 'contain', 'item 3: viewer vimg stays contain (no diff)') + page.screenshot(path=os.path.join(SHOTS, 'item3-viewer.png')) + # arrows are inert while the viewer is open + pill_now = sect_pill()['txt'] + for kk in ('ArrowLeft', 'ArrowRight', 'ArrowUp'): + page.keyboard.press(kk) + check(sect_pill()['txt'] == pill_now, + 'arrows are inert while the viewer is open') + key('Escape') + check(not viewer_on() and dossier_on(), + 'Esc closes the viewer only (dossier stays open)') + key('Escape') + check(not dossier_on() and not viewer_on(), + 'second Esc closes the dossier to the deck') + check(top_id() == tid_before and top_state()['photo'] == src_before, + 'walk-back leaves the same card on the same photo') + + # item 2 A5: list row -> dossier -> viewer -> Esc -> dossier -> Esc -> list + page.evaluate("() => document.querySelector('.tab[data-tab=list]').click()") + wait('#lrows .row') + flick(400) + thumb_fit = page.evaluate("""() => { const t=document.querySelector( + '.thumb'); return t?getComputedStyle(t).objectFit:null; }""") + check(thumb_fit == 'cover', 'item 3: list thumbs stay cover (no diff)') + page.evaluate("""() => document.querySelector('#lrows .row').click()""") + flick(400) + check(dossier_on(), 'list row tap opens the dossier') + dhero_click() + check(viewer_on(), 'list dossier hero opens the viewer') + key('Escape') + check(not viewer_on() and dossier_on(), 'Esc: viewer -> dossier (list)') + key('Escape') + check(not dossier_on() + and page.evaluate("() => document.getElementById('listScreen')" + ".classList.contains('on')"), + 'second Esc returns to the list screen') + page.evaluate("() => document.querySelector('.tab[data-tab=deck]').click()") + flick(300) + + # arrows inert while the dossier is open + tap_center() + check(dossier_on(), 'dossier open for the key-inertness check') + before_pill = sect_pill()['txt'] + for kk in ('ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'): + page.keyboard.press(kk) + flick(40) + check(sect_pill()['txt'] == before_pill, + 'arrow keys are inert while the dossier is open') + + # item 10+4: dossier band + minimal rubric on the fresh card + db = page.evaluate("""() => { const b=document.getElementById('dband'); + if(b.style.display==='none') return {off:true}; + return {off:false, cls:b.className}; }""") + check(bool(db.get('off')), 'dossier on a fresh card shows no band') + drank = page.evaluate("""() => { const d=document.getElementById('drank'); + return {shown:d.style.display!=='none', + pts:document.getElementById('dpts').textContent, + lines:document.getElementById('dlines').innerHTML, + foot:document.getElementById('dfoot').textContent}; }""") + check(re.search(r'^\d+%$', drank['pts']), + 'item 4: rank block shows gauge + NN%% (%s)' % drank['pts']) + check(not drank['lines'] and not drank['foot'], + 'item 4: breakdown lines + footer hidden with rubric_debug off') + dc = page.evaluate("""() => { const p=document.getElementById('dphoto'); + const b=document.getElementById('dblur'); + return {fit:getComputedStyle(p).objectFit, + bg:b.style.backgroundImage}; }""") + check(dc['fit'] == 'contain' and bool(dc['bg']), + 'item 3: dossier hero fits contain over its backdrop') + page.screenshot(path=os.path.join(SHOTS, 'item3-dossier.png')) + key('Escape') + + # item 2: side taps still flip (clamped), never open a sheet + before_photo = top_state()['photo'] + tap_side('left') + check(top_state()['photo'] == before_photo, 'left tap clamps at photo 0') + check(not dossier_on() and not viewer_on(), + 'side taps never open sheets (B1 §11.2 regression)') + tap_side('right') + check(top_state()['photo'] != before_photo, 'right tap flips forward') + + # --- item 4: rubric_debug on -> breakdown appears ---------------------- + api_post('/api/prefs', {'key': 'rubric_debug', 'value': 'on'}) + page.reload(wait_until='networkidle') + wait('.topcard') + flick(400) + tap_center() + check(dossier_on(), 'dossier open for the debug-mode check') + drank = page.evaluate("""() => { const d=document.getElementById('drank'); + if(d.style.display==='none') return {shown:false}; + return {shown:true, + lines:document.getElementById('dlines').textContent, + foot:document.getElementById('dfoot').textContent}; }""") + check(bool(drank['shown']) and bool(drank['lines']), + 'item 4: rubric_debug on -> breakdown lines render') + page.screenshot(path=os.path.join(SHOTS, 'item4-debug-on.png')) + key('Escape') + page.evaluate("() => document.querySelector('.tab[data-tab=trends]').click()") + flick(300) + page.evaluate("() => document.getElementById('tRubOff').click()") + flick(400) + check(api('/api/section').get('prefs', {}).get('rubric_debug') == 'off', + 'Trends toggle wrote rubric_debug=off to the server') + heck = page.evaluate("""() => document.getElementById( + 'tRubOff').classList.contains('on')""") + check(bool(heck), 'toggle UI paints the off state') + page.evaluate("() => document.querySelector('.tab[data-tab=deck]').click()") + flick(300) + + # --- item 7: header pill + stats = api('/api/stats') + pill_txt = page.evaluate( + "() => document.getElementById('pill').textContent") + check(('%d live' % stats['live']) in pill_txt + and ('%d fresh' % stats['fresh']) in pill_txt, + 'item 7: pill reads live + fresh (%s)' % pill_txt.replace('\n', ' ')) + + # --- B1 no-lock: rapid commit storm (no sheet, no lock) ---------------- + before = sect_pill()['txt'] + taps(2, 40) + flick(500) + t = top_state() + check(t['topcards'] == 1, 'no-lock storm keeps exactly one .topcard') + after = sect_pill()['txt'] + check(int(after.split('/')[0]) - int(before.split('/')[0]) == 2, + 'both storm swipes landed (%s -> %s)' % (before, after)) + + # --- item 10 + P3 loop: sweep to the return band and exhaust ------------ + bands = {} + for _ in range(30): + tid = top_id() + if tid is not None and tid in by_id: + slot = by_id[tid]['slot'] + if slot in ('return', 'wildcard') and slot not in bands: + bands[slot] = page.evaluate("""() => { const b=document.querySelector( + '.topcard .band'); if(!b) return null; + const s=getComputedStyle(b); + return {cls:b.className, color:s.color, bg:s.backgroundColor, + text:b.textContent}; }""") + if not page.evaluate("() => document.querySelector('.topcard')"): + break + key('ArrowRight') + flick(80) + check(bool(bands.get('return')), + 'item 10: a SECOND LOOK card topped and carried its band') + if bands.get('return'): + check(bands['return']['color'] == 'rgb(196, 181, 253)', + 'item 10: SL band text is the violet tint') + check(bands['return']['bg'] == 'rgba(124, 58, 237, 0.22)', + 'item 10: SL band fill is violet 22%') + check('SECOND LOOK' in bands['return']['text'], + 'item 10: SL band label intact') + page.screenshot(path=os.path.join(SHOTS, 'item10-card-band.png')) + if bands.get('wildcard'): + check('wild' in bands['wildcard']['cls'], + 'item 10: wildcard band keeps its amber variant') + # dossier band on a return item: drive the real openDetail path + ret_item = next((i for i in body['items'] if i['slot'] == 'return'), None) + db = page.evaluate("""(item) => { openDetail(item, {type:'deck'}); + const b=document.getElementById('dband'); + const s=getComputedStyle(b); + return {off:b.style.display==='none', color:s.color, + bg:s.backgroundColor, text:b.textContent}; }""", ret_item) + check(not db['off'] and db['color'] == 'rgb(196, 181, 253)' + and 'SECOND LOOK' in db['text'], + 'item 10: dossier mirrors the violet SECOND LOOK band') + key('Escape') + + # finish the section -> report -> forge + for _ in range(10): + if not page.evaluate("() => document.querySelector('.topcard')"): + break + key('ArrowRight') + flick(80) + wait('#report.on, #empty2.on', timeout=10000) + rep = page.evaluate("""() => ({report:document.getElementById('report') + .classList.contains('on'), + dry:document.getElementById('dryCap').style.display!=='none', + counts:document.getElementById('repCounts').textContent, + empty2:document.getElementById('empty2').classList.contains('on')})""") + check(bool(rep['report']), 'P3 loop: report card owns the exhausted deck') + check(not rep['dry'], 'item 6: rich batch shows no dry caption') + check(not rep['empty2'], 'item 8: empty2 stays hidden while stock remains') + check(sect_pill()['hot'], 'pill is hot at the section end') + page.screenshot(path=os.path.join(SHOTS, 'p3-report.png')) + sec_before = section_body()['section']['id'] + page.evaluate("() => document.getElementById('btnForge').click()") + wait('.topcard', timeout=20000) + check(section_body()['section']['id'] != sec_before, + 'forge opened a brand-new section') + pill = sect_pill() + check(re.search(r'^\d+/25$', pill['txt']), + 'new section pill starts at a plain N/25 (%s)' % pill['txt']) + check(not dossier_on() and not viewer_on(), 'forge leaves no sheets open') + print('rich walk complete') + + +# --- WALK2: returns-only (counter fix + dry messaging) --------------------- + +def walk_returns_only(): + wait('.topcard') + flick(800) + body = section_body() + check(len(body['items']) == 25 and body['recipe']['new'] == 0, + 'returns-only catalog composes 25 second looks (recipe %s)' + % json.dumps(body['recipe'])) + check(body['progress']['decided'] == 0 + and body['progress']['total'] == 25, + 'item 5: fresh batch reads 0/25 (old code painted 25/25)') + pill = sect_pill() + check(pill['txt'] == '0/25' and not pill['hot'], + 'pill shows 0/25, no premature hot state') + + # one real strike -> 1/25 (in-section event, not lifetime verdict) + key('ArrowRight') + pill = sect_pill() + check(pill['txt'] == '1/25', 'first strike -> 1/25 (%s)' % pill['txt']) + check(section_body()['progress']['decided'] == 1, + 'server truth agrees: one in-section decision') + key('ArrowUp') + check(sect_pill()['txt'] == '2/25', 'ArrowUp maybe -> 2/25') + key('ArrowDown') + check(sect_pill()['txt'] == '1/25', 'ArrowDown undo -> 1/25') + key('Backspace') + check(sect_pill()['txt'] == '0/25', 'Backspace undo -> 0/25') + + # mixed actions -> report counts proven in-section only + key('ArrowLeft') + key('ArrowUp') + key('ArrowRight') + check(sect_pill()['txt'] == '3/25', 'three strikes -> 3/25 (clamped)') + + # sweep to exhaustion + for _ in range(30): + if not page.evaluate("() => document.querySelector('.topcard')"): + break + key('ArrowRight') + flick(60) + wait('#report.on', timeout=10000) + pill = sect_pill() + check(pill['txt'] == '25/25' and pill['hot'], + 'pill clamps at 25/25 and goes hot (never 26/25)') + counts = api('/api/section')['counts'] + check(counts['likes'] + counts['maybes'] + counts['nopes'] == 25, + 'report counts the 25 in-section verdicts (not lifetime)') + rep = page.evaluate("""() => ({dry:document.getElementById('dryCap') + .style.display!=='none', + status:document.getElementById('forgeStatus').textContent})""") + check(bool(rep['dry']), 'item 6: dry caption shows on the 0-fresh report') + + # dry forge -> the sentence + a fresh 0/25 returns-only deck + page.evaluate("() => document.getElementById('btnForge').click()") + for _ in range(40): + st = page.evaluate( + "() => document.getElementById('forgeStatus').textContent") + if 'Catalog fully reviewed' in st: + break + flick(250) + st = page.evaluate( + "() => document.getElementById('forgeStatus').textContent") + check('Catalog fully reviewed — fresh listings arrive with the next' + ' 08:30 import; showing second looks' in st, + 'item 6: dry forge status shows the exact BACKLOG sentence') + wait('.topcard', timeout=15000) + body = section_body() + check(body['recipe']['new'] == 0 and body['progress']['decided'] == 0, + 'dry forge decks another 25 second looks at 0/25') + print('returns-only walk complete') + + +# --- WALK3: dead catalog (loud empty2) ------------------------------------- + +def burn_returns(db_path): + conn = sqlite3.connect(db_path) + try: + conn.execute('DELETE FROM section_items') + conn.execute("UPDATE sections SET status='closed'" + " WHERE status!='closed'") + conn.execute( + """INSERT INTO section_items (section_id, listing_id, slot, + position) + SELECT (SELECT id FROM sections ORDER BY id DESC LIMIT 1), + listing_id, 'return', 1 FROM swipes + WHERE listing_id IN (SELECT id FROM listings + WHERE status='active')""") + conn.commit() + finally: + conn.close() + + +def walk_dead_catalog(): + body = section_body() + check(body['pool'] == {'fresh': 0, 'returns': 0, 'wildcards': 0}, + 'item 8: drained catalog reports pool 0/0/0') + check(body['items'] == [], 'compose emits zero items on a dead pool') + wait('#empty2.on', timeout=10000) + st = page.evaluate("""() => { const e=document.getElementById('empty2'); + return {on:e.classList.contains('on'), + txt:e.textContent, + report:document.getElementById('report').classList.contains('on')}; }""") + check(bool(st['on']), 'item 8: loud empty2 owns the stage') + check('No more new bikes!' in st['txt'] and '08:30' in st['txt'], + 'item 8: exact title + next-import line') + check(not st['report'], 'item 8: report card masked on the dead catalog') + page.screenshot(path=os.path.join(SHOTS, 'item8-empty2.png')) + + # forge on the dead catalog: still empty2, no wasted Forge invitation + page.evaluate("() => document.getElementById('btnForge').click()") + flick(1500) + st = page.evaluate("""() => { const e=document.getElementById('empty2'); + return {on:e.classList.contains('on'), + report:document.getElementById('report').classList.contains('on')}; }""") + check(bool(st['on']) and not st['report'], + 'forge on 0/0/0 leaves empty2 (report never re-invites)') + + # nightly import simulated: a brand-new listing re-lights the deck + stop_server() + conn = sqlite3.connect(WALK3) + try: + lid = 990 + conn.execute("DELETE FROM listings WHERE id=?", (lid,)) + conn.execute( + "INSERT INTO listings (id, region, src_id, title, url, status," + " img_url, score, first_seen, desc) VALUES" + " (?, 'ns', 'v11night', 'Fresh overnight listing'," + " 'https://www.kijiji.ca/v-motorcycles/v11/990', 'active'," + " NULL, 50, '2026-11-13T08:30:00Z', 'arrived with the import')", + (lid,)) + conn.commit() + finally: + conn.close() + start_server(WALK3) + page.reload(wait_until='networkidle') + wait('.topcard', timeout=15000) + st = page.evaluate("""() => ({empty2:document.getElementById('empty2') + .classList.contains('on'), top:!!document.querySelector('.topcard')})""") + check(bool(st['top']) and not st['empty2'], + 'item 8: one fresh listing clears empty2 and relights the deck') + print('dead-catalog walk complete') + + +# --- WALK0: hand the rich DB a quick dry run through items 1 acceptance --- + +def main(): + global page + setup() # always: fresh staged DBs per run + # clear any stray scratch server left by an aborted run + import signal + for line in subprocess.run( + ['ps', '-eo', 'pid,args'], capture_output=True, + text=True).stdout.splitlines(): + if 'app.serve' in line and str(PORT) in line: + try: + os.kill(int(line.split()[0]), signal.SIGTERM) + except (ValueError, ProcessLookupError): + pass + time.sleep(0.5) + try: + from playwright.sync_api import sync_playwright + except ImportError: + print('playwright missing — run: .venv/bin/python -m app.fe_walk_v11') + return 1 + + with sync_playwright() as pw: + exe = os.path.expanduser( + '~/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome') + if not os.path.exists(exe): + exe = None + + # phase 1: rich + start_server(WALK1) + try: + browser = pw.chromium.launch(headless=True, executable_path=exe, + args=['--no-sandbox']) + ctx = browser.new_context(viewport={'width': 390, 'height': 844}, + has_touch=True, is_mobile=True) + page = ctx.new_page() + page.goto(BASE_URL + '/', wait_until='networkidle') + walk_rich() + ctx.close() + finally: + stop_server() + + # phase 2: returns-only (uses WALK2 fresh from setup) + start_server(WALK2) + try: + browser = pw.chromium.launch(headless=True, executable_path=exe, + args=['--no-sandbox']) + ctx = browser.new_context(viewport={'width': 390, 'height': 844}, + has_touch=True, is_mobile=True) + page = ctx.new_page() + page.goto(BASE_URL + '/', wait_until='networkidle') + walk_returns_only() + ctx.close() + finally: + stop_server() + + # phase 3: dead catalog + shutil.copy2(WALK2, WALK3) + burn_returns(WALK3) + start_server(WALK3) + try: + browser = pw.chromium.launch(headless=True, executable_path=exe, + args=['--no-sandbox']) + ctx = browser.new_context(viewport={'width': 390, 'height': 844}, + has_touch=True, is_mobile=True) + page = ctx.new_page() + page.goto(BASE_URL + '/', wait_until='networkidle') + walk_dead_catalog() + ctx.close() + finally: + stop_server() + + print('---') + print('%d/%d walk checks passed' % (CHECKS[0], CHECKS[0] + CHECKS[1])) + return 1 if CHECKS[1] else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/app/sections.py b/app/sections.py index a4f407a..02f5707 100644 --- a/app/sections.py +++ b/app/sections.py @@ -1,8 +1,12 @@ """Bike-Tinder P3 section engine — compose / read / exhaust / forge. -The deck is one finite SECTION at a time (default 25 cards): -2 wildcards + <=5 returns (<=2 nopes, "surprising" only) + remainder new, -per docs/RANKING.md + docs/DATA-MODEL.md "P3 additions". +v1.1 batch formula (amends the frozen "<=5 returns" rule — one-shot, +re-nope and one-return rules unchanged): every batch targets 25 cards = +fresh first (up to 23 slots) + >=2 second looks whenever eligible stock +exists + the 2 wildcard slots; when fresh < the remainder, second looks +FILL the batch up to 25 (family cap relaxes to 8 for fill returns only). +Fresh exhausted -> returns-only batches; returns exhausted -> fresh-only; +both -> an empty composed section (the loud empty state, item 8). Pinned order within a section (position 1 first): new (points DESC) -> returns (maybe > like > nope, points DESC within @@ -11,10 +15,12 @@ Pinned order within a section (position 1 first): wildcard stretches sit at the back. (Wildcard placement inside the deck is not pinned anywhere in the frozen docs — noted in the P3 report.) -Constraints: <=4 per non-NULL family per section; >=3 price bands when the -pool allows (soft repair swaps new-slot items within the final list). -A re-nope (round>=2 nope event) rests an item: no more second looks. -Rounds cap at 2 for influence (taste.py); returns are offered once. +Constraints: <=4 per non-NULL family per section (fill-return slots relax +to <=8); >=3 price bands when the pool allows (soft repair swaps new-slot +items within the final list). <=2 re-nope returns per batch (reserved +nope slots must be "surprising"). A re-nope (round>=2 nope event) rests +an item: no more second looks. Rounds cap at 2 for influence (taste.py); +returns are offered once. Modes: 'ondemand' (default) — forge runs the agent pass then composes; 'strict' — forge refuses (409) unless a taste_profile strictly newer than @@ -26,9 +32,11 @@ from . import taste as tastemod from .taste import NOPE FAMILY_CAP = 4 -MAX_RETURNS = 5 +RETURN_FILL_FAMILY_CAP = 8 MAX_RETURN_NOPES = 2 WILDCARDS = 2 +FRESH_CAP = 23 +MIN_RETURNS = 2 DEFAULT_SIZE = 25 MIN_BANDS = 3 @@ -262,6 +270,11 @@ def compose_section(conn, engine, size=DEFAULT_SIZE, profile_version=None, Caller closes/reports any prior section first. `engine` scores the candidates (a TasteEngine over active listings + events + profile). + + v1.1 slot math (see module docstring; this amends the frozen "<=5 + returns" rule): reserved returns (>=2 when eligible stock exists) -> + wildcards (2) -> fresh (capped at 23) -> fill returns to `size` + (family cap 4 preferred, 8 rescue; <=2 re-nopes total). """ import datetime now = now or datetime.datetime.now(datetime.timezone.utc).replace( @@ -297,18 +310,18 @@ def compose_section(conn, engine, size=DEFAULT_SIZE, profile_version=None, top_bands = {tastemod.price_band(r['price_num']) for r in top3 if tastemod.price_band(r['price_num']) is not None} - # 2) returns: maybe > like > nope, points DESC within role; nopes must be - # "surprising" (points >=55 or net-positive family); <=5, <=2 nopes. - # Budget: wildcards keep their 2 slots; returns never push the - # section past `size`. - wanted_rets = min(MAX_RETURNS, max(0, size - min(WILDCARDS, size))) + # 2) reserved returns: the >=2 guarantee (role order maybe > like > nope, + # points DESC within role). Nopes must be "surprising" for RESERVED + # slots only (points >=55 or net-positive family); <=2 re-nope + # returns per batch overall; family cap 4 applies here. role_order = {'maybe': 0, 'like': 1, 'nope': 2} + ret_ordered = sorted(ret_cands, reverse=True, + key=lambda r: (-role_order.get(r['verdict'], 9), + _points_key(engine, r))) rets = [] nope_count = 0 - for cand in sorted(ret_cands, reverse=True, - key=lambda r: (-role_order.get(r['verdict'], 9), - _points_key(engine, r))): - if len(rets) >= wanted_rets: + for cand in ret_ordered: + if len(rets) >= min(MIN_RETURNS, size): break fam = cand['family'] if fam and fam_count.get(fam, 0) >= FAMILY_CAP: @@ -325,7 +338,8 @@ def compose_section(conn, engine, size=DEFAULT_SIZE, profile_version=None, if fam: fam_count[fam] = fam_count.get(fam, 0) + 1 - # 3) wildcards: min(2, size), preferred different family AND price band + # 3) wildcards: min(2, remaining slots), preferred different family AND + # price band (existing novel/preferred pick, unchanged) liked_ids = [r['listing_id'] for r in conn.execute( "SELECT listing_id FROM swipes WHERE action='like'")] nov = novelty(rows_by_id, liked_ids, @@ -339,7 +353,7 @@ def compose_section(conn, engine, size=DEFAULT_SIZE, profile_version=None, and (tastemod.price_band(r['price_num']) is None or tastemod.price_band(r['price_num']) not in top_bands)] - n_wilds = min(WILDCARDS, size) + n_wilds = min(WILDCARDS, max(0, size - len(rets))) wilds = [] avail = sorted([r for r in wild_cands if r['id'] not in picked_ids], key=wild_key) @@ -355,12 +369,41 @@ def compose_section(conn, engine, size=DEFAULT_SIZE, profile_version=None, picked_ids.add(cand['id']) if fam: fam_count[fam] = fam_count.get(fam, 0) + 1 - n_wilds = len(wilds) # pool shortfall reverts to new + n_wilds = len(wilds) # pool shortfall reverts to fresh/fill - # 4) new fills the remainder of the section (size - returns - wildcards) - want_new = max(0, size - len(rets) - n_wilds) + # 4) fresh fills the remainder (size - returns - wildcards), never + # beyond FRESH_CAP. Wildcards draw from the fresh pool but do NOT + # count against the fresh-slot cap (25 - 0 - 2 = 23 new + 2 wild on + # a returns-free rich day; 21 new + 2 wild + 2 returns when both + # guarantees hold — the sign-off shape). Pool-limited by what the + # wildcard pick left in new_cands; a dry wild pool reverts slots. + want_new = min(size - len(rets) - len(wilds), FRESH_CAP, + max(0, len(new_cands) - len(wilds))) picked_new = pick_new(engine, new_cands, want_new, fam_count, picked_ids) + # 5) fill returns to size: role order; re-nope cap <=2 total (reserved + # included, no surprising gate here); two-pass family cap — prefer + # <=4, rescue pass relaxes to <=8 for FILL slots only (fresh picks + # and reserved returns keep 4). Deterministic: role order, points + # DESC within role. + for pass_cap in (FAMILY_CAP, RETURN_FILL_FAMILY_CAP): + for cand in ret_ordered: + if len(rets) >= size - len(picked_new) - n_wilds: + break + if cand['id'] in picked_ids: + continue + fam = cand['family'] + if fam and fam_count.get(fam, 0) >= pass_cap: + continue + if cand['verdict'] == NOPE and nope_count >= MAX_RETURN_NOPES: + continue + rets.append(cand) + picked_ids.add(cand['id']) + if cand['verdict'] == NOPE: + nope_count += 1 + if fam: + fam_count[fam] = fam_count.get(fam, 0) + 1 + # final pinned order: new -> returns -> wildcards ordered = list(picked_new) + rets + wilds @@ -425,16 +468,58 @@ def section_items(conn, section): ORDER BY si.position ASC""", (section['id'],)).fetchall() +def section_decided_ids(conn, section): + """Listing ids with a swipe event stamped in THIS section. + + The decided set for progress/report/exhaustion is always these ids — + never the lifetime `swipes.action` verdict (a second-look card enters + its section wearing an old decision; only an in-section event means it + was actually decided here). The join keeps out-of-section stray events + honest (same shape `section_counts` uses). + """ + return {r['listing_id'] for r in conn.execute( + """SELECT e.listing_id FROM swipe_events e + JOIN section_items si + ON si.section_id = e.section_id + AND si.listing_id = e.listing_id + WHERE e.section_id = ?""", (section['id'],))} + + +def pool_counts(conn): + """Stock answer to \"can the forge serve anything?\" — fresh / eligible + returns / wildcards candidate counts, each from the composer's own + candidate SQL. The per-section LAST_TWO exclusion inside compose_section + is deliberately NOT applied here: the answer is about stock, not about + what one batch will pick (item 8).""" + def _count(sql): + return conn.execute( + 'SELECT COUNT(*) AS n FROM (%s)' % sql).fetchone()['n'] + return {'fresh': _count(NEW_POOL_SQL), + 'returns': _count(RETURNS_SQL), + 'wildcards': _count(WILDCARD_SQL)} + + def section_counts(conn, section): """Per-action counts + second-look / wildcard engagement for this section (events stamped with this section_id only).""" counts = {'likes': 0, 'maybes': 0, 'nopes': 0} - for item in section_items(conn, section): - if item['decision'] == 'like': + # one verdict per listing: the latest in-section event wins (an undo + + # re-swipe within a section must not double-count the report numbers) + latest = {} + for ev in conn.execute( + """SELECT e.listing_id, e.action FROM swipe_events e + JOIN section_items si + ON si.section_id = e.section_id + AND si.listing_id = e.listing_id + WHERE e.section_id = ? + ORDER BY e.id ASC""", (section['id'],)): + latest[ev['listing_id']] = ev['action'] + for action in latest.values(): + if action == 'like': counts['likes'] += 1 - elif item['decision'] == 'maybe': + elif action == 'maybe': counts['maybes'] += 1 - elif item['decision'] == 'nope': + elif action == 'nope': counts['nopes'] += 1 restruck = conn.execute( """SELECT COUNT(DISTINCT e.listing_id) AS n FROM swipe_events e @@ -458,8 +543,9 @@ def maybe_exhaust(conn, section, now): """Flip an all-decided open section to 'report' (returns the fresh row).""" if section['status'] != 'open': return section + decided_ids = section_decided_ids(conn, section) items = section_items(conn, section) - if all(it['decision'] is not None for it in items): + if items and not [it for it in items if it['id'] not in decided_ids]: close_section(conn, section, now, status='report') return conn.execute('SELECT * FROM sections WHERE id=?', (section['id'],)).fetchone() @@ -470,7 +556,8 @@ def read_section(conn, section, now): """Meta + items + progress + exhausted for GET /api/section.""" section = maybe_exhaust(conn, section, now) items = section_items(conn, section) - decided = sum(1 for it in items if it['decision'] is not None) + decided_ids = section_decided_ids(conn, section) + decided = sum(1 for it in items if it['id'] in decided_ids) return { 'section': { 'id': section['id'], diff --git a/app/selfcheck.py b/app/selfcheck.py index 8d62382..70a3b4b 100644 --- a/app/selfcheck.py +++ b/app/selfcheck.py @@ -382,7 +382,17 @@ def test_api_stats_health(): 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 set(s) == {'live', 'fresh', 'new', 'likes', 'maybes', + 'nopes', 'last_import'} # v1.1 item 7: + fresh + # fresh = actives with no swipe row (the deck's new-pool predicate) + conn = sqlite3.connect(os.path.join(d, 'test.sqlite3')) + expect_fresh = conn.execute( + "SELECT COUNT(*) AS n FROM listings l" + " WHERE l.status='active'" + " AND NOT EXISTS (SELECT 1 FROM swipes s" + " WHERE s.listing_id = l.id)").fetchone()[0] + conn.close() + assert s['fresh'] == expect_fresh, (s['fresh'], expect_fresh) assert s['last_import'] is not None and s['last_import']['ok'] is True assert s['new'] == expect_new, (s['new'], expect_new) h = c.get('/api/health').json() @@ -739,7 +749,9 @@ def test_p3_exhaust_and_report(): c.post('/api/prefs', json={'key': 'forge_mode', 'value': 'ondemand'}) f = c.post('/api/section/forge', json={'size': 6}).json() sid = f['section']['id'] - for it in [i for i in f['items'] if i['decision'] is None]: + # v1.1 (item 5): EVERY item needs an in-section strike to complete + # the section — returns re-enter the deck and are re-struck too. + for it in f['items']: r = c.post('/api/swipe', json={'listing_id': it['id'], 'action': 'like', 'section_id': sid}) assert r.status_code == 200 @@ -1279,6 +1291,456 @@ def test_b1_swipe_photo_idx(): shutil.rmtree(d, ignore_errors=True) +# --- v1.1 checks (temp copies of the source DB only — see p3_env) ---------- + + +def test_v11_item5_section_counter(): + """Returns-only section must read 0/N decided until real in-section + events exist; report counts and exhaustion key off those events.""" + d, c, db_path = p3_env() + try: + conn = _sq_conn(db_path) + conn.execute('DELETE FROM section_items') + conn.execute("UPDATE sections SET status='closed'" + " WHERE status!='closed'") + conn.execute( + """INSERT INTO swipes (listing_id, action, created_at) + SELECT id, 'maybe', '2026-11-01T00:00:00Z' FROM listings + WHERE status='active' + AND id NOT IN (SELECT listing_id FROM swipes)""") + conn.execute( + """INSERT INTO swipe_events (listing_id, action, round, section_id) + SELECT id, 'maybe', 1, NULL FROM listings + WHERE status='active' + AND id NOT IN (SELECT listing_id FROM swipe_events)""") + conn.commit() + conn.close() + s = c.get('/api/section').json() # cold compose + total = s['progress']['total'] + assert total > 0, s + assert s['recipe']['new'] == 0, s['recipe'] + # every item wears a LIFETIME verdict but zero in-section decisions + for it in s['items']: + assert it['decision'] is not None, it['id'] + assert it['decided_this_section'] is False, it['id'] + assert s['progress']['decided'] == 0, s['progress'] + sec_id = s['section']['id'] + # list edit (no section_id) must not count as an in-section decision + lid0 = s['items'][0]['id'] + c.post('/api/swipe', json={'listing_id': lid0, 'action': 'like'}) + assert c.get('/api/section').json()['progress']['decided'] == 0 + # first real in-section swipe -> 1/total + c.post('/api/swipe', json={'listing_id': lid0, 'action': 'like', + 'section_id': sec_id}) + s2 = c.get('/api/section').json() + assert s2['progress']['decided'] == 1, s2['progress'] + # second -> 2/total; report counts reflect in-section verdicts only + lid1 = s2['items'][1]['id'] + c.post('/api/swipe', json={'listing_id': lid1, 'action': 'maybe', + 'section_id': sec_id}) + s3 = c.get('/api/section').json() + assert s3['progress']['decided'] == 2, s3['progress'] + assert s3['counts']['likes'] == 1, s3['counts'] + assert s3['counts']['maybes'] == 1, s3['counts'] + # undo residual (Q5, accepted): DELETE clears the swipe row but the + # section-stamped event stays -> count holds (clamp-only behavior) + c.delete('/api/swipe/%d' % lid1) + s4 = c.get('/api/section').json() + assert s4['progress']['decided'] == 2, s4['progress'] + c.post('/api/swipe', json={'listing_id': lid1, 'action': 'maybe', + 'section_id': sec_id}) + # strike the rest in-section -> report flips, counts match verdicts + for it in s4['items']: + if it['id'] not in (lid0, lid1): + c.post('/api/swipe', json={'listing_id': it['id'], + 'action': 'maybe', + 'section_id': sec_id}) + s5 = c.get('/api/section').json() + assert s5['exhausted'] is True + assert s5['section']['status'] == 'report' + assert s5['progress']['decided'] == total, s5['progress'] + assert s5['counts']['likes'] == 1, s5['counts'] + assert s5['counts']['maybes'] == total - 1, s5['counts'] + # a fresh returns-only section of the same shape composes cleanly + # (lifetime verdicts present, in-section decided == 0 again) + f = c.post('/api/section/forge', json={}).json() + assert f['recipe']['new'] == 0, f['recipe'] + assert f['progress']['decided'] == 0, f['progress'] + for it in f['items']: + assert it['decision'] is not None and not it['decided_this_section'] + finally: + shutil.rmtree(d, ignore_errors=True) + + +def test_v11_item7_stats_fresh(): + """Item 7: /api/stats.fresh counts actives with no swipe row and moves + on swipe (the deck's new-pool predicate).""" + d, c, db_path = p3_env() + try: + conn = _sq_conn(db_path) + rows = conn.execute( + "SELECT id FROM listings WHERE status='active' LIMIT 5" + ).fetchall() + assert len(rows) == 5 + ids = [r['id'] for r in rows] + conn.execute("UPDATE listings SET status='gone' WHERE status='active'") + conn.execute("DELETE FROM swipes WHERE listing_id IN (%s)" + % ','.join('?' * len(ids)), ids) + conn.execute("DELETE FROM swipe_events WHERE listing_id IN (%s)" + % ','.join('?' * len(ids)), ids) + for lid in ids: + conn.execute("UPDATE listings SET status='active' WHERE id=?", (lid,)) + for lid in ids[:3]: + conn.execute( + "INSERT INTO swipes (listing_id, action, created_at)" + " VALUES (?, 'maybe', '2026-11-01T00:00:00Z')", (lid,)) + conn.commit() + conn.close() + st = c.get('/api/stats').json() + assert st['live'] == 5, st + assert st['fresh'] == 2, st # 2 of 5 still never-touched + # swiping one of the untouched pair moves fresh to 1 on next read + c.post('/api/swipe', json={'listing_id': ids[3], 'action': 'like'}) + assert c.get('/api/stats').json()['fresh'] == 1 + finally: + shutil.rmtree(d, ignore_errors=True) + + +def test_v11_item8_pool_and_dry(): + """Item 8/6: pool counts ride section_state; 0/0/0 composes an empty + section; a fresh=0, returns>0 catalog labels the forge note dry.""" + d, c, db_path = p3_env() + try: + conn = _sq_conn(db_path) + conn.execute('DELETE FROM section_items') + conn.execute("UPDATE sections SET status='closed'" + " WHERE status!='closed'") + # every active gets a maybe swipe + round-1 event (returns alive, + # fresh + wildcards drained) + conn.execute( + """INSERT INTO swipes (listing_id, action, created_at) + SELECT id, 'maybe', '2026-11-01T00:00:00Z' FROM listings + WHERE status='active' + AND id NOT IN (SELECT listing_id FROM swipes)""") + conn.execute( + """INSERT INTO swipe_events (listing_id, action, round, section_id) + SELECT id, 'maybe', 1, NULL FROM listings + WHERE status='active' + AND id NOT IN (SELECT listing_id FROM swipe_events)""") + conn.commit() + conn.close() + s = c.get('/api/section').json() # dry but alive + assert s['pool']['fresh'] == 0 and s['pool']['returns'] > 0, s['pool'] + assert s['recipe']['new'] == 0 and len(s['items']) > 0 + f = c.post('/api/section/forge', json={}).json() + assert f['recipe']['new'] == 0 and len(f['items']) > 0 + assert 'catalog fully reviewed' in (f.get('note') or ''), f.get('note') + # drain returns too (one-shot) + wildcards: pool 0/0/0 -> empty compose + conn = _sq_conn(db_path) + conn.execute("DELETE FROM section_items") + conn.execute("UPDATE sections SET status='closed'" + " WHERE status!='closed'") + # burn return eligibility: every active already had its return slot + # -> re-insert one-shot return rows for all of them + conn.execute( + """INSERT INTO section_items (section_id, listing_id, slot, position) + SELECT (SELECT id FROM sections ORDER BY id DESC LIMIT 1), + listing_id, 'return', 1 FROM swipes + WHERE listing_id IN (SELECT id FROM listings + WHERE status='active')""") + conn.commit() + conn.close() + s2 = c.get('/api/section').json() + assert s2['pool'] == {'fresh': 0, 'returns': 0, 'wildcards': 0}, s2['pool'] + assert s2['items'] == [] + assert s2['recipe']['new'] == 0 and s2['recipe']['return'] == 0 + assert s2['recipe']['wildcard'] == 0, s2['recipe'] + finally: + shutil.rmtree(d, ignore_errors=True) + + +# --- item 9: batch formula v2 compose matrix (A1-A8, staged pools) -------- + + +def _v11_wipe(conn): + """Reset a temp copy to a controlled catalog: no sections/swipes/events, + everything parked 'gone' (rows kept so ids/families can be re-used).""" + conn.execute('DELETE FROM section_items') + conn.execute('DELETE FROM sections') + conn.execute('DELETE FROM swipes') + conn.execute('DELETE FROM swipe_events') + conn.execute("UPDATE listings SET status='gone' WHERE status!='dup'") + + +def _v11_ids(conn, n, start=0): + return [r['id'] for r in conn.execute( + 'SELECT id FROM listings ORDER BY id LIMIT ? OFFSET ?', + (n, start))] + + +def _v11_activate(conn, ids, family=None, score=None): + for lid in ids: + if score is None: + conn.execute("UPDATE listings SET status='active', family=?" + " WHERE id=?", (family, lid)) + else: + conn.execute("UPDATE listings SET status='active', family=?," + " score=? WHERE id=?", (family, score, lid)) + + +def _v11_stage_returns(conn, ids, action='maybe', rests=()): + """swipes row + round-1 event per id; rests get a round-2 nope event + (the REST rule bars them from returns forever).""" + rests = set(rests) + for lid in ids: + conn.execute("INSERT INTO swipes (listing_id, action, created_at)" + " VALUES (?, ?, '2026-11-01T00:00:00Z')", + (lid, action)) + conn.execute("INSERT INTO swipe_events (listing_id, action, round," + " section_id) VALUES (?, ?, 1, NULL)", (lid, action)) + if lid in rests: + conn.execute("INSERT INTO swipe_events (listing_id, action," + " round, section_id) VALUES (?, 'nope', 2, NULL)", + (lid,)) + + +def _v11_stage_fresh(conn, ids, seen=False): + """No swipes row (=fresh). seen=True drops wildcard eligibility via a + round-1 event (fresh pool and wildcard pool are otherwise the same).""" + for lid in ids: + if seen: + conn.execute("INSERT INTO swipe_events (listing_id, action," + " round, section_id) VALUES (?, 'maybe', 1, NULL)", + (lid,)) + + +def _v11_stage_support(conn, ids, family): + """Gone-status listings whose events tilt a family's net (nope-gate).""" + for lid in ids: + conn.execute("UPDATE listings SET family=? WHERE id=?", (family, lid)) + conn.execute("INSERT INTO swipe_events (listing_id, action, round," + " section_id) VALUES (?, 'like', 1, NULL)", (lid,)) + + +def _v11_compose(db_path, size=25): + from . import sections as secmod + conn = db.connect(db_path) + db.init_db(conn) + engine = secmod.build_engine( + conn, now=datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None)) + sec = secmod.compose_section(conn, engine, size=size) + recipe = json.loads(sec['recipe']) + items = [dict(r) for r in secmod.section_items(conn, sec)] + conn.commit() + conn.close() + return recipe, items, engine + + +def _slots(items): + out = {'new': [], 'return': [], 'wildcard': []} + for it in items: + out[it['slot']].append(it) + return out + + +def test_v11_item9_compose_matrix(): + """A1-A8 of the item 9 acceptance criteria on deterministic pools.""" + d, _c, db_path = p3_env() + + def env(): + conn = _sq_conn(db_path) + _v11_wipe(conn) + conn.commit() + return conn + + try: + # --- A1: 30 fresh / 10 returns / wildcards -> 25: 21/2/2 ---------- + conn = env() + ids = _v11_ids(conn, 60) + _v11_activate(conn, ids[0:28]) # 28 fresh + _v11_stage_fresh(conn, ids[0:28]) # (all wild-eligible) + _v11_activate(conn, ids[28:38]) # 10 returns + _v11_stage_returns(conn, ids[28:38]) + conn.commit(); conn.close() + recipe, items, engine = _v11_compose(db_path) + slots = _slots(items) + assert len(items) == 25, len(items) + assert (recipe['new'], recipe['return'], recipe['wildcard']) \ + == (21, 2, 2), recipe + assert (len(slots['new']), len(slots['return']), + len(slots['wildcard'])) == (21, 2, 2) + # pinned order: new -> return -> wildcard + roles = [it['slot'] for it in items] + assert roles == ['new'] * 21 + ['return'] * 2 + ['wildcard'] * 2, \ + roles[:2] + # fresh picks ordered points DESC (non-increasing) + pts = [engine.points(it['id']) for it in slots['new']] + assert all(a >= b for a, b in zip(pts, pts[1:])), pts + + # --- A2a: 5 fresh / 20 returns / wildcards -> 5/18/2 --------------- + conn = env() + ids = _v11_ids(conn, 60) + _v11_activate(conn, ids[0:3]) + _v11_stage_fresh(conn, ids[0:3]) # 3 fresh-only + _v11_activate(conn, ids[3:5]) + _v11_stage_fresh(conn, ids[3:5]) # 2 fresh+wild + _v11_activate(conn, ids[5:25]) # 20 returns + _v11_stage_returns(conn, ids[5:25]) + conn.commit(); conn.close() + recipe, items, _e = _v11_compose(db_path) + assert len(items) == 25, len(items) + # Wildcards are BY DEFINITION never-swiped (wild pool ⊆ fresh pool), + # so on a 5-fresh day the 2 wilds come from those 5: the deck reads + # 5 fresh-total (3 new + 2 wild) + 20 second looks = 25 — Glenn's + # "5 fresh listed, rest second looks". The spec's "5 fresh + 18 + # returns + 2 wild" arithmetic assumed 7 fresh-eligible listings; + # with 5 the pool-honest fill is 20 (implementer-verified). + assert (recipe['new'], recipe['return'], recipe['wildcard']) \ + == (3, 20, 2), recipe + + # --- A2b: same but the wildcard pool is dry -> 5/20/0 -------------- + conn = env() + ids = _v11_ids(conn, 60) + _v11_activate(conn, ids[0:5]) + _v11_stage_fresh(conn, ids[0:5], seen=True) # fresh, no wilds + _v11_activate(conn, ids[5:25]) # 20 returns + _v11_stage_returns(conn, ids[5:25]) # + conn.commit(); conn.close() + recipe, items, _e = _v11_compose(db_path) + assert len(items) == 25, len(items) + assert (recipe['new'], recipe['return'], recipe['wildcard']) \ + == (5, 20, 0), recipe + + # --- A3: 23 fresh / 5 returns / wildcards -> 21/2/2 (the sign-off + # --- sentence shape) ---------------------------------------------- + conn = env() + ids = _v11_ids(conn, 60) + _v11_activate(conn, ids[0:23]) + _v11_stage_fresh(conn, ids[0:23]) + _v11_activate(conn, ids[23:28]) # 5 returns + _v11_stage_returns(conn, ids[23:28]) + conn.commit(); conn.close() + recipe, items, _e = _v11_compose(db_path) + slots = _slots(items) + assert len(items) == 25 and len(slots['new']) == 21 \ + and len(slots['return']) == 2 \ + and len(slots['wildcard']) == 2, recipe + + # --- A4: 0 fresh / 12 returns (2 nope) -> 12 returns, <=2 nopes, + # --- LAST_TWO excl: second compose repeats nothing ----------------- + conn = env() + ids = _v11_ids(conn, 60) + _v11_activate(conn, ids[0:12]) + _v11_stage_returns(conn, ids[0:10], 'maybe') + _v11_stage_returns(conn, ids[10:12], 'nope') # 2 re-nope cands + conn.commit(); conn.close() + recipe, items, _e = _v11_compose(db_path) + slots = _slots(items) + assert len(items) == 12 and len(slots['new']) == 0 \ + and len(slots['return']) == 12 \ + and len(slots['wildcard']) == 0, recipe + n_nopes = sum(1 for it in slots['return'] + if it['decision'] == 'nope') + assert n_nopes <= 2, n_nopes + first_ids = {it['id'] for it in items} + # second compose on the same pool: LAST_TWO bars the 12 just served + # and returns stay one-shot -> nothing to serve + recipe2, items2, _e = _v11_compose(db_path) + assert len(items2) == 0, items2 + assert not ({it['id'] for it in items2} & first_ids) + + # --- A5: 40 fresh / 0 returns -> 25 then the 15 leftover, no + # --- overlap, fresh ordered points DESC ---------------------------- + conn = env() + ids = _v11_ids(conn, 60) + _v11_activate(conn, ids[0:40]) + _v11_stage_fresh(conn, ids[0:40]) + # spread scores so the points DESC order check is meaningful + for k, lid in enumerate(ids[0:40]): + conn.execute("UPDATE listings SET score=? WHERE id=?", + (40 - k, lid)) + conn.commit(); conn.close() + recipe, items, engine = _v11_compose(db_path) + assert len(items) == 25, len(items) + assert recipe['return'] == 0, recipe + first_ids = {it['id'] for it in items} + slots1 = _slots(items) + pts = [engine.points(it['id']) for it in slots1['new']] + # scores unique DESC -> fresh-slot points non-increasing (wildcards + # sit pinned at the back and don't participate in that order) + assert pts == sorted(pts, reverse=True), pts + recipe2, items2, _e = _v11_compose(db_path) + slots2 = _slots(items2) + assert recipe2['return'] == 0, recipe2 + assert len(items2) == 15, len(items2) + second_ids = {it['id'] for it in items2} + assert not (first_ids & second_ids) # zero overlap + + # --- A6: 0/0/0 -> empty composed section (FE side = item 8) -------- + conn = env() + conn.commit(); conn.close() + recipe, items, _e = _v11_compose(db_path) + assert items == [], items + assert (recipe['new'], recipe['return'], recipe['wildcard']) \ + == (0, 0, 0), recipe + + # --- A7: family cap — fill-heavy cruiser pool: fresh/reserved hold + # --- <=4, fill raises return slots to <=8 -------------------------- + conn = env() + ids = _v11_ids(conn, 60) + _v11_activate(conn, ids[0:40], family='cruiser') + _v11_stage_fresh(conn, ids[0:10], seen=True) # 10 fresh cruiser + _v11_stage_returns(conn, ids[10:40], 'maybe') # 30 cruiser rets + conn.commit(); conn.close() + recipe, items, _e = _v11_compose(db_path) + slots = _slots(items) + fam = {} + for it in items: + fam[it['family']] = fam.get(it['family'], 0) + 1 + assert fam.get('cruiser', 0) <= 8, fam + assert len(slots['new']) == 2, recipe # fresh stops at cap 4 + assert len(slots['return']) == 6, recipe # 2 reserved + 4 fill + assert (recipe['new'], recipe['return']) == (2, 6), recipe + + # --- A8: re-nope gate — <=2 nope returns total; a rested item never + # --- returns; gate can empty the reserved slots -------------------- + conn = env() + ids = _v11_ids(conn, 60) + _v11_activate(conn, ids[0:10]) + _v11_stage_returns(conn, ids[0:10], 'maybe') # 10 maybes + _v11_activate(conn, ids[10:15], family='gatefam', score=100) + _v11_stage_returns(conn, ids[10:15], 'nope') # 5 nope re-nopes + # family net positive: gate passes for gatefam's nope candidates + _v11_stage_support(conn, ids[30:40], family='gatefam') # 10 likes + _v11_activate(conn, ids[15:16], family='gatefam') + _v11_stage_returns(conn, ids[15:16], 'maybe', + rests=[ids[15]]) # rested: r2 nope + conn.commit(); conn.close() + recipe, items, _e = _v11_compose(db_path) + slots = _slots(items) + assert len(items) == 12, len(items) # 10 maybe + 2 nope + n_nopes = sum(1 for it in slots['return'] + if it['decision'] == 'nope') + assert n_nopes == 2, n_nopes + assert all(it['id'] != ids[15] for it in slots['return']) # rested + + # gate-block: low-score nope family, net NOT positive -> reserved + # slots skipped; fill still caps at 2 nopes (gate-free fill rule) + conn = env() + ids = _v11_ids(conn, 60) + _v11_activate(conn, ids[0:3], family='blockfam', score=1) + _v11_stage_returns(conn, ids[0:3], 'nope') # 3 gate-blocked + conn.commit(); conn.close() + recipe, items, _e = _v11_compose(db_path) + slots = _slots(items) + assert recipe['return'] == 2, recipe # fill cap only + assert all(it['decision'] == 'nope' + for it in slots['return']), items + finally: + shutil.rmtree(d, ignore_errors=True) + + P3_API_TESTS = [ ('p3: events seeding idempotent (from swipes)', test_p3_event_seeding_idempotent), ('p3: swipe round math + clear-then-re-decide', test_p3_round_math_and_clear), @@ -1295,6 +1757,20 @@ P3_API_TESTS = [ if API_DEPS: TESTS += P3_API_TESTS +V11_TESTS = [ + ('v1.1: item 5 — section counter keys off in-section events', + test_v11_item5_section_counter), + ('v1.1: item 7 — stats fresh = active minus swiped', + test_v11_item7_stats_fresh), + ('v1.1: item 8/6 — pool counts, 0/0/0 empty compose, dry forge note', + test_v11_item8_pool_and_dry), + ('v1.1: item 9 — batch formula v2 compose matrix A1-A8', + test_v11_item9_compose_matrix), +] + +if API_DEPS: + TESTS += V11_TESTS + B1_TESTS = [ ('b1: guarded migrations (imgs + last_photo_idx, idempotent)', test_b1_migration_guarded), diff --git a/app/static/index.html b/app/static/index.html index 48f3eb0..75e71ae 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -33,7 +33,8 @@ header{height:calc(50px + env(safe-area-inset-top,0px));display:flex;align-items .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 .photo{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;filter:drop-shadow(0 6px 24px rgba(0,0,0,.45))} +.phblur{position:absolute;inset:-18px;background-size:cover;background-position:center;background-repeat:no-repeat;filter:blur(22px) brightness(.55) saturate(1.1);transform:scale(1.08);pointer-events:none} .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} @@ -66,8 +67,9 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items .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} +.dhero{position:relative;height:34%;min-height:190px;flex:none;background:#17171c;touch-action:none;overflow:hidden} +.dblur{position:absolute;inset:-18px;background-size:cover;background-position:center;background-repeat:no-repeat;filter:blur(22px) brightness(.55) saturate(1.1);transform:scale(1.08);pointer-events:none} +.dphoto{width:100%;height:100%;object-fit:contain;display:block;filter:drop-shadow(0 6px 24px rgba(0,0,0,.45))} .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} @@ -95,6 +97,10 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items .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} +/* item 8: loud exhausted state — section mode only (legacy .empty stays) */ +.empty2{position:absolute;inset:6px 14px 2px;display:none;flex-direction:column;align-items:center;justify-content:center;gap:10px;border:1px dashed #4a4a55;border-radius:18px;background:#141419;color:var(--mut);text-align:center;padding:32px;font-size:14px;min-height:220px;z-index:3} +.empty2.on{display:flex} +.empty2 b{color:#fff;font-size:22px;font-weight:800} /* 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} @@ -138,7 +144,8 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items .sect.hot{color:var(--accent1)} .band{position:absolute;top:12px;left:12px;z-index:4;max-width:78%;font-size:11px;font-weight:650;letter-spacing:.4px;border-radius:999px;padding:4px 9px;pointer-events:none;line-height:1.35;user-select:none} .band.wild{color:#ffb43a;background:rgba(255,180,58,.14);border:1px solid rgba(255,180,58,.35)} -.band.sl{color:rgba(255,255,255,.85);background:rgba(255,255,255,.10);border:1px solid rgba(255,255,255,.22)} +.band.sl{color:#c4b5fd;background:rgba(124,58,237,.22);border:1px solid rgba(167,139,250,.65);font-weight:800} +.dband{position:static;display:inline-block;margin-top:6px} .report{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;z-index:3} .report.on{display:flex} .report b{color:#fff;font-size:16px} @@ -210,6 +217,7 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items
You're all caught up.New bikes arrive nightly — next import 08:30.
↺ brings the last card back.
+
No more new bikes!New bikes arrive nightly — next import 08:30.
↺ brings the last card back.
Section complete.
@@ -217,6 +225,7 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items
+
on demand⇄strict @@ -242,11 +251,12 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items
-
+
+
@@ -479,14 +490,19 @@ function setStamps(el,a,b,c){ el.querySelector('.stamp.nope').style.opacity=b; el.querySelector('.stamp.maybe').style.opacity=c; } -function bandHTML(item){ // P3 system bands — the ONLY card-face additions - if(item.slot==='wildcard') return `
WILDCARD · maybe you’ll like this one instead
`; +function bandLabel(item){ // P3 system bands — text for card + dossier + if(item.slot==='wildcard') return 'WILDCARD · maybe you’ll like this one instead'; if(item.slot==='return'){ const verb= item.decision==='like'?'liked':item.decision==='maybe'?'maybed':item.decision==='nope'?'noped':'seen'; - return `
SECOND LOOK — you ${verb} this
`; + return 'SECOND LOOK — you '+verb+' this'; } return ''; } +function bandHTML(item){ // card face only + const lbl=bandLabel(item); + if(!lbl) return ''; + return `
${lbl}
`; +} /* ---------- B1 §11: photo galleries ---------- */ function nPhotos(it){ // payload n_photos; tolerant of old APIs const n= it&&typeof it.n_photos==='number'?it.n_photos:(it&&it.img?1:0); @@ -509,8 +525,10 @@ function segHTML(n,cur){ if(!n||n<2) return ''; return `
${segBars(n,cur)}
`; } +function escAttr(s){ return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,'''); } function phwrapHTML(src){ - return `
${src?``:`
Photo pending — thumbnail backfill queued
`}
`; + const img=src?`
`:`
Photo pending — thumbnail backfill queued
`; + return `
${img}
`; } function attachPhError(entry){ const wrap=entry.el.querySelector('.phwrap'); @@ -646,6 +664,12 @@ async function loadSection(){ finally{ deckLoading=false; } maybeRefill(); } +/* item 8: the deck can serve nothing at all (pool all-zero) -> loud empty */ +function poolDead(){ + return !!(section && section.pool + && section.pool.fresh===0 && section.pool.returns===0 + && section.pool.wildcards===0); +} function maybeRefill(){ if(legacyMode){ if(buffer.length=sectTotal){ + if(poolDead()){ + // nothing to serve at all: the loud empty owns the stage (no report) + $('empty2').classList.add('on'); + $('report').classList.remove('on'); + $('empty').classList.remove('on'); + }else if(section&§Decided>=sectTotal){ // locally exhausted (last POSTs may still be flushing — the report // shows from localDecisions; a quiet resync freshens counts and the // server's report flip, rate-limited so a failing last POST can't // cause a resync loop) $('report').classList.add('on'); $('empty').classList.remove('on'); + $('empty2').classList.remove('on'); if(!section.exhausted&&Date.now()-lastResyncAt>2000){ lastResyncAt=Date.now(); loadSection(); } }else if(section){ $('empty').classList.remove('on'); $('report').classList.remove('on'); + $('empty2').classList.remove('on'); } }else{ $('empty').classList.remove('on'); $('report').classList.remove('on'); + $('empty2').classList.remove('on'); } } @@ -681,7 +713,7 @@ function commit(el,dir){ const act= dir==='maybe'?'maybe':dir; setDecision(t.d.id, act, (!legacyMode&§ion&&!section.exhausted)?section.section.id:null, nPhotos(t.d)>1?(t.cur||0):null); // B1: photo on screen at strike - sectDecided++; + sectDecided=Math.min(sectTotal, sectDecided+1); // clamp: never past the total paintSect(); flyOut(el,dir); fillPile(); relayer(); @@ -717,6 +749,7 @@ function undo(){ preloadPhotos(); $('empty').classList.remove('on'); $('report').classList.remove('on'); + $('empty2').classList.remove('on'); } $('btnLike').onclick=()=>{ if(pile[0]) commit(pile[0].el,'like'); }; $('btnNope').onclick=()=>{ if(pile[0]) commit(pile[0].el,'nope'); }; @@ -751,31 +784,46 @@ function upH(e){ else if(dy<-140) commit(el,'maybe'); else { el.style.transform=''; setStamps(el,0,0,0); if(!moved && e.type==='pointerup' && isTop(el)) tapZone(pile[0],e); } } -/* §11.2/§11.3/§11.4 tap zones: sides flip (clamped), center = fullscreen. - The old tap-anywhere -> dossier gesture is retired (ⓘ stays the door). */ +/* §11.4 v1.1 tap zones: sides flip (clamped), CENTER = dossier (bike data + first — the old center-tap→fullscreen and tap-anywhere→dossier gestures + are retired). The deck dossier remembers its pile entry so a viewer + opened from its hero walks back to it on Esc. */ function tapZone(entry,e){ - const n=nPhotos(entry.d); - if(n<1 || !entry.d.img) return; const r=stage.getBoundingClientRect(); const rel=e.clientX-r.left, w=r.width||360; - if(rel>=w/3 && rel<=w*2/3) openViewer(entry.d, entry.cur||0, {type:'card',entry}); - else flipCard(entry, rel=w/3 && rel<=w*2/3){ + dEntry=entry; // live pile entry (cur photo index) + openDetail(entry.d,{type:'deck'}); + }else{ + flipCard(entry, rel viewer) +let dEntry=null; // v1.1: pile entry when opened from the deck function openDetail(data,ctx){ dCtx=ctx||null; dData=data; + dEntry=null; // only tapZone re-sets it (deck path) 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'); }; + const blur=$('dblur'); + if(data.img){ ph.src=data.img; ph.classList.remove('ph'); blur.style.backgroundImage='url("'+data.img+'")'; } + else { ph.removeAttribute('src'); ph.classList.add('ph'); blur.style.backgroundImage='none'; } + ph.onerror=()=>{ ph.removeAttribute('src'); ph.classList.add('ph'); blur.style.backgroundImage='none'; }; $('dtitle').textContent=data.title||''; $('dprice').textContent=priceFor(data); $('dsub').textContent=subFor(data); $('dchips').innerHTML=cardChips(data).map(x=>`${x}`).join(''); + // item 10: the card band mirrors into the info view (violet SL / amber wild) + const db=$('dband'); + const bl=bandLabel(data); + if(bl){ + db.className='band'+(data.slot==='wildcard'?' wild':' sl')+' dband'; + db.textContent=bl; + db.style.display='inline-block'; + }else{ db.style.display='none'; } const rows=[ ["Year",data.year!=null?String(data.year):null], ["Odometer",data.km!=null?(fmtNum(data.km)+' km'):null], @@ -794,7 +842,9 @@ function openDetail(data,ctx){ $('detail').classList.add('on'); $('dback').classList.add('on'); $('dbody').scrollTop=0; } -/* P3 dossier rank block: gauge + points + strongest ± lines + audit footer */ +/* P3 dossier rank block: gauge + points% minimal; the full "why" breakdown + and the audit footer render only when prefs.rubric_debug === 'on' (§10.5). */ +function rubricOn(){ return !!(section&§ion.prefs&§ion.prefs.rubric_debug==='on'); } function fillRank(data){ if(!data||data.tank==null||data.tank.value==null){ $('drank').style.display='none'; @@ -802,24 +852,30 @@ function fillRank(data){ } $('drank').style.display='flex'; $('drankTank').innerHTML=tankSVG(data.tank,true); - $('dpts').textContent=data.tank.value+'/100'; - const lines=(data.points_breakdown||[]).slice() - .sort((a,b)=>Math.abs(b.value)-Math.abs(a.value)).slice(0,4); - $('dlines').innerHTML=lines.map(l=>{ - const lbl= l.source==='agent'?('agent: '+l.label):l.label; - const n= l.value>=0?(' +'+l.value):(' '+l.value); - return `
· ${lbl}${n}
`; - }).join(''); - const ri= section&§ion.read_info; - $('dfoot').textContent= ri?`from ${ri.events} strikes · read ${relTime(ri.profile_at)}`:''; - // list/matches payloads carry no breakdown — fill it on the quiet - if(!data.points_breakdown&&data.id&&!legacyMode){ - getJSON('/api/listing/'+data.id).then(full=>{ - if(full&&full.points_breakdown&&dCtx) fillRank(full); - }); + $('dpts').textContent=(data.tank.value|0)+'%'; + if(rubricOn()){ + const lines=(data.points_breakdown||[]).slice() + .sort((a,b)=>Math.abs(b.value)-Math.abs(a.value)).slice(0,4); + $('dlines').innerHTML=lines.map(l=>{ + const lbl= l.source==='agent'?('agent: '+l.label):l.label; + const n= l.value>=0?(' +'+l.value):(' '+l.value); + return `
· ${lbl}${n}
`; + }).join(''); + const ri= section&§ion.read_info; + $('dfoot').textContent= ri?`from ${ri.events} strikes · read ${relTime(ri.profile_at)}`:''; + // list/matches payloads carry no breakdown — fill it on the quiet + if(!data.points_breakdown&&data.id&&!legacyMode){ + getJSON('/api/listing/'+data.id).then(full=>{ + if(full&&full.points_breakdown&&dCtx) fillRank(full); + }); + } + }else{ + // minimal display: gauge + % only; skip the quiet enrichment fetch + $('dlines').innerHTML=''; + $('dfoot').textContent=''; } } -function closeDetail(){ const d=$('detail'); d.style.transition=''; d.style.transform=''; d.classList.remove('on'); $('dback').classList.remove('on'); dCtx=null; } +function closeDetail(){ const d=$('detail'); d.style.transition=''; d.style.transform=''; d.classList.remove('on'); $('dback').classList.remove('on'); dCtx=null; dEntry=null; } $('dclose').onclick=closeDetail; $('dclose').addEventListener('pointerdown',e=>e.stopPropagation()); /* pull-down-to-close (grabber bar + photo) */ @@ -829,10 +885,12 @@ function dMoveH(e){ if(dDragY===null) return; const dy=Math.max(0,e.clientY-dDra 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=''; - /* B1 §11.3: a tap on the dossier HERO opens the fullscreen viewer at photo 0 */ + /* B1 §11.3: a tap on the dossier HERO opens the fullscreen viewer at + photo 0. When the dossier sits over the deck, the viewer back-chain + walks Esc back to the dossier (item 2), then to the deck. */ if(e.type==='pointerup' && dy<=6 && e.currentTarget&&e.currentTarget.classList && e.currentTarget.classList.contains('dhero') && dData&&dData.img){ - openViewer(dData,0,{type:'dossier'}); + openViewer(dData,0,{type:'dossier', backToDetail:dEntry}); } } } [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){ @@ -881,6 +939,19 @@ function closeViewer(){ v.style.transition=''; v.style.transform=''; vCtx=null; vPtrs.clear(); vPinch=null; vDrag=null; } +/* v1.1 walk-back chain: when the viewer was reached from the deck dossier + hero, Esc returns to the dossier (photo 0) instead of the deck. */ +function vWalkBack(){ + const back=vCtx&&vCtx.back; + if(back&&back.backToDetail){ + const item=vCtx.item; + closeViewer(); + openDetail(item, back.backToDetail); + dEntry=back.backToDetail.entry||null; // keep the chain repeatable + } else { + closeViewer(); + } +} $('vback').addEventListener('click',closeViewer); $('vclose').onclick=closeViewer; $('vclose').addEventListener('pointerdown',e=>e.stopPropagation()); @@ -1127,7 +1198,8 @@ let badgeFired=false; async function loadStats(){ const res=await getJSON('/api/stats'); if(!res) return; - $('pill').innerHTML=`${res.live} live`+(res.new>0?` · ${res.new} new`:''); + const fresh=typeof res.fresh==='number'?res.fresh:null; // old API: no segment + $('pill').innerHTML=`${res.live} live${fresh!=null?` · ${fresh} fresh`:''}`+(res.new>0?` · ${res.new} new`:''); $('lsub').innerHTML=`${res.live} active · `+(res.new>0?`${res.new} new`:res.new+' new')+' since your last visit'; if(!badgeFired){ badgeFired=true; @@ -1140,7 +1212,7 @@ let sectDecided=0, sectTotal=0; function paintSect(){ const t=$('sect'); if(legacyMode||!section){ t.textContent=''; return; } - t.textContent=`${sectDecided}/${sectTotal}`; + t.textContent=`${Math.min(sectDecided,sectTotal)}/${sectTotal}`; // clamp: never paints past the total t.classList.toggle('hot',sectDecided>=sectTotal); } function paintMode(){ @@ -1156,14 +1228,20 @@ function paintMode(){ } function updateSectionUI(body){ sectTotal=body.progress.total; + // item 6: the dry-catalog caption rides every hydrate (legacy API -> absent) + $('dryCap').style.display= dryLabel(body)?'':'none'; let decided=0; for(const it of (body.items||[])){ const local=localDecisions.get(it.id); - if(local===undefined? it.decision!=null : local!=null) decided++; + // item 5: server truth = decided DURING this section (an in-section + // event), never the lifetime decision a second look wears into it; + // local intent wins while a swipe's POST is in flight + if(local===undefined? !!it.decided_this_section : local!=null) decided++; } sectDecided=decided; paintSect(); paintMode(); + paintRubricMode(); const c=body.counts||{}; $('repCounts').innerHTML=`♥ ${c.likes||0} · ★ ${c.maybes||0} · ✕ ${c.nopes||0}`; const hits=[]; @@ -1177,6 +1255,28 @@ async function setMode(m){ } $('modeOnd').onclick=()=>setMode('ondemand'); $('modeStr').onclick=()=>setMode('strict'); +function paintRubricMode(){ + const on=rubricOn(); + $('tRubOff').classList.toggle('on',!on); + $('tRubOn').classList.toggle('on',on); +} +async function setRubricDebug(m){ + const res=await fetch('/api/prefs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key:'rubric_debug',value:m})}).catch(()=>null); + if(res&&res.ok){ + if(section){ section.prefs=section.prefs||{}; section.prefs.rubric_debug=m; } + paintRubricMode(); + if(!legacyMode) loadSection(); // repaint any dossier already open + } +} +$('tRubOff').onclick=()=>setRubricDebug('off'); +$('tRubOn').onclick=()=>setRubricDebug('on'); +/* item 6: dry catalog — a forged batch with zero fresh cards is labeled, + never silent (BACKLOG "Dry-catalog messaging"). */ +const DRY_MSG='Catalog fully reviewed — fresh listings arrive with the next 08:30 import; showing second looks'; +function dryLabel(body){ + return !!(body && body.recipe && body.recipe.new===0 + && body.items && body.items.length>0); +} $('btnForge').onclick=async()=>{ if(forgeBusy||!section) return; forgeBusy=true; @@ -1186,8 +1286,10 @@ $('btnForge').onclick=async()=>{ const res=await fetch('/api/section/forge',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}); const body=await res.json(); if(res.ok){ - $('forgeStatus').textContent= body.note&&body.note.indexOf('fallback')>=0 - ?'Agent offline — forging on the last read.':''; + $('forgeStatus').textContent= dryLabel(body) + ?DRY_MSG + :(body.note&&body.note.indexOf('fallback')>=0 + ?'Agent offline — forging on the last read.':''); section=body; buffer.length=0; const live=new Set([...pile.map(c=>c.d.id),...hist.map(h=>h.id)]); @@ -1196,6 +1298,12 @@ $('btnForge').onclick=async()=>{ fillPile(); updateSectionUI(body); if(pile.length){ $('report').classList.remove('on'); } + // item 8: a zero-item forge on a fully drained catalog -> empty2, + // no lone Forge button inviting a wasted agent run + if((body.items||[]).length===0 && poolDead()){ + $('report').classList.remove('on'); + $('empty2').classList.add('on'); + } }else{ $('forgeStatus').textContent= body.detail||'Forge failed'; paintMode(); @@ -1260,13 +1368,29 @@ document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{ else if(name==='trends') fetchTrends(); }); -/* ---------- desktop keyboard arrows (DESIGN.md: v1 frontend) ---------- */ +/* ---------- v1.1 keyboard map (BACKLOG "Shortcuts"): ← nope · → like · + ↑ maybe · ↓ undo · Backspace = undo alias (kept for muscle memory) · + Esc closes the top open sheet (viewer → dossier → deck). Arrows act only + on the deck; text-input focus and open sheets swallow them; arrow-key + defaults are prevented so no page scroll can fire. -------------------- */ document.addEventListener('keydown',e=>{ - if(e.target.tagName==='INPUT'||$('detail').classList.contains('on')||$('viewer').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(); } + const t=e.target; + if(t && (t.tagName==='INPUT'||t.tagName==='TEXTAREA'||t.tagName==='SELECT'||t.isContentEditable)) return; + const k=e.key; + if(k==='Escape'){ + if($('viewer').classList.contains('on')) vWalkBack(); // viewer → dossier (when reached from it) → deck + else if($('detail').classList.contains('on')) closeDetail(); + return; // Esc never forges or navigates tabs + } + const sheetOpen=()=>$('detail').classList.contains('on')||$('viewer').classList.contains('on'); + if(k==='ArrowDown'){ e.preventDefault(); if(!sheetOpen()) undo(); return; } + if(k==='Backspace'){ if(!sheetOpen()) undo(); return; } + if(k==='ArrowLeft'||k==='ArrowRight'||k==='ArrowUp'){ + e.preventDefault(); + if(sheetOpen()) return; + const el=pile[0]&&pile[0].el; + if(el) commit(el, k==='ArrowLeft'?'nope':k==='ArrowRight'?'like':'maybe'); + } }); /* ---------- boot ---------- */ diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index ccc33ab..924f057 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -2,10 +2,58 @@ Glenn's "later versions" idea queue. He drops ideas in conversation; they land here (near-verbatim + a scope sketch + open questions), and get built in a future version — never mid-phase. Newest first. +## v1.1 — QOL wishlist (drafted 2026-11-12; feasibility triaged same day; build awaits Glenn's go-ahead) + +Glenn's words: *"Going to go through a laundry list of fixes before we do lets say 1.1?"* — nothing here is started. + +### Bugs & dry-catalog messaging (reported live 2026-11-12 evening) + +Glenn's words: *"I just got a new batch and its marked as 2/2. When I go to the next, its 3/2… I don't believe it's counting the entries correctly. Furthermore, I'm only again getting 2 bikes at a time… I feel I should be getting batches of 25 at a time?"* + +- **Counter bug (diagnosed in code 2026-11-12):** the pill shows `decided/total` for the current section. **Returns arrive wearing their previous decision** (the section payload joins `swipes.action`), so a fresh second-looks-only batch instantly counts as fully decided — a new batch paints "2/2" before it is touched, and the hot/complete state fires prematurely. Swiping then increments optimistically on top (`sectDecided++`), so the count runs past the total — "3/2", "4/2". **Fix sketch:** count a card as decided only if decided *during this section* (server per-item flag from events stamped with this section_id — the source `section_counts` already uses); clamp to total; keep the hot state off until a real in-section decision exists. +- **Dry-catalog messaging (pulled forward from the Dry-pool UX note below):** when the fresh pool is 0 the deck silently degrades to returns-only sections (2–3 cards: ≤5 returns budget, one-shot per item, family caps) — by design but confusing. v1.1: surface the state ("Catalog fully reviewed — fresh listings arrive with the next 08:30 import; showing second looks") whenever a composed/forged batch reports `new: 0`, and label forge results accordingly so it never silently drips 2 cards again. + +### Batch formula v2 + fresh counter + empty state + Second Look pop (requested live 2026-11-12 evening, round 2) + +Glenn's words: *"Alright lets add a new counter to the top of 'bikes lef' or something to indicate how many bikes are fresh. I think if there is no more new bikes, the empty page (all bikes gone through) should say 'no more new bikes!'. Maybe there is already but I didn't read it. Make the UI more pronounced so I can see it clearly. One more thing, I think there should be 25 bikes at a time, min 2 of second look. So say there is 5 bikes left, those 5 fresh bikes are listed, with the rest being second looks. Ok last thing, the Second Look indicator, make it more pronounced visually. Change the color a bit so it pops a bit more."* + +- **Fresh counter (header):** new chip in the top header showing **fresh (undecided) bikes left** — extend `/api/stats` with `fresh` (active listings with no swipe — the exact NEW_POOL predicate). Header pill gains `K fresh`. When 0, the state of the hunt reads at a glance. +- **Empty state, loud:** the exhausted screen becomes prominent **"No more new bikes!"** (today `.empty` says a quiet "You're all caught up" and the report screen can mask it in section mode). Show whenever the deck can serve *nothing* at all (no fresh, no eligible second looks, no wildcards); keep "New bikes arrive nightly — next import 08:30". +- **Batch formula v2** *(amends the frozen ≤5-returns rule — build-time UI-SPEC/P3 note):* every batch targets **25 cards**. Fresh first (up to 23 slots); **≥2 second looks per batch whenever eligible stock exists** *(reading Glenn's "min 2": a full-fresh day = 23 fresh + 2 second looks; veto line if he wants pure-fresh instead)*; when fresh < 23, second looks **fill the remainder up to 25** (his example: 5 fresh + 20 second looks); leftover fresh rolls to the next batch. Limits kept: ≤2 re-noped returns per batch; family cap relaxes for fill-returns when needed (build detail); returns stay one-shot. Fresh exhausted → second-looks-only batches; returns exhausted → fresh-only batches; both → the loud empty state. + - *Noted consequence:* with fill-to-25, the one-shot second-look pool (~30 remaining as of tonight) burns through in a batch or two — after that only nightly imports feed the deck. Re-offer policy (e.g. re-eligible after everything's had its turn) stays queued (option (c) below); revisit after living with v2. +- **Second Look band pops:** `.band.sl` is currently near-invisible (white at ~10% on dark); re-tint to a distinct **violet accent** (bold fill + border) on card face + info view. Wildcard stays amber for contrast. +- **UI-SPEC amendments at build:** §11 tap flow (already queued) + `.band.sl` palette + empty-state copy + header fresh counter. +- **Glenn's sign-offs (2026-11-12, live):** ≥2-second-looks default CONFIRMED (rich-day deck = 21 fresh + 2 second looks + 2 wildcards) · violet band CONFIRMED · Backspace kept as undo alias CONFIRMED. **Build phase AUTHORIZED** the same day — implementation spec = `docs/V1.1-BUILD.md`. + +### Shortcuts +- Arrow keys: Left = Nope, Right = Like, Up = Maybe, **Down = back one** *(read as: undo the last swipe — confirm at spec time)*. **Esc** = exit fullscreen views. +- Feasibility: trivial — keyboard invokes the exact actions gestures/buttons already call (undo exists). Care: ignore keys while a text input has focus; prevent arrow-key page scroll. Desktop/Bluetooth-keyboard benefit only; the app is reachable from any tailnet device (MacBook included). + +### UI changes +1. **Tap flow — info first, then photos.** Center tap opens the fullscreen **bike data view** (today it opens the photo); tapping the photo there opens the fullscreen **image viewer**. Side taps keep flipping photos on the card. Esc/back pops viewer → data → deck. Resolves "can't get info unless I hit the ⓘ button." Revisit `UI-SPEC.md` §11 tap map before build (v1.1 spec bump). +2. **Photo fit: fill → contain, option C chosen.** Show photos uncropped — no "zoomed" crops. **Decided 2026-11-12 (Glenn: "OH C! I love C!"): option C — fit + blurred backdrop (whole photo over a soft blur of the same image) on the card AND the info-page hero.** Fullscreen viewer already fits (unchanged); list thumbs stay fill (56px, irrelevant). Demo reference: `~/dev/bike-tinder-demos/fit-vs-fill.html` (panel C). +3. **Rubric display goes minimal.** Show ONLY the gas-tank gauge + percent full (e.g. "76%"); drop the rest of the rubric-fit display in bike views — **plus a setting that re-enables the full "why" breakdown for debugging** (Glenn, 2026-11-12; wire via the existing `prefs` key/value API — same pattern as `forge_mode`). + +## v1.2 — Analytics suite (version split set with Glenn 2026-11-12) + +Glenn's words: *"take in just about all interactions… how long I have looked at each picture, how many pictures I flipped through, how quickly I swiped… kept on an individual bike perspective and fed to the model for further accuracy"* + *"start the database from scratch, giving myself a new start."* + +- **Capture (local-only, per bike):** dwell time per photo, flips (count/order), decision latency (card visible → swipe), maybe/undo/list-edit events, session id; card-dwell vs fullscreen-dwell as separate signals. +- **Store:** new append-only `interaction_events` table (same pattern as `swipe_events`); batched client flush (`sendBeacon`/pagehide); iOS PWA: pause dwell clocks on `visibilitychange` so background time can't pollute. +- **Feed the model:** nightly agent pass consumes per-bike aggregates as extra taste features. HARD LAW unchanged: no LLM in the swipe path — capture is write-only until the nightly read. +- **Reset:** ships with v1.2, paired with a from-scratch DB reset so telemetry starts clean on the new ledger. Scope to confirm: wipe swipes + events + profile only (listings/catalog stay — re-import is idempotent); legacy data stays in `data/backup/` archives. (v1.1 does NOT reset anything — swipe history survives it untouched.) +- **Open questions:** reset scope; raw events forever vs aggregate-and-prune; include list-browsing events; surface any of it in the UI (probably not). + +## Gallery mirror gap (found 2026-11-12, filing for a later phase) + +Verified while preparing the fit/fill demo: the local image mirror (`data/img/{id}.webp`) holds **primaries only**. Gallery photos beyond the first (`/img/{id}?i≥1`) serve a **302 redirect to the Kijiji CDN** — not hedged locally. If an ad is deleted we keep photo 1, but the gallery (often where the good shots are) is gone. Fix options for a later phase: mirror galleries on import/first-touch (estimates in the B1 notes: roughly +350–600 MB for the full catalog — fine for the mirror volume), keep the redirect as fallback. Not urgent; filed so it is not forgotten. + ## Dry-pool UX + agent feedback (noted 2026-11-12) Glenn hit a fully-reviewed catalog: 563/563 actives decided → fresh pool = 0 → forges returned 2-card returns-only sections ("stuck on a couple bikes"), and "Run agent now" looked inert (a pass ran, but the deck can't change without fresh listings). Possible polish: (a) report/forge state says "catalog fully reviewed — fresh listings arrive with the next import" instead of silently dripping 2 cards; (b) agent button shows a richer confirmation (profile version + lean delta); (c) re-open "second look" eligibility or widen the wildcard pool when the catalog is dry; (d) a "review your Maybes" shortcut at exhaustion. Related: the nightly import + nightly agent read make the pool self-refreshing (Phase 4). +**Pulled into v1.1 (2026-11-12):** option (a) — dry-state messaging — is now part of the v1.1 scope (see Bugs & dry-catalog messaging above); options (b)/(c)/(d) remain queued for a later version. + ## B1 · Photo experience v2 — multi-image galleries, photo swipe, fullscreen viewer **Queued 2026-11-10** (during the P3 build; for the version after it). **PULLED FORWARD 2026-11-10; SHIPPED 2026-11-11 — built, verified (walk 390/390, selfcheck 31/31), LIVE on :9453; see `PLAN.md`.** Spec frozen in `UI-SPEC.md` §11 + `DATA-MODEL.md` B1 additions. diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index f0a16c2..5012859 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -162,9 +162,9 @@ Semantics: - Round = prior events for that listing + 1, assigned at write time. Rounds cap at 2 for engine influence (any later events weigh as round 2; v1 returns never produce rounds >2 by design). - **Migration seeding:** when `swipe_events` is empty and `swipes` has rows → insert one round-1 event per swipe row (created_at copied). Only-when-empty, hence idempotent. - **Sections:** at most ONE `open` section at a time. The deck = that section's `section_items` in `position` order, filtered to items undecided at read. Exhausted = no item undecided → `status='report'`. Forge = close current + open new. First-ever read composes a cold section (rubric-weighted, w=0). -- **Returns eligibility (v1 contract):** a verdict exists (like/maybe/nope in `swipes`); listing `active`; NOT in either of the previous two sections; no round≥2 `nope` ever (rested); not already returned once (round cap). Selection prefers maybe > like > nope; nope candidates must be "surprising" (points ≥55 or net-positive family). Max 5 returns per section, ≤2 of them nopes. +- **Returns eligibility (v1.1 contract, amends the frozen ≤5 rule):** a verdict exists (like/maybe/nope in `swipes`); listing `active`; NOT in either of the previous two sections; no round≥2 `nope` ever (rested); not already returned once (round cap). Selection prefers maybe > like > nope; nope candidates must be "surprising" (points ≥55 or net-positive family) for the RESERVED slots only. Every batch carries ≥2 returns whenever eligible stock exists and fills the remainder up to 25 when fresh < 23; ≤2 nope-verdict returns per batch; fill returns relax the per-family cap from 4 to 8 (fill slots only). - **`points` is computed, not stored** (deterministic from events + profile; a cache is allowed only if it never changes semantics). -- **prefs:** `forge_mode` ('ondemand' default | 'strict'). Existing keys unchanged. +- **prefs:** `forge_mode` ('ondemand' default | 'strict') · `rubric_debug` ('off' default | 'on' — dossier "why" breakdown visibility; v1.1). Existing keys unchanged. - **Legacy deck semantics:** `/api/deck` (cursor paging; 7-day maybe re-admit) stays as a read-only fallback for debugging; sections are the production flow. ## B1 additions — frozen 2026-11-10 (photo experience v2) diff --git a/docs/PLAN.md b/docs/PLAN.md index 65dbc09..d01eae4 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1,6 +1,12 @@ # PLAN — Bike-Tinder -**Status:** Build phase — Phase 2 complete + phone-proven (2026-11-10); **Phase 3 built, verified, and live (2026-11-11); B1 photo experience v2 built, verified, and live (2026-11-11)**; Phase 4 next. Phases are gated: each lands, is verified, then the next begins. Glenn's standing rule applies — "pause" stops all spend immediately. +**Status:** **v1.0 SHIPPED (2026-11-12)** — Phases 1–4 complete: stack live under its unit, nightly chain armed, tailnet URL in daily use, Gitea release `v1.0.0` cut. **v1.1** (QOL batch) is spec'd and awaits Glenn's build order; **v1.2** (analytics suite + fresh-ledger reset) queued. Phases are gated: each lands, is verified, then the next begins. Glenn's standing rule applies — "pause" stops all spend immediately. + +## Version map + +- **v1.0** — shipped 2026-11-12: the working loop (Phases 1–4 — catalog, swipe deck, personalization, deployed stack + nightly automation). Gitea release `v1.0.0`. +- **v1.1** — QOL batch, spec'd 2026-11-12 (`BACKLOG.md`): keyboard shortcuts · info-first tap flow · option C photos (fit + blurred backdrop) · minimal rubric + debug "why" toggle · section-counter fix + dry-catalog messaging · fresh counter + loud "No more new bikes!" empty state · batch formula v2 (25-target, second-look fill, ≥2 per batch) + Second Look band pop. No data reset. **Build authorized 2026-11-12** — sign-offs: ≥2-SL default (rich day 21 fresh/2 SL/2 wild) · violet band · Backspace alias; spec = `docs/V1.1-BUILD.md`; build phase in progress. +- **v1.2** — analytics suite (`BACKLOG.md`): full interaction capture + from-scratch ledger reset ("fresh start"). ## v1 goal diff --git a/docs/RANKING.md b/docs/RANKING.md index 95425de..f58470f 100644 --- a/docs/RANKING.md +++ b/docs/RANKING.md @@ -26,7 +26,7 @@ Deterministic, explainable, pure-Python. Learning reads `swipe_events` only. **Score.** `taste_raw` = Σ shrunk weights over the listing's values; `taste_norm` = percentile rank among active listings (0–100). Blend: `w = min(0.85, N/60)` where N = total events; `points = clip( round( (1−w)·rubric + w·taste_norm + agent_adj ), 0, 100 )`. `agent_adj` = Σ profile adjustments (clamped ±10 per listing). Breakdown for the dossier: rubric share · top 3 ± taste features · agent lines — all source-labeled. -**Section recipe.** 25 cards = 2 wildcards + ≤5 returns + remainder new (points DESC). Constraints: ≤4 per family per section; ≥3 price bands when the pool allows; shortfalls fill from new. +**Section recipe.** Every batch targets 25 cards = fresh first (up to 23 slots) + ≥2 second looks whenever eligible stock exists + the 2 wildcard slots (wildcards draw from the fresh pool). When fresh < the remainder, second looks fill up to 25 (family cap relaxes to 8 for fill returns only); leftover fresh rolls to the next batch; fresh/return shortfalls are never padded — a drained pool yields a short (or empty) section. Constraints: ≤4 per family per section for fresh + reserved returns; ≥3 price bands when the pool allows. **Wildcards.** Candidates: active, unseen, not in the last two sections. Exclusions: families with ≥3 cumulative nopes and net-negative weight. Prefer: different family AND different price band than the section's top new items. Score = `0.5·points + 0.5·novelty` (novelty = distance from the liked-profile centroid). Picks: 2 per section. diff --git a/docs/UI-SPEC.md b/docs/UI-SPEC.md index 3bde939..8a1574c 100644 --- a/docs/UI-SPEC.md +++ b/docs/UI-SPEC.md @@ -20,6 +20,7 @@ ### 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)). +- Photo fit is **contain** (v1.1): the whole photo is shown uncropped over a soft blurred backdrop of the same image (option C). The backdrop is decorative (blurred, dimmed, overflow-clipped); the sharp photo sits on top with a gentle shadow. The fullscreen viewer already fits; list thumbs stay `cover` (56px). - 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. @@ -51,6 +52,8 @@ ### 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. +§2's quiet "You're all caught up." remains only for legacy (pre-section) mode. In section mode, when the deck can serve nothing at all — no fresh listings, no eligible second looks, no wildcards — the deck shows a prominent **"No more new bikes!"** panel with "New bikes arrive nightly — next import 08:30." and, when true, "↺ brings the last card back." This panel supersedes the report card in that state only; the report card remains the end-of-section screen whenever more cards can still be served (v1.1). + ## 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. @@ -87,7 +90,7 @@ Opened from deck ⓘ (or tap) AND list row tap. iOS-sheet geometry: top gap 26px 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. +3. **Badge.** `POST /api/badge/seen` once per session when the deck first renders. Header pill shows `{live} live · {fresh} fresh` always, and `· {new} new` only when new > 0. `fresh` = active listings with no swipe (the deck's new-pool predicate) — v1.1. 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. @@ -119,7 +122,7 @@ Human (parent-run after build): Glenn swipes a real deck from his phone and edit - The deck serves ONE section at a time: 25 cards (default). A section is the unit of a swipe burst; when it is exhausted, the next one is forged (with an agent pass in between — §10.2). - Header right side, next to the stats: section progress "7/25" (12px/650, muted; turns `--accent1` at 25/25; fits within the existing header, no layout shift). -- Section composition (engine side — RANKING.md): ≤5 SECOND LOOK cards + 2 WILDCARD cards + the remainder new. +- Section composition (engine side — RANKING.md): every batch targets 25 cards = fresh first (up to 23 slots) + ≥2 SECOND LOOK cards whenever eligible stock exists + 2 WILDCARD cards; when fresh < 23, second looks fill the remainder up to 25. Re-nope returns stay ≤2 per batch; fresh shortfalls are never padded (v1.1). - ALL §2 mechanics apply unchanged inside a section: 5-deep stack, `.topcard` invariant, no-lock commit, stamps, undo; the buffer refills from the pinned section order. ### 10.2 Section report + forge @@ -133,13 +136,14 @@ Human (parent-run after build): Glenn swipes a real deck from his phone and edit - **Forge mode `ondemand` (default):** primary button **"Forge next 25"**. Tap → inline status "The agent is reading your strikes…" → new section hydrates. If the pass fails: "Agent offline — forging on the last read." and the forge still lands (fallback profile). - **Forge mode `strict`:** button disabled; caption "Waiting for the nightly read — next section after the agent runs." - Both modes: small toggle row under the actions — "Forge: on demand ⇄ strict" (persists `prefs['forge_mode']`). + - When a forged batch reports `new: 0` the report shows: "Catalog fully reviewed — fresh listings arrive with the next 08:30 import; showing second looks." and the forge reads the same note in its status line (v1.1). - Undo from the report screen re-opens the section with the last card (§2 undo semantics). ### 10.3 Card bands (the ONLY card-face additions) - Position: top-left inset (12px); `pointer-events:none`; no layout shift; no effect on stack metrics or drag. 11px/650, letter-spacing .4px, radius 999px, padding 4px 9px, max-width 78%; may wrap to 2 lines. - **WILDCARD — maybe you'll like this one instead** — color `#ffb43a`; bg `rgba(255,180,58,.14)`; border `1px solid rgba(255,180,58,.35)`. -- **SECOND LOOK — you {liked|maybed|noped} this** — color `rgba(255,255,255,.85)`; bg `rgba(255,255,255,.10)`; border `1px solid rgba(255,255,255,.22)`. +- **SECOND LOOK — you {liked|maybed|noped} this** — color `#c4b5fd`; bg `rgba(124,58,237,.22)`; border `1px solid rgba(167,139,250,.65)`; font-weight 800. Wildcard stays amber (`#ffb43a` family values unchanged) for contrast. The same band renders in the dossier ("info view") when the listing's slot is `return` or `wildcard` (v1.1). - No other text may sit on the card face in v3. ### 10.4 Tank gauge (match points, non-numeric) @@ -151,9 +155,10 @@ Human (parent-run after build): Glenn swipes a real deck from his phone and edit ### 10.5 Dossier — "Why this rank" - New block after chips, before "The machine": - - Gauge + "{points}/100" (17px/700). + - Gauge + "{points}%" (17px/700). - Up to 4 breakdown lines, strongest first, muted: "· {feature} {±n}"; agent contributions render as "· agent: {why} {±n}". - Footer: "from {N} strikes · read {relative}" (N = events count; time = profile version time). +- Hidden-by-default detail: the breakdown lines and the "from N strikes" footer render only when `prefs['rubric_debug'] === 'on'` (default `'off'`). With the pref off the block shows **the tank gauge + "{points}%"** only — the minimal rubric display. The toggle lives in the Trends "Actions" area (why-breakdown on ⇄ off, same control style as the forge-mode toggle) — v1.1. - This is the only numeric display in the app (accepted — it exists for audit). ### 10.6 Trends tab (real) @@ -192,14 +197,15 @@ Top→bottom: ### 11.3 Fullscreen viewer -- Tap the CENTER third → fullscreen viewer opens on the photo currently shown. +- Tap the photo in the dossier hero → fullscreen viewer opens (starts at photo 0). Direct card→viewer is retired (see §11.4); the viewer is reached from the dossier (v1.1). - Viewer: horizontal swipe = prev/next (clamp); pinch-zoom + double-tap = zoom (1×–~4×); segment bars stay visible; close via ✕ (36px, top-left) or pull-down (grabber or photo, `dy > 110` — the §3 idiom). Closing returns to the same card, same photo position; deck state untouched. - Also opens from the dossier hero (starts at photo 0). Same sheet geometry as the dossier (z-band 69/70), photo-first: no body, no actions row. +- Viewer close (✕, pull-down, or Esc) returns to the dossier; the next Esc returns to the deck (v1.1). ### 11.4 Doors (supersedes the old tap→dossier) -- ⓘ (top-right of card) → dossier (unchanged). The card face now owns: **sides = flip, center = fullscreen**; the "tap anywhere → dossier" gesture is RETIRED. -- The dossier stays reachable from ⓘ and from list rows (§3/§4 unchanged). +- ⓘ (top-right of card) → dossier (unchanged). The card face now owns: **sides = flip, center = dossier (bike data first)**; the "tap anywhere → dossier" gesture is RETIRED and the B1 center-tap→fullscreen gesture is RETIRED in favour of it (v1.1). +- Tapping the photo in the dossier hero opens the fullscreen image viewer (§11.3). Viewer close (✕, pull-down, or Esc) returns to the dossier; the next Esc returns to the deck. The dossier stays reachable from ⓘ and from list rows (§3/§4 unchanged). ### 11.5 Repeat rotation diff --git a/docs/V1.1-BUILD.md b/docs/V1.1-BUILD.md new file mode 100644 index 0000000..8c9104c --- /dev/null +++ b/docs/V1.1-BUILD.md @@ -0,0 +1,624 @@ +# V1.1-BUILD — v1.1 implementation specification + +Build spec for the v1.1 QOL batch. Requirements: `docs/BACKLOG.md` "v1.1 — QOL wishlist" (both subsections). Frozen UI contract this amends: `docs/UI-SPEC.md` (v2 + P3 §10 + B1 §11). Version map: `docs/PLAN.md`. + +**Status:** APPROVED 2026-11-12 (Glenn): ≥2-second-looks default confirmed (rich day = 21 fresh + 2 second looks + 2 wildcards); violet band; Backspace kept as undo alias. Build authorized — implementation in progress on `dev`. Every item below states goal, exact change plan (file/function/line anchors verified against the code at `381b6c8`), pseudocode where the logic is heavy, edge cases, acceptance criteria, and the verification steps the parent will run. Nothing is built yet. + +**No data reset. No schema change. v1.1 is frontend + read-path/query only** (see "Data safety"). The only logic rewrite is the compose allocation itself (`compose_section`), which still writes *only* what it writes today (a new `sections`/`section_items` row set, exactly as a forge already does). + +## Code facts everyone should start from (verified 2026-11-12, commit `381b6c8`) + +Backend: + +- `app/sections.py` (488 lines): `NEW_POOL_SQL` (40–44), `RETURNS_SQL` (46–55), `WILDCARD_SQL` (57–61), constants `FAMILY_CAP=4 MAX_RETURNS=5 MAX_RETURN_NOPES=2 WILDCARDS=2 DEFAULT_SIZE=25 MIN_BANDS=3` (28–33), `pick_new` (160–178), `repair_bands` (181–256), `compose_section` (259–404), `section_items` (417–425), `section_counts` (428–454), `maybe_exhaust` (457–466), `read_section` (469–488). +- `app/api.py` (738 lines): `/api/stats` (234–264), `swipe` POST (299–357; `section_id` optional at 307–309, `append_event` call at 345–346), `section_state` (446–471), `get_section` (473–488; cold compose hardcodes `size=25` at 482–485), `forge` (490–527), `/api/prefs` (652–666; `allowed = {'forge_mode': ('ondemand','strict')}` at 656), `img` with `object-fit:contain` viewer served at 670–708. +- `app/db.py`: `swipe_events` schema with `section_id` (18–26), `append_event` (120–143) — already stamps `section_id` when the client sends it; P3 seeding copies events with `section_id=NULL` (111–117). + +Frontend: `app/static/index.html` (1282 lines, single-file app): + +- Keyboard handler ALREADY EXISTS at 1263–1270 (see item 1 — this must be reconciliated with the BACKLOG key map). +- Tap zones: `tapZone` (756–763) — sides flip, center opens viewer. Built faithful to B1 §11.4. v1.1 changes the center-target only. +- Deck→dossier door: `.ibtn` click (578–580); `openDetail` (768–796); dossier hero with `dphoto` (245) and hero-tap→viewer in `dEndH` (832–837). +- Viewer: `openViewer` (870–877), `closeViewer` (878–883), `vCtx={item,idx,back}` (853, 873); `.vimg` (197) already `object-fit:contain`. +- Card DOM: `phwrapHTML` (512–513) puts `` inside the `.phwrap`; `.card .photo` is `object-fit:cover` (CSS 36). +- Header: `#pill` (206, filled by `loadStats` 1127–1136 — it renders `live · new` from `/api/stats`), `#sect` (207) is the section progress pill, `.sect` CSS 137–138, `.pill` 29–30. +- Empty state: `#empty` (212) with copy "You're all caught up."; CSS 95–97; shown only by `maybeRefill` in **legacy mode** (652) — unreachable in section mode, where the report card (`#report`, 213–224, shown at 660–662) owns exhaustion. +- Section counter: client state `sectDecided/sectTotal` (1139); `paintSect` (1140–1145); increments in `commit` (684) and `undo` (706); server truth re-derived on every `updateSectionUI` (1157–1173) from `body.items[].decision` — the bug: `decision` is the listing's *lifetime* verdict, not a this-section decision. +- `bandHTML` (482–489); `.band` (139), `.band.wild` (140), `.band.sl` (141); `hasSegs` band offset (188); `updateSectionUI` (1157); `forge` button flow (1180–1206); prefs pattern `setMode` (1174–1179); `loadStats` (1127). +- Info-page hero: `.dphoto` (70) is `object-fit:cover`; list thumbs `.thumb` (110) are `cover` and stay that way. +- Rubric display: dossier "Why this rank" block `#drank` (250–257) filled by `fillRank` (798–821) — gauge + `{points}/100` (805) + up to 4 breakdown lines (806–812) + footer (813–814); list rows show tank only (985). + +Discrepancies found between docs and code (code wins; both recorded): + +- B1 §11.4 says center-tap = fullscreen viewer. Code matches. v1.1 re-maps center-tap = dossier (BACKLOG UI change 1) — the UI-SPEC amendment in this doc supersedes §11.3/§11.4. +- `sections.py` module docstring (2–17) and `docs/RANKING.md:29` still say "≤5 returns"; DATA-MODEL line 165 says "Max 5 returns per section". All replaced by item 9's formula (docs to update: RANKING.md:29, DATA-MODEL.md:165, the module docstring — the spec's one doc-touch, see item 9). +- The 2026-11-12 empty-pool symptom ("forges returned 2-card sections") means the eligible-return pool is *near-empty today*; details in item 9. +- Current keyboard handler maps **Backspace**, not ArrowDown, to undo, and does not prevent arrow page scroll — item 1 fixes this. + +--- + +## Item 1 — Keyboard shortcuts + +**Goal (BACKLOG "Shortcuts"):** ← nope, → like, ↑ maybe, ↓ undo (back one), Esc exits fullscreen views. Desktop/Bluetooth-keyboard benefit; no touch changes. + +Notes before the plan: + +- The expected row of actions shares handlers: commits go through `commit()` (`index.html` 677–689; button bindings at 721–723), undo through `undo()` (702–720; binding 724). The keys must call these exact functions so all P3 invariants (optimistic persist through `setDecision`, photo_idx, no-lock flight) hold — never bespoke handlers. +- A keyboard handler already exists (1263–1270): ← / → / ↑ do the right things, but undo is **Backspace**, ↓ is unmapped, Esc is unmapped, there is no scroll guard, and the text-input guard covers only `INPUT`. + +**Plan (all in `app/static/index.html`):** + +1. Replace the handler (1264–1270) with the v1.1 key map: + ``` + ArrowLeft -> nope (commit(pile[0].el,'nope')) + ArrowRight -> like (commit(pile[0].el,'like')) + ArrowUp -> maybe (commit(pile[0].el,'maybe')) + ArrowDown -> undo (undo()) + Backspace -> undo (undo()) // kept: existing users' muscle memory — note in Open Questions + Escape -> exit fullscreen views (below) + ``` +2. Guard before acting: **ignore all keys while a text input has focus.** Extend the existing `e.target.tagName==='INPUT'` check to also skip `TEXTAREA`, `SELECT`, or any element with `isContentEditable` (the list screen has a live search input; searching with arrows must filter, not swipe): + ``` + const t=e.target; + if(t && (t.tagName==='INPUT'||t.tagName==='TEXTAREA'||t.tagName==='SELECT'||t.isContentEditable)) return; + ``` +3. **Prevent arrow-key page scroll** (the app body is `overflow:hidden`, but focused buttons can still scroll; be safe): for the four arrow keys, `e.preventDefault()` immediately after the guard passes — i.e. before letting the browser's default horizontal/vertical scroll run. Backspace default is harmless and untouched. +4. **Esc, one key, stack order** (viewer `#viewer` z-72 > dossier `#detail` z-70 > deck): + - if `#viewer` has class `on` -> `closeViewer()` (878) + - else if `#detail` has class `on` -> `closeDetail()` (822; returns to deck or, from a list row, to the list screen that stays open underneath — same as today's pull-down close) + - else if `#report` has class `on` -> do nothing (Esc never forges or navigates tabs) + - else -> ignore. + Note: the currently-open sheet also swallows keys before Esc gets them, because the existing early-return for open `#detail`/`#viewer` is removed in favour of the Esc-first ordering above. Esc only closes; arrows only work on the deck. + +**Edge cases:** + +- Arrow keys must do nothing when no pile: `commit` and `undo` already no-op on empty `pile[0]`/`hist` (677–678, 703). +- Key repeat (holding →): `commit` drains the pile as fast as repeat fires — same behaviour as mashing the like button, acceptable, no extra lock needed (the no-lock law already tolerates fast commits). +- The report card is up but the section is closed: arrows no-op (pile empty); Esc no-ops; the Forge button remains mouse/tap only. + +**Acceptance criteria (objective):** + +1. New chromium/headless page, deck served: dispatch `ArrowLeft/Right/Up` -> top card flies nope/like/maybe; the very same network payloads fire as button taps (verify no second code path: instrument or assert `sectDecided`/paintSect side effects identical). +2. Dispatch `ArrowDown` -> the swiped card returns to the top of the pile (undo flight + `DELETE /api/swipe/{id}` queued). +3. Dispatch `Backspace` -> same undo. +4. Focus the list search input, type arrows -> input cursor moves / text unchanged, zero swipe commits (assert pile length unchanged). +5. While the dossier is open, all arrow keys are inert; Esc closes it. While the viewer is open, Esc closes only the viewer (dossier, if underneath, stays open; a second Esc closes it). +6. Call `window.scrollTo(0,100)`-style check: with `document.body.scrollTop` manipulable, arrow dispatch does not change scroll (assert via `e.defaultPrevented` spy on `keydown`). + +**Parent verification:** + +1. `fe_walk_b1`-style headless walk: boot app, run acceptance 1–6 in order, assert green. +2. Optional human spot-check on MacBook over the tailnet. + +--- + +## Item 2 — Info-first tap flow + +**Goal (BACKLOG UI changes 1):** center tap → bike data dossier first; tapping the photo there → fullscreen image viewer; side taps still flip photos; Esc walks back out (viewer → data → deck). Resolves "can't get info unless I hit the ⓘ button". Esc routing is item 1's stack; this item changes the tap targets + the viewer's back-chain. + +**Current state:** `tapZone` (756–763) sends the center third to `openViewer(entry.d, entry.cur, {type:'card',entry})`; side thirds flip (`flipCard`, 525–536). `openDetail` is only reachable via ⓘ (578–580) / list rows (1036) / matches rows (1113). Dossier hero tap opens the viewer (832–837). Viewer close returns to whatever was under it (878–883). + +**Plan (all `app/static/index.html`):** + +1. `tapZone` (761): change the center branch to `openDetail(entry.d, {type:'deck'})`. Sides unchanged. Do not clear the card's photo position — the dossier is a sheet over the deck. +2. **Esc walk-back chain (new):** `openDetail` (768) must record *how it was opened* so a viewer reached from it can walk back. Add one module-level variable, e.g. `let dEntry=null;` — set in `tapZone`'s new branch to `{...dCtx, entry}` (the pile entry with its live `cur` photo index), set to `null` for row/matches opens (1036, 1113) and for ⓘ (580). `closeDetail` (822) clears it. +3. Viewer back-chain: when `tapZone`'s center branch opens the dossier, the dossier hero keeps its existing tap→`openViewer(dData,0,{type:'dossier'})` (835). Extend the viewer context so it can walk back to the dossier-on-deck: + - in `openViewer` (870–877): pass anextra field on `back`, e.g. `{type:'dossier', walkBack:dEntry}` or simpler: make the dossier-hero call (835) pass `{type:'dossier', backToDetail:dEntry}`; + - item 1's Esc handler calls, before plain `closeViewer()`, a new tiny `vWalkBack()`: if `vCtx.back.backToDetail` exists, capture the item, call `closeViewer()`, then `openDetail(item, backToDetail)` — image returns to the deck dossier (photo 0, per §3's hero contract), where the *next* Esc closes to the deck. If no `backToDetail`, plain `closeViewer()`. +4. The dossier closes to exactly the deck it came from: when opened from a deck card, the ⓘ/pull-down close semantics (822) already leave the pile untouched; no change. From a list row, Esc closes to the list (already true). + +**Edge cases:** + +- One-photo / no-photo cards: center tap still opens the dossier (data-first is the point); the hero tap no-ops when `dData.img` is null (835 already guards). +- The dossier's own action buttons (838–848) still commit the current top card — unchanged; the flow is open data, read it, swipe from inside it. That is desired: dossier actions exist today. +- Viewer opened the old way from a deck card — gone: after item 2 there is no direct card→viewer path; every deck-carded viewer is reached via dossier. `{type:'card',entry}` back shape (873) becomes unused from the deck; leave the plumbing, back-chain field only added for the dossier path. + +**Acceptance criteria:** + +1. Center tap (≤6px movement) on the top card opens `#detail` (`.on`), populated with that card's payload; **no** viewer opens; no commit fires. +2. Side taps still flip photos both directions, clamped; no dossier opens (existing B1 §11.2 behaviour re-verified). +3. Tap the dossier hero photo -> `#viewer` opens at photo 0. Esc -> viewer closes, dossier still open. Esc -> dossier closes, same deck/pile state and same top card (photo index intact). +4. ⓘ tap still opens the dossier (regression). +5. From the list: row tap -> dossier -> hero tap -> viewer -> Esc -> dossier -> Esc -> list (regression with the new Esc routing). + +**Parent verification:** + +1. Headless walk of acceptance 1–5 plus the B1 §11.7 suite re-run green (flip clamps, viewer swarm, no-lock, one `.topcard`). +2. Human: phone pass — Glenn taps the middle of a card, sees data, taps the photo, sees fullscreen, Esc/Esc (desktop) or ✕/pull-close (touch) walks back out. + +--- + +## Item 3 — Photos = option C (fit + blurred backdrop) + +**Goal (BACKLOG UI changes 2, decided 2026-11-12: "OH C! I love C!"):** the whole photo is shown uncropped (fit) over a soft blurred backdrop of itself, on the deck card AND the info-page hero. Fullscreen viewer unchanged (already fits). List thumbs stay `cover` (56px, irrelevant). Demo reference panel C: `~/dev/bike-tinder-demos/fit-vs-fill.html`. + +**Verified current CSS (index.html):** + +- Deck card photo: `.card .photo{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}` (36) — the element is inside `.phwrap` (513). +- Info hero: `.dphoto{width:100%;height:100%;object-fit:cover;display:block}` (70) inside `.dhero` (69; bg `#17171c`). +- Viewer: `.vimg{...object-fit:contain...}` (197) — already fits; **verify only, change nothing.** +- List thumbs: `.thumb{...object-fit:cover...}` (110) — **unchanged.** + +**Plan (CSS-only, `app/static/index.html`