Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7088304eda | ||
|
|
beeace231d | ||
|
|
f252ea06c7 | ||
|
|
79e7a4f61f | ||
|
|
618cd4a61c | ||
|
|
4ec86c23d1 | ||
|
|
66d3cc4da3 | ||
|
|
35138bdc22 | ||
|
|
08f945a029 | ||
|
|
2160ba6ce8 | ||
|
|
a1455aade4 | ||
|
|
0d2e5a592f | ||
|
|
98d345ceee | ||
|
|
ac613e680a | ||
|
|
e67f8a0cda | ||
|
|
7a1d14f23a | ||
|
|
829d43851b | ||
|
|
381b6c8a9c | ||
|
|
7e4c41cffd | ||
|
|
d1c3adddff | ||
|
|
085b2bc2ce | ||
|
|
e77ed38ebf | ||
|
|
eff1ae313f | ||
|
|
066329e92d | ||
|
|
47cecbc28b |
12
README.md
12
README.md
@@ -2,9 +2,9 @@
|
||||
|
||||
A private, Tinder-style web app for browsing the motorcycle market: swipe real listings (like / nope / maybe), see everything on one editable list, and let the deck learn your taste. Built for Glenn; runs on the Rotarran, published to the tailnet only.
|
||||
|
||||
**Status:** Phase 3 (personalization) built on `dev` (2026-11-11): sections of 25 (wildcards + second looks + new), taste engine with match points, tank gauges, dossier "Why this rank", real Trends tab, agent pass (mock / live / fallback). Phase 2 remains shipped as-is; deployment (podman stack) shipped 2026-11-12 — stack live under `podman-stack-bike-tinder.service`, nightly import + agent read + DB backup armed; the private phone URL (`https://biketinder.tailf35a34.ts.net`) awaits the one-time Tailscale sidecar authorization.
|
||||
**Status:** Live. Phases 1–3 are deployed (sections — wildcards + second looks + new; taste engine with match points; tank gauges; dossier "Why this rank"; real Trends tab; agent pass mock / live / fallback), B1 is live (photo galleries, deck flips, fullscreen viewer), and v1.1.0 (QOL patrol) was deployed + released on 2026-11-13. The podman stack has served since 2026-11-12 under `podman-stack-bike-tinder.service`, with the nightly import + agent read + DB backup armed; the private phone URL (`https://biketinder.tailf35a34.ts.net`) was authorized (Tailscale sidecar) on 2026-11-12 and is in use from Glenn's phone.
|
||||
|
||||
**B1 (photo experience v2)** built on `dev`: full photo galleries mirrored locally, deck flips (tap left/right thirds, clamped), segment bars, a fullscreen viewer (swipe nav, pinch/double-tap zoom, pull-down close), and repeat rotation (a re-shown card leads at its next photo). See UI-SPEC §11 + the "B1 develop notes" section below.
|
||||
**B1 (photo experience v2)** shipped 2026-11-11: full photo galleries mirrored locally, deck flips (tap left/right thirds, clamped), segment bars, a fullscreen viewer (swipe nav, pinch/double-tap zoom, pull-down close), and repeat rotation (a re-shown card leads at its next photo). See UI-SPEC §11 + the "B1 develop notes" section below.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -54,7 +54,7 @@ python3 -m app.importer --state-dir ~/.hermes/scripts/motorcycle-hunt.d --db dat
|
||||
# serve the app (127.0.0.1:9453 by default)
|
||||
.venv/bin/python -m app.serve --db data/bike-tinder.sqlite3 --port 9453
|
||||
|
||||
# self-check: P1 + P2 + P3 (25 checks, temp DB copies only)
|
||||
# self-check: P1–P3 + B1 + v1.1 (35 checks, temp DB copies only)
|
||||
.venv/bin/python -m app.selfcheck
|
||||
|
||||
# P3: agent pass — mock is deterministic + offline (tests, offline forge);
|
||||
@@ -131,9 +131,11 @@ Stage 2 + the task brief.
|
||||
`b1/test-img/` = staged `{id}_{n}.webp` photos · `b1/test-copy.sqlite3` =
|
||||
pristine live-DB snapshot (selfcheck copies it; never write to it).
|
||||
|
||||
## Deployment (planned)
|
||||
## Deployment (live)
|
||||
|
||||
A small podman compose stack on the Rotarran under `/var/mnt/DATA/podman-stacks/bike-tinder/`, its own systemd user unit (`Delegate=yes`), SQLite database. The stack ships its own Tailscale node (sidecar container — no host-tailnet coupling) and serves the app privately at `https://biketinder.tailf35a34.ts.net`. Single user; reachable only from Glenn's own devices.
|
||||
The app runs as a small podman compose stack on the Rotarran under `/var/mnt/DATA/podman-stacks/bike-tinder/` (a checkout of `release`), under its own systemd user unit (`podman-stack-bike-tinder.service`, `Delegate=yes`), SQLite database — deployed 2026-11-12. The stack ships its own Tailscale node (sidecar container — no host-tailnet coupling); the node was authorized on 2026-11-12, and the app serves privately at `https://biketinder.tailf35a34.ts.net` — in use from Glenn's phone. Single user; reachable only from Glenn's own devices.
|
||||
|
||||
The nightly chain (`bike-tinder-import`, 08:30 daily) keeps it current: catalog import → live agent read (taste profile) → SQLite backup into the stack's `data/backup/` (rotation 14 deep).
|
||||
|
||||
## Crew
|
||||
|
||||
|
||||
42
app/api.py
42
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')
|
||||
|
||||
876
app/fe_walk_v11.py
Normal file
876
app/fe_walk_v11.py
Normal file
@@ -0,0 +1,876 @@
|
||||
"""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', ' '))
|
||||
|
||||
# --- v1.1.1 (issue #2): the header pill keeps itself live --------------
|
||||
pl0 = api('/api/stats')
|
||||
key('ArrowRight') # probe swipe, undone right after
|
||||
flick(300)
|
||||
pl1 = api('/api/stats')
|
||||
dl = time.time() + 5
|
||||
ptx = ''
|
||||
while time.time() < dl:
|
||||
ptx = page.evaluate("() => document.getElementById('pill').textContent")
|
||||
if ('%d fresh' % pl1['fresh']) in ptx:
|
||||
break
|
||||
time.sleep(0.2)
|
||||
check(('%d fresh' % pl1['fresh']) in ptx and ('%d live' % pl1['live']) in ptx,
|
||||
'issue #2: pill refreshes itself after a swipe, no reload (%s -> %s)'
|
||||
% (pl0['fresh'], ptx))
|
||||
key('ArrowDown') # undo the probe swipe (net-zero)
|
||||
flick(300)
|
||||
pl2 = api('/api/stats')
|
||||
dl = time.time() + 5
|
||||
while time.time() < dl:
|
||||
ptx = page.evaluate("() => document.getElementById('pill').textContent")
|
||||
if ('%d fresh' % pl2['fresh']) in ptx:
|
||||
break
|
||||
time.sleep(0.2)
|
||||
check(('%d fresh' % pl2['fresh']) in ptx,
|
||||
'issue #2: pill refreshes itself after an undo, no reload (%s)' % ptx)
|
||||
|
||||
# --- 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())
|
||||
143
app/sections.py
143
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'],
|
||||
|
||||
519
app/selfcheck.py
519
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
|
||||
@@ -922,6 +934,10 @@ def test_p3_points_and_band():
|
||||
br = eng.breakdown(1)
|
||||
assert {b['source'] for b in br} <= {'rubric', 'taste', 'agent'}
|
||||
assert br[0]['source'] == 'rubric'
|
||||
# regression (#3): a listing OUTSIDE the engine's pool (delisted
|
||||
# mid-batch, never swiped) must degrade quietly, not crash the render
|
||||
assert eng.points(9999) is None
|
||||
assert eng.breakdown(9999) == []
|
||||
|
||||
|
||||
def test_p3_payload_additions():
|
||||
@@ -947,6 +963,39 @@ def test_p3_payload_additions():
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
|
||||
def test_issue3_section_survives_midsection_delist():
|
||||
"""#3 regression: one card delisted after compose must not 500 the deck.
|
||||
|
||||
The section render asks the taste engine for a breakdown of every
|
||||
card. A listing that left the active pool mid-batch AND has no swipe
|
||||
history is not in the engine's by_id map; the unguarded lookup used
|
||||
to raise, killing GET /api/section and GET /api/listing/{id} with a
|
||||
500 (live incident 2026-11-15: section 57 + delisted listing 298).
|
||||
"""
|
||||
d, c, db_path = p3_env()
|
||||
try:
|
||||
sec = c.get('/api/section')
|
||||
assert sec.status_code == 200, sec.status_code
|
||||
body = sec.json()
|
||||
conn = _sq_conn(db_path)
|
||||
ev_ids = {r['listing_id'] for r in conn.execute(
|
||||
'SELECT DISTINCT listing_id FROM swipe_events')}
|
||||
it = next((x for x in body['items'] if x['id'] not in ev_ids), None)
|
||||
assert it is not None, 'fixture section has no unswiped card'
|
||||
conn.execute("UPDATE listings SET status='gone' WHERE id=?",
|
||||
(it['id'],))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
r = c.get('/api/section')
|
||||
assert r.status_code == 200, r.status_code
|
||||
assert it['id'] in [x['id'] for x in r.json()['items']]
|
||||
one = c.get('/api/listing/%d' % it['id'])
|
||||
assert one.status_code == 200, one.status_code
|
||||
assert one.json()['points_breakdown'] == []
|
||||
finally:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
|
||||
# --- B1 checks (photo experience v2, UI-SPEC §11 / DATA-MODEL B1) -----------
|
||||
|
||||
def make_b1_item(title, imgs=None, img=None, price='$5,000', km='8000'):
|
||||
@@ -1279,6 +1328,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),
|
||||
@@ -1289,12 +1788,28 @@ P3_API_TESTS = [
|
||||
('p3: agentpass mock determinism + live fallback', test_p3_agentpass_mock_and_fallback),
|
||||
('p3: points/band function known answers', test_p3_points_and_band),
|
||||
('p3: payload additions (list/listing/section)', test_p3_payload_additions),
|
||||
('p3: mid-section delist keeps the deck up (#3)',
|
||||
test_issue3_section_survives_midsection_delist),
|
||||
]
|
||||
|
||||
|
||||
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),
|
||||
|
||||
@@ -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
|
||||
|
||||
<div class="stage" id="stage">
|
||||
<div class="empty" id="empty"><b>You're all caught up.</b>New bikes arrive nightly — next import 08:30.<br>↺ brings the last card back.</div>
|
||||
<div class="empty2" id="empty2"><b>No more new bikes!</b>New bikes arrive nightly — next import 08:30.<br>↺ brings the last card back.</div>
|
||||
<div class="report" id="report">
|
||||
<b>Section complete.</b>
|
||||
<div id="repCounts"></div>
|
||||
@@ -217,6 +225,7 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items
|
||||
<div id="repLine2"></div>
|
||||
<button class="rforge" id="btnForge">Forge next 25</button>
|
||||
<div class="rstatus" id="forgeStatus"></div>
|
||||
<div class="rcap" id="dryCap" style="display:none">Catalog fully reviewed — fresh listings arrive with the next 08:30 import; showing second looks</div>
|
||||
<div class="rmode" id="modeRow">
|
||||
<span id="modeOnd" class="on">on demand</span><i class="sep">⇄</i><span id="modeStr">strict</span>
|
||||
<div class="rcap" id="strictCap" style="display:none">Waiting for the nightly read — next section after the agent runs.</div>
|
||||
@@ -242,11 +251,12 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items
|
||||
<div class="detail" id="detail">
|
||||
<div class="dgrab" id="dgrab"><i></i></div>
|
||||
<button class="dclose" id="dclose">⌄</button>
|
||||
<div class="dhero"><img class="dphoto" id="dphoto" alt=""><div class="dherograd"></div></div>
|
||||
<div class="dhero"><div class="dblur" id="dblur"></div><img class="dphoto" id="dphoto" alt=""><div class="dherograd"></div></div>
|
||||
<div class="dbody" id="dbody">
|
||||
<div class="dtitle" id="dtitle"></div>
|
||||
<div class="drow"><div class="dprice" id="dprice"></div><div class="dsub" id="dsub"></div></div>
|
||||
<div class="dchips" id="dchips"></div>
|
||||
<div class="band dband" id="dband" style="display:none"></div>
|
||||
<div class="drank" id="drank" style="display:none">
|
||||
<div class="tankwrap" id="drankTank"></div>
|
||||
<div style="flex:1;min-width:0">
|
||||
@@ -319,6 +329,7 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items
|
||||
<div class="tstatus" id="runStatus"></div>
|
||||
</div>
|
||||
<div class="trow"><span>Forge:</span><span id="tOnd" class="on">on demand</span><i>⇄</i><span id="tStr">strict</span></div>
|
||||
<div class="trow"><span>Why breakdown:</span><span id="tRubOff" class="on">off</span><i>⇄</i><span id="tRubOn">on</span></div>
|
||||
<div class="tmuted" id="tLastRun"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -404,6 +415,7 @@ function setDecision(id, act, secId=null, photoIdx=null){
|
||||
localDecisions.set(id, act);
|
||||
updateRowDecision(id, act); // optimistic paint
|
||||
enqueue(id, act, secId, photoIdx);
|
||||
refreshStats(); // v1.1.1 (#2): every decision change nudges the header counters
|
||||
}
|
||||
|
||||
/* ---------- payload -> display helpers ---------- */
|
||||
@@ -479,14 +491,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 `<div class="band wild">WILDCARD · maybe you’ll like this one instead</div>`;
|
||||
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 `<div class="band sl">SECOND LOOK — you ${verb} this</div>`;
|
||||
return 'SECOND LOOK — you '+verb+' this';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
function bandHTML(item){ // card face only
|
||||
const lbl=bandLabel(item);
|
||||
if(!lbl) return '';
|
||||
return `<div class="band${item.slot==='wildcard'?' wild':' sl'}">${lbl}</div>`;
|
||||
}
|
||||
/* ---------- 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 +526,10 @@ function segHTML(n,cur){
|
||||
if(!n||n<2) return '';
|
||||
return `<div class="segments">${segBars(n,cur)}</div>`;
|
||||
}
|
||||
function escAttr(s){ return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,'''); }
|
||||
function phwrapHTML(src){
|
||||
return `<div class="phwrap">${src?`<img class="photo" src="${src}" alt="" draggable="false">`:`<div class="nophoto">Photo pending — thumbnail backfill queued</div>`}</div>`;
|
||||
const img=src?`<div class="phblur" style="background-image:url('${escAttr(src)}')"></div><img class="photo" src="${escAttr(src)}" alt="" draggable="false">`:`<div class="nophoto">Photo pending — thumbnail backfill queued</div>`;
|
||||
return `<div class="phwrap">${img}</div>`;
|
||||
}
|
||||
function attachPhError(entry){
|
||||
const wrap=entry.el.querySelector('.phwrap');
|
||||
@@ -642,10 +661,17 @@ async function loadSection(){
|
||||
buffer.push(...fresh);
|
||||
fillPile();
|
||||
updateSectionUI(body);
|
||||
refreshStats(); // v1.1.1 (#2): a new batch landed — freshen the counters
|
||||
}catch(e){ setTimeout(()=>loadSection(),15000); }
|
||||
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<DEPTH&&deckCursor&&!deckLoaded) loadDeck();
|
||||
@@ -653,23 +679,31 @@ function maybeRefill(){
|
||||
return;
|
||||
}
|
||||
if(!pile.length&&!buffer.length&&!deckLoading){
|
||||
if(section&§Decided>=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 +715,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 +751,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 +786,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?-1:1);
|
||||
if(rel>=w/3 && rel<=w*2/3){
|
||||
dEntry=entry; // live pile entry (cur photo index)
|
||||
openDetail(entry.d,{type:'deck'});
|
||||
}else{
|
||||
flipCard(entry, rel<w/3?-1:1); // no-ops on single/no-photo cards
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- docs/mockup core: fullscreen dossier ---------- */
|
||||
let dCtx=null;
|
||||
let dData=null; // B1: dossier payload (hero -> 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=>`<span class="chip">${x}</span>`).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 +844,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 +854,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 `<div class="${l.value>=0?'pos':'neg'}">· ${lbl}${n}</div>`;
|
||||
}).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 `<div class="${l.value>=0?'pos':'neg'}">· ${lbl}${n}</div>`;
|
||||
}).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 +887,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 +941,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,20 +1200,34 @@ let badgeFired=false;
|
||||
async function loadStats(){
|
||||
const res=await getJSON('/api/stats');
|
||||
if(!res) return;
|
||||
$('pill').innerHTML=`<b>${res.live}</b> live`+(res.new>0?` · <b>${res.new}</b> new`:'');
|
||||
const fresh=typeof res.fresh==='number'?res.fresh:null; // old API: no segment
|
||||
$('pill').innerHTML=`<b>${res.live}</b> live${fresh!=null?` · <b>${fresh}</b> fresh`:''}`+(res.new>0?` · <b>${res.new}</b> new`:'');
|
||||
$('lsub').innerHTML=`${res.live} active · `+(res.new>0?`<b style="color:#fff">${res.new} new</b>`:res.new+' new')+' since your last visit';
|
||||
if(!badgeFired){
|
||||
badgeFired=true;
|
||||
fetch('/api/badge/seen',{method:'POST'}).catch(()=>{});
|
||||
}
|
||||
}
|
||||
/* v1.1.1 (issue #2): loadStats only ran at boot, so the header pill and the
|
||||
list sub-line went stale after every swipe until a full reload. Refresh
|
||||
shortly after any decision change (swipes, undo, dossier/list/matches
|
||||
edits) and whenever a new batch lands. Coalesced + fire-and-forget: a
|
||||
swipe never waits on it. */
|
||||
let statsTimer=null;
|
||||
function refreshStats(){
|
||||
if(statsTimer) return; // bursts collapse into one read
|
||||
statsTimer=setTimeout(()=>{ statsTimer=null; loadStats(); },600);
|
||||
}
|
||||
/* foreground/resume: catch anything that changed while the app slept
|
||||
(e.g. the 08:30 import) without waiting for the next swipe */
|
||||
document.addEventListener('visibilitychange',()=>{ if(!document.hidden) refreshStats(); });
|
||||
|
||||
/* ---------- P3 section UI: progress pill + report + forge + modes ---------- */
|
||||
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 +1243,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 +1270,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 +1301,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)]);
|
||||
@@ -1195,7 +1312,14 @@ $('btnForge').onclick=async()=>{
|
||||
buffer.push(...fresh);
|
||||
fillPile();
|
||||
updateSectionUI(body);
|
||||
refreshStats(); // v1.1.1 (#2): forged batch — freshen the counters
|
||||
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 +1384,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 ---------- */
|
||||
|
||||
@@ -379,8 +379,13 @@ class TasteEngine:
|
||||
"""Source-labeled breakdown lines for the dossier."""
|
||||
row = self.by_id.get(listing_id)
|
||||
lines = []
|
||||
lines.append({'source': 'rubric', 'label': 'rubric fit',
|
||||
'value': int(row['score'] or 0)})
|
||||
# issue #3: a section can outlive an ad. A listing that left the
|
||||
# active pool mid-batch (and has no swipe history) is not in
|
||||
# by_id; its breakdown degrades to no-signal lines instead of a
|
||||
# TypeError that would 500 the whole section render.
|
||||
if row is not None:
|
||||
lines.append({'source': 'rubric', 'label': 'rubric fit',
|
||||
'value': int(row['score'] or 0)})
|
||||
for label, s, _d, _v in self.contributions(listing_id)[:3]:
|
||||
lines.append({'source': 'taste', 'label': label,
|
||||
'value': round(s, 1)})
|
||||
|
||||
@@ -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 `<b>K</b> 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -70,6 +76,7 @@ Glenn's approved design (2026-11-10):
|
||||
- ✅ DB + image mirror migrated intact into the stack volume; backup drill produced its first files
|
||||
- ✅ Deploy fix (found on first in-container agent call): `requests` was missing from requirements — the host venv had it incidentally, the slim image did not
|
||||
- ✅ Runbook in DESIGN.md (deploy/rollback/logs/import gates/backup-restore/phone checklist)
|
||||
- ✅ **Release v1.0.0 published 2026-11-12** — Gitea release, tag pinned to `main` head; notes summarize the working loop; source zip/tar.gz auto-generated by Gitea (nothing attached)
|
||||
- ✅ In use from Glenn's phone (2026-11-12 — first photo flips + swipes logged through the sidecar); PWA "Add to Home Screen" available; full reboot test optional/pending
|
||||
- **Exit criteria:** survives a reboot (service-level verified; power-level test optional); refreshes overnight unattended (armed, first automatic run 08:30 next day); installable and used from the phone — **achieved 2026-11-12.**
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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. The pill and the list sub-line refresh themselves shortly after any decision change (swipes, undo, dossier/list/matches edits) and whenever a new batch lands — no reload needed (v1.1.1, issue #2).
|
||||
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
|
||||
|
||||
|
||||
624
docs/V1.1-BUILD.md
Normal file
624
docs/V1.1-BUILD.md
Normal file
@@ -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 `<img class="photo">` 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` <style>):**
|
||||
|
||||
1. Deck card:
|
||||
- change line 36 to `object-fit:contain` (keep `position:absolute;inset:0;width:100%;height:100%`).
|
||||
- add inside `.phwrap` (512–513) a backdrop layer behind the sharp photo. HTML: when a `src` exists render `<div class="phblur"></div><img class="photo" ...>` (use an inline `style="background-image:url('...')"` from the same `photoSrc` value; escape the URL-attrs as the file already does elsewhere — the src is app-server-generated, but quote-escape it to be safe). CSS:
|
||||
```
|
||||
.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)}
|
||||
```
|
||||
The photo gets a touch of shadow so the edge reads on top of the blur: `filter:drop-shadow(0 6px 24px rgba(0,0,0,.45))` on `.card .photo`. The `nophoto` fallback path (513) renders no `.phblur`, naturally.
|
||||
- `container` presence check: `.card .grad` (37) sits over the fit area — fine (it is a scrim, UI-SPEC §2).
|
||||
2. Info-page hero:
|
||||
- change line 70 to `object-fit:contain`.
|
||||
- give `#dphoto` the same twin: add a `.dblur` div inside `.dhero` (245) *behind* the image, styled identically to `.phblur` (`.dhero` has no `overflow:hidden`; add it). Populate on `openDetail` (772 area): same `data.img` URL, emptied when no photo / error fallback (774). Simplest robust approach: reuse a tiny helper `setBackdrop(shellEl, url)` called from `openDetail` and `phwrapHTML`'s error/success paths.
|
||||
3. No-reflow guarantees: both fits put letterbox bars around the photo; because the photo is `position:absolute` and the blur is a sibling underneath, nothing shifts. Card title/text keep their spot (info block is below the fold of a 4:3-ish area; on unusually tall images the photo shrinks towards the center and the bottom gradient may clip more of it than today — accepted, that is the point of "uncropped").
|
||||
4. Cost note: `filter:blur(22px)` per card × up to 5 cards. GPU-composited in Chrome/Safari; on the older iPhone, if paint cost shows, drop the blur radius to 16px or disable the backdrop layer when `prefers-reduced-motion` — implementer's knob, not a spec change.
|
||||
|
||||
**Edge cases:**
|
||||
|
||||
- 0-photo listing: `.nophoto` panel unchanged, no backdrop, dossier hero dimmed as today.
|
||||
- Aspect extremes (portrait screenshots inside 16:9 crops): letterbox grows as the photo gets wider/taller; clamp with a `max-height:100%` implicit via fit (contain already does), nothing else needed.
|
||||
- B1 preloader (538–551) fetches `photoSrc` — unaffected.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
1. Computed style on a rendered deck card photo == `object-fit:contain`; a backdrop node exists per card and its background-image equals the photo's resolved URL; paddings/sizes of the info block are unchanged vs pre-change (measure before/after in the headless walk).
|
||||
2. Dossier hero photo == `contain`, backdrop present, spec table layout unmoved.
|
||||
3. Viewer `.vimg` computed style == `contain` (no diff), list `.thumb` == `cover` (no diff).
|
||||
4. 0-photo card and 1-photo card still render their fallbacks (B1 §11.7 fallback checks re-run).
|
||||
|
||||
**Parent verification:** headless computed-style assertions 1–4 + half a dozen screenshots (portrait + landscape cards, dossier, viewer) eyeballed against the demo's panel C.
|
||||
|
||||
---
|
||||
|
||||
## Item 4 — Minimal rubric + debug "why" toggle
|
||||
|
||||
**Goal (BACKLOG UI changes 3):** bike views show ONLY the gas-tank gauge + percent full (e.g. "76%"); a setting re-enables the full "why" breakdown for debugging. Wired via the existing `prefs` key/value API (same pattern as `forge_mode`).
|
||||
|
||||
**Where rubric displays today (index.html):**
|
||||
|
||||
- Dossier "Why this rank" block `#drank` (250–257), filled by `fillRank` (798–821): gauge (`drankTank`), `{points}/100` (805), breakdown lines (806–812), footer (813–814).
|
||||
- List rows: tank gauge only, inside `rowHTML` (985) — already minimal; **untouched**.
|
||||
- Deck card face: no rubric display — nothing to do.
|
||||
|
||||
**Plan:**
|
||||
|
||||
Backend (`app/api.py`):
|
||||
|
||||
1. Extend the allowed prefs set (656): `allowed = {'forge_mode': ('ondemand','strict'), 'rubric_debug': ('off','on')}` — the key/value API then accepts `{key:'rubric_debug', value:'off'|'on'}` with zero new plumbing (the endpoint at 652–666 is generic).
|
||||
2. Expose the value so the FE can paint: add to `section_state` (446–471) one field, e.g. `data['prefs'] = {'rubric_debug': get_pref(conn, 'rubric_debug', 'off')}` (same read shape as `data['mode']` at 453). `/api/section` and the forge response already go through `section_state`, so every section load carries it. No `/api/stats` change needed.
|
||||
3. Nothing else backend-side: the breakdown payload (`points_breakdown`) is already in section items (`include_breakdown=True`, 454–456) — cheap to keep sending.
|
||||
|
||||
Frontend (index.html):
|
||||
|
||||
4. `fillRank` (798–821) renders by default: the gauge (existing 804) + compact "76%" label. Change 805 from `data.tank.value+'/100'` to `data.tank.value+'%'` and move the label into the existing left gauge column or directly beside it (e.g. wrap gauge + `%` in the `.tankwrap`).
|
||||
5. Guard the detailed parts behind the pref: build the `dlines` HTML (806–812) and the `dfoot` line (813–814) ONLY when the pref is `'on'`; otherwise empty both and keep `#drank` visible with just gauge + `%` (the block itself persists so the minimal state is still "gauge + %", matching the BACKLOG ask).
|
||||
6. Toggle UI (findable by Glenn, harmless otherwise): add to the Trends "Actions" area (317–320) a second `.trow` exactly in the `setMode` style (1174–1179 + 1248–1249): "Why breakdown: on ⇄ off", both spans wired like `tOnd`/`tStr` to a new `setRubricDebug(m)` that POSTs `/api/prefs` and updates the sections-wide `section.prefs.rubric_debug` for the next `fillRank`. Persist per session: `updateSectionUI` (1157–1173) also stores `section.prefs` (already via body) so a re-loaded deck repaints correctly.
|
||||
7. `fillRank` reads `((section&§ion.prefs&§ion.prefs.rubric_debug)==='on')`; also keep the existing on-the-quiet `/api/listing/` enrichment for list rows (816–819) guarded the same way — when off, don't fetch (saves a request; nothing is displayed).
|
||||
|
||||
**Edge cases:**
|
||||
|
||||
- Legacy (no `/api/section`) mode: `section` is null → pref off → minimal display; the API call for the toggle then target isn't the issue (legacy servers return 404 on `/api/prefs` POST; FE silently ignores failure, `setMode` precedent 1175–1176).
|
||||
- `tank.value` null (unscored/deep rows): `fillRank` already hides the whole block (799–802) — unchanged.
|
||||
- The percent must be the rounded tank value (0–100 clamp is already applied in `tankSVG` 455).
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
1. Dossier for a scored listing shows gauge + "NN%" and NO breakdown lines, NO footer line, on a fresh load (pref unset).
|
||||
2. `POST /api/prefs {rubric_debug:'on'}` → 200 `{key,value}`; reload → same dossier shows breakdown lines + footer. `'off'` → they disappear. Invalid value → 400.
|
||||
3. List rows and card face carry zero rubric text in both states (regression).
|
||||
4. Pref survives server restart (SQLite `prefs`) and page reload; forge response carries it.
|
||||
|
||||
**Parent verification:**
|
||||
|
||||
1. TestClient: prefs happy path / 400 path; fresh-DB `/api/section` body includes `prefs.rubric_debug`.
|
||||
2. Headless: dossier DOM assertions with the pref off and on (steps A2).
|
||||
|
||||
---
|
||||
|
||||
## Item 5 — Section counter fix (the 2/2 → 3/2 bug)
|
||||
|
||||
**Goal (BACKLOG "Counter bug"):** count a card as decided only if decided DURING this section (server-side per-item flag, from events stamped with this section_id — the source `section_counts` uses); clamp to total; no premature "hot"/complete state.
|
||||
|
||||
**Root cause (verified):** `section_items` (sections.py 417–425) LEFT-JOINs `swipes` so items carry `decision` = lifetime verdict. A second-look card enters its section with `decision='like'|'maybe'|'nope'` already set, but it has NOT been decided in this section (no event with `section_id` = this section). `read_section` progress (473) and the FE (`updateSectionUI` 1160–1164) count those as decided → fresh batch paints "2/2" before any touch; the next swipe increments optimistically (`commit` 684) → "3/2". Fix = use only events with `e.section_id = section.id` as the decided set, plus clamping at the FE.
|
||||
|
||||
**Plan:**
|
||||
|
||||
Server (`app/sections.py`):
|
||||
|
||||
1. Add a helper next to `section_counts`:
|
||||
```sql
|
||||
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 = ? -- decisive: an event stamped in THIS section
|
||||
```
|
||||
as `section_decided_ids(conn, section) -> set(listing_id)`. (Filtering to `si.listing_id` membership keeps out-of-section stray events from a foreign section_id honest; the join is the same shape `section_counts` uses at 439–451.)
|
||||
2. Add the per-item flag on the read path: `section_items` cannot see events per row without a second join; cheaper — after building `items` in `read_section` (471–472), set `item['decided_this_section'] = item['id'] in decided_ids`. (`sqlite3.Row` is read-only until cast; either materialize items as dicts there or attach the flag in `api.section_state` (447–457) where items are rebuilt as full payloads anyway — recompute `section_decided_ids` once in `section_state` and add `p['decided_this_section'] = p['id'] in ids` alongside `p['slot']` at 454–456. Prefer this: one read, no Row mutation.)
|
||||
3. `read_section` progress (473): `decided = sum(1 for it in items if it['decided_this_section'])`. `maybe_exhaust` (457–466) **unchanged** — it must wait for real in-section verdicts too, which it now does automatically because `section_items` is ALSO the joined-swipes view it reads... No: `maybe_exhaust` reads `it['decision'] is not None`. Change line 462 to check `section_decided_ids(conn, section)` membership instead: `undecided = [it for it in items if it['id'] not in decided_ids]` and `if not undecided: close_section(...)`. Otherwise a returns-only section reports "complete" before a single strike. This is part of the same fix.
|
||||
4. `section_counts` (428–454) already derives like/maybe/nope from `decision` (432–438) — same lifetime-verdict flaw in *report numbers*. Change its per-item loop to use the same decided-ids set: count an action for this section ONLY when an event with this `section_id` exists and matches (`SELECT e.listing_id, e.action ... WHERE e.section_id=?` joined to `section_items` the same way; the existing `restruck`/`wildcard_hits` queries at 439–451 already do exactly this join — mirror it). Note: this does not change what is *stored*, only what the report shows.
|
||||
5. No DB writes anywhere in this item (flag is computed per read).
|
||||
|
||||
Frontend (`app/static/index.html`):
|
||||
|
||||
6. `updateSectionUI` (1157–1173) counting loop (1160–1163) → `if(local===undefined ? !!it.decided_this_section : local!=null) decided++`. (Local intent still wins while a swipe's POST is in flight — the optimistic window is seconds and cannot resurrect the 2/2 bug because it only ever reflects what the user just did in this session.)
|
||||
7. Clamp everywhere `sectDecided` is incremented, so a stale/foreign path can never paint past the total: `commit` (684) → `sectDecided=Math.min(sectTotal, sectDecided+1)`; `undo` (706) already clamps the floor. `paintSect` (1140–1145) additionally renders `Math.min(sectDecided, sectTotal)`.
|
||||
8. Hot state: `paintSect` toggles `hot` only when `sectDecided>=sectTotal` (1144, unchanged code) — with in-section counting this now fires only when the section truly has a verdict per card; `commit` keeps painting immediately after a full sweep (the server resync in `maybeRefill` 663–666 handles the exhausted flip and a rapid double-swipe resync on deck reload straightens the numbers, same as today).
|
||||
|
||||
**Edge cases:**
|
||||
|
||||
- **Undo of a swipe on a second look (return slot) that already has a lifetime verdict:** the DELETE restores the prior `swipes` row and clears no `section_id` event (out of scope — see Data safety), so the server's in-section decided count still contains that card until the section dies. The FE decrements optimistically (706), then a resync re-counts it. Net: `sectDecided` may show one more decided than the user feels, but NEVER past `sectTotal` thanks to the clamp, and never "hot" before the section is substantively done. This same artifact exists today, worse. Flagged in Open Questions (a true fix needs an event-deletion rule — data semantics, v1.2 territory).
|
||||
- List/matches edits during a section: swipes posted WITHOUT `section_id` (setDecision from rows, 844–845) → no event with this section_id → a card in the section whose verdict was flipped from the list is (correctly) not counted as decided-in-section. The deck will simply re-offer it if still in buffer — acceptable and consistent.
|
||||
- **Seeded legacy events have `section_id=NULL`** (db.py 111–117): they never count for any section — exactly the intended behaviour (they predate sections).
|
||||
- `decided_this_section` must be present for ALL items in `section_state` payloads or the FE `local===undefined` branch misfires — set it unconditionally (not only for returns).
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
1. Compose a returns-only section (fresh pool 0 — reproducible by DELETE-ing all fresh rows' absence or staging a DB where every active listing has a swipe): `/api/section` `progress.decided == 0` even though every `item.decision` is non-null; every `item.decided_this_section == false`.
|
||||
2. Post one swipe with `section_id`: progress 1/2; recount matches events; `paintSect` shows 1/2. Post the second: 2/2, `hot` class present, section flips to `report` on next read, report counts reflect the two verdicts (not lifetime).
|
||||
3. With 2 items and a double-swipe (two commits before any resync): painted pill never exceeds `2/2` (clamp assertion).
|
||||
4. A section that mixes new + return: decided counts only in-section events on both slots; a fresh item swiped from the LIST (no section_id) leaves `progress.decided` unchanged.
|
||||
5. Full P3 regression: compose→strike→rounds→report→forge loop green; undo restores the previous pill value minus one.
|
||||
|
||||
**Parent verification:**
|
||||
|
||||
1. Selfcheck additions in `selfcheck.py` (new checks against a temp DB, same style as existing) + the staged-DB walk in acceptance 1–4.
|
||||
2. Live-DB dry run: snapshot `swipe_events`, compute `section_decided_ids` for the current section, assert it matches the user-visible count story. No writes.
|
||||
|
||||
---
|
||||
|
||||
## Item 6 — Dry-catalog messaging
|
||||
|
||||
**Goal (BACKLOG "Dry-catalog messaging"):** whenever a composed/forged batch reports `new: 0`, surface "Catalog fully reviewed — fresh listings arrive with the next 08:30 import; showing second looks" in the UI and label forge results accordingly, so the deck never silently drips 2 cards.
|
||||
|
||||
**Why it happens:** `NEW_POOL_SQL` (sections.py 40–44) is empty once every active listing has a swipe row; `compose_section` still forges from `RETURNS_SQL` — under v1 formula a few cards (night-of-symptom: 2). By design, but invisible.
|
||||
|
||||
**Plan:**
|
||||
|
||||
Server: the recipe already carries the number. `compose_section` returns `recipe: {'new': ..., 'return': ..., 'wildcard': ...}` (377–386) and `section_state` (446–471) already exposes `recipe` in `read_section` (479–480). **No server change required for the number itself.** Optional hardening: `forge` (490–527) appends to its `note` when the composed recipe has 0 new — e.g. `note += ' · catalog fully reviewed'` — so `section_state(note=...)` returns a labeled state (469–470); the FE below is the primary surface.
|
||||
|
||||
Frontend (index.html):
|
||||
|
||||
1. Shared renderer: a function `dryLabel(body)` → `body.recipe && body.recipe.new===0` (and `body.items.length>0`, i.e. there IS a section being served) → the BACKLOG sentence. Wire it into both hydrations:
|
||||
- `updateSectionUI` (1157): show/hide a new caption node in the report block — reuse `#strictCap` pattern (223): add `<div class="rcap" id="dryCap">Catalog fully reviewed — fresh listings arrive with the next 08:30 import; showing second looks</div>` to `#report` (212–224 area; display:none default), toggled by `dryLabel`. Report is where the deck lands after the batch, so the message persists exactly where Glenn looks.
|
||||
- `btnForge` success path (1188–1198): when the forge response is dry → `$('forgeStatus').textContent = dry text` (instead of blanking at 1189–1190) and `dryCap` stays visible via the `updateSectionUI(body)` call (1197).
|
||||
2. Fresh inventory restored mid-session (import lands): `loadSection` already refetches on reload/resync — `updateSectionUI` recomputes `dryLabel` every time; no timer needed.
|
||||
3. Copy stays exactly the BACKLOG sentence (keep "08:30" hardcoded as today — same assumption the empty state makes).
|
||||
|
||||
**Edge cases:**
|
||||
|
||||
- Dry + zero items (nothing left at all): item 8's loud empty owns that state — `dryCap` must NOT show there (guard on `items.length>0`).
|
||||
- `recipe` absent/legacy API: no label; `dryLabel` returns false (body check).
|
||||
- Forge in `strict` mode can't be run anyway; the caption still paints from `GET /api/section` on load.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
1. Forge with `recipe.new:0` and >0 items → forgeStatus shows the exact sentence; report shows `dryCap`; a subsequent full-import + reload clears it (recipe.new>0).
|
||||
2. Non-dry forge → `dryCap` absent, forgeStatus behaves as before.
|
||||
3. Deck behaviour unchanged otherwise (no new fetch loops; swipes unaffected).
|
||||
|
||||
**Parent verification:** staged-DB forge with an emptied fresh pool; assert the DOM states in A1/A2 + the existing section-loop selfcheck green.
|
||||
|
||||
---
|
||||
|
||||
## Item 7 — Fresh counter (header)
|
||||
|
||||
**Goal (BACKLOG "Fresh counter"):** extend `/api/stats` with `fresh` = active listings with no swipe (the `NEW_POOL_SQL` predicate); header shows "N fresh" by the existing live/new pill. Zero reads at a glance.
|
||||
|
||||
**Plan:**
|
||||
|
||||
Backend (`app/api.py`, `stats`, 234–264):
|
||||
|
||||
1. Add one query next to `live` (238–240):
|
||||
```sql
|
||||
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)
|
||||
```
|
||||
— verbatim the `NEW_POOL_SQL` predicate (sections.py 40–44). Add `'fresh': fresh` to the return dict (254–262). Note the deliberate semantic: a like/nope/maybe row of ANY age removes freshness (fresh = never touched), which is the same predicate the pool uses. A maybe swiped 8+ days ago that the legacy deck re-admits is still not "fresh" — correct per definition.
|
||||
2. No other backend change: `fresh` can differ from `live - decided` because `swipes` rows are never deleted by undos that keep events, and list edits create rows too — define fresh exactly by the predicate above and do not try to reconcile.
|
||||
|
||||
Frontend (index.html, `loadStats`, 1127–1136):
|
||||
|
||||
3. Change the pill line 1130 to `<b>${res.live}</b> live` + `· <b>${res.fresh}</b> fresh` (always shown; `` fresh `0` when the pool is dry — that's the point) + the existing new-count only when >0. Spacing stays in the one `.pill` (206). Match the UI-SPEC §10.1 fit rule (no layout shift — a pill string is fine).
|
||||
4. Add the same fresh number to the list sub-line? No — keep `lsub` unchanged (1131); the header pill is the requested surface.
|
||||
|
||||
**Edge cases:**
|
||||
|
||||
- Fresh goes below 0/negative never (a COUNT).
|
||||
- Badge `new` counts first-seen-since-visit, an orthogonal thing; both can coexist in the pill.
|
||||
- Old API (missing `fresh`): `res.fresh` undefined → guard (`res.fresh??0` or `typeof res.fresh==='number'?res.fresh:0`), fallback renders no fresh segment.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
1. Fresh staging DB: 5 actives, 3 with a swipes row, 2 not → `/api/stats.fresh == 2`; header pill text contains "2 fresh".
|
||||
2. All-5 swiped → `fresh:0`; pill shows "0 fresh" (not hidden).
|
||||
3. Swiping one card → next `loadStats` shows 4 fresh (server read reflects the upsert; FE doesn't optimistically touch the stats pill — the pill updates on next stats fetch, same as today).
|
||||
|
||||
**Parent verification:** TestClient stats assertions on the staged DB + headless DOM text check after a swipe.
|
||||
|
||||
---
|
||||
|
||||
## Item 8 — Loud empty state ("No more new bikes!")
|
||||
|
||||
**Goal (BACKLOG "Empty state, loud"):** when the deck can serve nothing at all — no fresh, no eligible second looks, no wildcards — show a prominent "No more new bikes!" keeping the "next import 08:30" line. Today's `.empty` ("You're all caught up") is unreachable in section mode (only `legacyMode` shows it — `maybeRefill` 649–654) and the report card masks exhaustion.
|
||||
|
||||
**Plan:**
|
||||
|
||||
Server (`app/sections.py` + `app/api.py`):
|
||||
|
||||
1. New pool helper in `sections.py` (mirrors the composer's candidate queries, minus the per-section exclusions — "can the forge serve anything?"):
|
||||
```python
|
||||
def pool_counts(conn):
|
||||
fresh = count(NEW_POOL_SQL)
|
||||
rets = count(RETURNS_SQL)
|
||||
wilds = count(WILDCARD_SQL)
|
||||
```
|
||||
(`RETURNS_SQL` is the composer's own eligibility predicate — resting/one-shot/last-two rules included; the last-two exclusion inside `compose_section` (274–278) is skipped here so the answer is about *stock*, not *this batch*; document that choice.)
|
||||
2. `section_state` (446–471) gains `data['pool'] = secmod.pool_counts(conn)` (fresh/returns/wildcards). Every section GET + forge response carries it. No write.
|
||||
|
||||
Frontend (index.html):
|
||||
|
||||
3. **Loud empty element:** new `#empty2` beside `#report` in `#stage` (after 212), styled prominently (CSS near 95–97): big title 22px/800, larger panel (min-height ~220px), accent border, centered; copy:
|
||||
```
|
||||
<b>No more new bikes!</b>
|
||||
New bikes arrive nightly — next import 08:30.
|
||||
(↺ still brings the last card back — keep the line only if it remains true after item 1.)
|
||||
```
|
||||
4. **Reachability (the fix):**
|
||||
- `maybeRefill` (649–674) section-mode branch: BEFORE the `sectDecided>=sectTotal` report check, if `section.pool` exists and `!buffer.length&&!pile.length` and `section.pool.fresh===0 && section.pool.returns===0 && section.pool.wildcards===0` → show `#empty2`, hide `#report` and `#empty`.
|
||||
- `btnForge` success path (1188–1206): after `updateSectionUI`, run the same check against the new body — if the forge returned zero items and `pool` is all-zero, show `#empty2` and don't leave the report's Forge button inviting a wasted agent run (hide the button or keep row; show empty2 above it).
|
||||
- Report screen stays the state for "section finished, more remains" (pool non-zero) — unchanged.
|
||||
5. Keep `.empty` (legacy mode) as-is; `#empty2` exists only for section mode. Undo (`undo()` 702–720) removes `#empty2`'s `.on` class exactly like it clears `.empty`/`.report` (718–719).
|
||||
|
||||
**Edge cases:**
|
||||
|
||||
- The last-two-sections exclusion: a listing only appears in the last two sections' worth of items — pool query ignores that; near-total exhaustion could still leave 1–2 stale-but-served listings? No: `section_items` only blocks `RETURNS_SQL` re-offer, not `NEW`/`WILDCARD`; if pool counts say zero, compose genuinely returns zero cards (same SQLs). The tiny edge where pool>0 but compose emits 0 due to last-two+wanted-order interplay is handled by the `items.length===0` fallback: empty2 shows when a forge returns no cards AND pool is all-zero; if forge returns 0 items with pool>0 (should not happen), show empty2 with the dry line instead and log — implementer's guard, not spec.
|
||||
- Import lands at 08:30 → first visit reloads sections: pool.fresh>0 → empty2 clears automatically on the next `loadSection`. No push, same as all state today.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
1. Staged DB with zero fresh, zero eligible returns, zero wildcards: `/api/section` body includes `pool` all-zero and `items` empty; FE shows `#empty2.on` with the exact title "No more new bikes!" and the 08:30 line; `#report` NOT shown.
|
||||
2. `pool.returns>0` (dry but alive) → report card shown, `dryCap` (item 6) active, NO empty2.
|
||||
3. Undo restores the last card from empty2 → empty2 hides (718–719 parity).
|
||||
4. Report + forge loops still work when pool is non-zero (regression).
|
||||
|
||||
**Parent verification:** staged-DB headless walk A1–A4; plus the nightly-import path simulated by inserting one fresh listing + reload → normal deck.
|
||||
|
||||
---
|
||||
|
||||
## Item 9 — Batch formula v2 (compose rewrite)
|
||||
|
||||
**This item AMENDS the frozen P3 rule "≤5 returns per section"** (`RANKING.md:29`, `DATA-MODEL.md:165`, `sections.py` docstring 2–17 — all say ≤5 / max 5). Say so in the commit notes. New rule, Glenn's words (BACKLOG "Batch formula v2"):
|
||||
|
||||
- Every batch targets **25 cards**.
|
||||
- Fresh first (up to 23 slots); **≥2 second looks per batch whenever eligible stock exists** *(DEFAULT PENDING GLENN'S CONFIRMATION — the alternative, pure-fresh batches on rich days with second looks as fill only, is below)*; 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 (spec below); returns stay one-shot.
|
||||
- Fresh exhausted → second-looks-only batches; returns exhausted → fresh-only; both → item 8's loud empty state (forges may return 0 items — already legal: `compose_section` inserts whatever it picks, 388–401).
|
||||
|
||||
### Allocation (the exact slot math, spelling out the 23/25/2 interplay)
|
||||
|
||||
Wildcards keep their 2 slots (BACKLOG leaves them standing; `WILDCARDS=2` 31). Resolve the equations in this order:
|
||||
|
||||
```
|
||||
want_ret = 2 if len(ret_cands) >= 2 else len(ret_cands) # the >=2 guarantee
|
||||
(0 when ret_cands empty -> pure-fresh batch)
|
||||
want_wild = 2 if usable wildcard candidates exist else fewer
|
||||
fresh_cap = 23
|
||||
want_fresh = size(25) - want_ret - want_wild
|
||||
clamped to min(len(new_cands), fresh_cap)
|
||||
```
|
||||
1. Returns and wildcards take their reserved slots FIRST (returns before wildcards before fresh, so the guarantee can never be crowded out by fresh).
|
||||
2. Fresh fills the remainder: `want_fresh = 25 - want_ret - want_wild` (max 23 when want_ret=2&wild=0? No — min is 21 on a rich day with 2+2 reserved). Then, **fill slack**: if `len(ret_cands)` has more offers and `len(picked) < 25`, add returns in role order (maybe > like > nope, points DESC — existing key 305–310) until 25 or the pool dries.
|
||||
|
||||
The resulting shapes on Glenn's scenarios:
|
||||
|
||||
- **Rich day, returns exist:** 25 cards = up to 23 fresh + ≥2 second looks + 2 wildcards. With 2 wildcards AND the 2-return guarantee, a full deck actually reads 21 fresh / 2 returns / 2 wildcards. (This is the one arithmetic consequence of "≥2 always" + "wildcards keep 2" + "target 25". The veto alternative below restores 23 fresh + ≤2.)
|
||||
- **His dry-day example:** 5 fresh + fill returns up to total 25 → 5 fresh + up to 18 returns + 2 wildcards (or 20 returns + 0 wildcards when the wildcard pool is dry — his literal "5 fresh + 20 second looks"). Fractional pools (e.g. 5 fresh + 12 returns) produce a short section (<25) — correct; nothing is invented to pad.
|
||||
- **Fresh = 0:** returns-only section(s) up to 25 cards each until the ~one-shot pool (≈0–30 remaining tonight per BACKLOG) dries — burns fast; BACKLOG's noted consequence is accepted in v1.1: after that, only nightly imports feed the deck.
|
||||
- **Returns = 0:** fresh 25-card batches again (plus wildcards).
|
||||
- **Both 0:** the loud empty state (item 8). Forge then legitimately returns a zero-item composed row; the FE renders empty2 — the report screen's Forge button should also stop being the only thing on screen (item 8 handles the mask).
|
||||
|
||||
### Re-nope + family-cap rules for fill-returns
|
||||
|
||||
- Keep ≤2 nope-verdict returns per batch (`MAX_RETURN_NOPES` 30; the "surprising" gate at 317–321 stays for *reserved* nope slots only).
|
||||
- **Fill-returns relax the family cap:** when selected as fill and the choice is between skipping the cap and landing a short deck, raise the per-family limit for `return`-slot items from 4 to 8 (fill-returns only; fresh picks and reserved returns keep 4). Deterministic: fill sweeps role order; a candidate that would exceed 8 is skipped; <=4 is always preferred pass 1 (two-pass fill: first under cap 4, then under cap 8).
|
||||
- Returns stay one-shot (`RETURNS_SQL`'s `NOT EXISTS section_items slot='return'` 53–54) and re-noped items rest forever (50–52). **No change to those predicates.**
|
||||
- Prefer not to burn the whole one-shot pool in two batches? The BACKLOG explicitly notes the pool ~30 tonight and accepts the burn; no v1.1 policy change. **If Glenn instead wants a drip feed, that is the veto line in Open Questions.**
|
||||
|
||||
### Compose pseudocode (replaces the flavour of `compose_section` at 259–404; structure kept)
|
||||
|
||||
```
|
||||
compose_section(conn, engine, size=DEFAULT_SIZE, ...):
|
||||
now, rows_by_id, last_two # unchanged (266-271)
|
||||
new_cands = NEW_POOL_SQL - last_two # unchanged (273-274)
|
||||
ret_cands = RETURNS_SQL - last_two # unchanged (275-276)
|
||||
wild_cands = WILDCARD_SQL - last_two - excluded_fams # unchanged (277-289)
|
||||
fam_count, picked_ids # unchanged (291-292)
|
||||
top3 = pick_new(...) ; top_fams/top_bands # unchanged (294-298) — feeds wildcard
|
||||
# preference; pure pick, no commit
|
||||
|
||||
# 1) reserved returns: min(2 + shortfall_unknown, len(ret_cands)), role order
|
||||
reserved_rets = ret_cands sorted by role_order/points; take until want_ret_nopes-rule or 2-max;
|
||||
always count the 2 GUARANTEED slots (skipping only ineligible nopes)
|
||||
# 2) wildcards: n_wilds = 2 (existing novel/preferred pick 328-358, unchanged)
|
||||
# 3) fresh: want_fresh = size - len(rets_reserved) - n_wilds, capped 23; pick_new (360-362)
|
||||
# 4) fill returns: while len(rets)<size-n_wilds-len(picked_new) and ret_cands remain:
|
||||
next in role order; nope cap (<=2 total incl. reserved);
|
||||
family rescue pass at cap 8 for fill only
|
||||
# 5) repair bands on new slots only (unchanged 367-375)
|
||||
# 6) pinned order new -> returns -> wildcards (unchanged 364-365); recipe/JSON/inserts
|
||||
unchanged (377-404) with recipe.new/return/wildcard the real counts
|
||||
```
|
||||
|
||||
Deliberate simplifications vs a full re-derivation:
|
||||
|
||||
- `pick_new`'s family cap 4, band repair, wildcard novelty, excluded families, LAST_TWO exclusion: **all unchanged** (160–178, 181–256, 284–289, 329–358).
|
||||
- The `wanted_rets` line 304 dies; the reserved-returns stage replaces it. Constants at 28–33: `MAX_RETURNS` is deleted/renamed (fill is bounded by size), `MAX_RETURN_NOPES` stays 2, add `FRESH_CAP=23` and `MIN_RETURNS=2` (pending Glenn's confirmation — the DEFAULT in this spec).
|
||||
- `read_section`/`section_state`/FE need **zero changes for the formula itself** — a section of any size ≤25 renders as today (progress pill counts `body.progress.total`).
|
||||
|
||||
Edge cases:
|
||||
|
||||
- Fewer than 2 eligible returns but ≥1: guarantee degrades gracefully (1 return + fill from... nothing else exists, that's the pool) — never fake it.
|
||||
- `size` stays the clamp (`size=25` default; the forge endpoint 492–495 accepts 1..100 — body `size` unchanged in the FE which always sends 25/`{}`).
|
||||
- Batch smaller than 25 mid-drain: the FE, report, and progress all already key off `progress.total`; nothing to cap client-side.
|
||||
|
||||
The veto alternative (record for Glenn):
|
||||
|
||||
- **≥2-always** (this spec's DEFAULT): every batch with any eligible return carries ≥2 second looks, even on a rich fresh day (21/2/2 shape). Keeps Glenn's "min 2" literally true.
|
||||
- **Pure-fresh-on-rich-days:** reserve 2 wildcards + up to 23 fresh; returns used ONLY as fill when fresh < 23 (rich day → 23 fresh + 2 wildcards + 0 returns). This obeys "fresh first, up to 23" literally but drops the "min 2" guarantee on the days he has the most fresh to see. One boolean switch in the allocator (`min_ret = 2 if Glenn confirms else 0`); nothing else moves.
|
||||
|
||||
**Acceptance criteria (staged DBs, deterministic pools):**
|
||||
|
||||
1. Pool 30 fresh / 10 eligible returns / wildcards available → forge composes 25: fresh + wildcard + return counts match the ≥2 + fill math; `recipe` JSON exact; `progress.total == 25`.
|
||||
2. Pool 5 fresh / 20 eligible returns / wildcards available → 25 cards, 5 fresh, returns fill to 20 (with 2 wildcards → 18; without → 20); his example honoured modulo wildcards.
|
||||
3. Pool 23 fresh / 5 returns / wildcards available → fresh 21 + returns 2 + wildcards 2 (the ≥2 formula) — and this exact result is the sentence Glenn must sign off (Open Questions).
|
||||
4. Pool 0 fresh / 12 returns → 12-card returns-only section; ≤2 nopes present; no item repeated from either of the previous two sections (LAST_TWO).
|
||||
5. Pool 40 fresh / 0 returns → 25-card fresh batch, order points DESC, leftover 15 fresh roll and appear in the NEXT forge (compose twice, zero overlap, no re-offer).
|
||||
6. Pool 0/0/0 → composed section with `items=[]`, FE shows item 8's empty2.
|
||||
7. Family cap: a fill-heavy batch with a dominant family raises its return-slot count only to ≤8 and never violates ≤4 for fresh/reserved; band-repair still reaches 3 bands when possible.
|
||||
8. Re-nope gate: exactly ≤2 nope-verdict returns per batch; a rested item (round≥2 nope) never appears.
|
||||
|
||||
**Parent verification:**
|
||||
|
||||
1. `selfcheck.py` additions driving `compose_section` against generated temp DBs for A1–A8 (the module is pure Python + SQLite and already selfcheck-tested this way for P3).
|
||||
2. Full P3 loop regression on a rich and a dry DB: compose → strikes → rounds → report → forge → counts; trends numbers unchanged in shape.
|
||||
|
||||
---
|
||||
|
||||
## Item 10 — Second Look band pops
|
||||
|
||||
**Goal (BACKLOG "Second Look band pops"):** `.band.sl` is near-invisible today — re-tint to a **violet accent** (bold fill + border), on card face + info view. Wildcard stays amber for contrast. *(DEFAULT violet pending Glenn's nod — one-line re-tint if he prefers another hue.)*
|
||||
|
||||
**Current:** `.band.sl{color:rgba(255,255,255,.85);background:rgba(255,255,255,.10);border:1px solid rgba(255,255,255,.22)}` (141) — white ~10% on dark, matches the P3 §10.3 second-look recipe (142). The band appears where `bandHTML` renders it: card face (560) and — verify — the dossier does NOT currently render a band at all: `openDetail` (768–796) has no band markup. "Info view" for the pill therefore needs a small addition, see 3 below.
|
||||
|
||||
**Plan (CSS + one markup line):**
|
||||
|
||||
1. Replace line 141:
|
||||
```
|
||||
.band.sl{color:#c4b5fd;background:rgba(124,58,237,.22);border:1px solid rgba(167,139,250,.65);font-weight:800}
|
||||
```
|
||||
(violet #7c3aed family; text near-white-violet `#c4b5fd`; bold fill + crisp border). Uppercase "SECOND LOOK" label (486) reads instantly against this.
|
||||
2. `.band.wild` (140) untouched — amber stays the contrast.
|
||||
3. Dossier/info view: add the same band to the dossier body so "info view" carries it. In `openDetail`, after the chips (778), insert the band when `data.slot==='return'|'wildcard'`:
|
||||
- simplest: an anchor div in `#dbody` (246, e.g. `<div class="band" style="position:static;display:inline-block;margin-top:6px" id="dband"></div>` — `display:none` default) filled by `openDetail` with `bandHTML(data)` logic (reuse the string builder 482–488; note `.band` is `position:absolute` by default, so give the dossier variant the static class or a dedicated `.dband` style with the same palette, per UI-SPEC palette text below).
|
||||
- `.card.hasSegs .band{top:22px}` (188) already handles the card-face coexistence with segment bars.
|
||||
4. No other surface: list rows don't carry bands today (not required).
|
||||
|
||||
**Edge cases:**
|
||||
|
||||
- Wildcard + segments + violet coexistence on the card: bands at 22px (188), segments at 8px (185) — unchanged geometry.
|
||||
- The band wraps up to 2 lines (139 `max-width:78%`) — violet fill still legible.
|
||||
- Contrast check: `#a78bfa` border on `#0b0b0e`-family backgrounds ≈ 7:1; text `#c4b5fd` on the violet-22% fill ≈ 6.5:1 — passes WCAG AA while staying dark-mode.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
1. Card with `slot:'return'` renders a violet band; computed styles match the new palette (assert colour/background/border strings); wildcard card still amber.
|
||||
2. Dossier opened on a return card shows the same violet band; on a new-slot card, no band.
|
||||
3. Segments + band layout regression green (B1 walk).
|
||||
|
||||
**Parent verification:** computed-style assertions + two screenshots (card, dossier) over the tailnet or headless.
|
||||
|
||||
---
|
||||
|
||||
## Data safety
|
||||
|
||||
v1.1 makes **ZERO schema migrations, zero data mutations, no reset.** Every DATA-MODEL table and every existing row survives untouched; Glenn's ledger (swipes/events) is not read-modified in any new way.
|
||||
|
||||
Writes that happen in v1.1 are only the writes that already happen today, unchanged:
|
||||
|
||||
- `POST /api/swipe` (upsert `swipes` + `append_event`), `DELETE /api/swipe`, `POST /api/prefs` (one new key: `rubric_debug` — a new `prefs` row is the ONLY new write in v1.1, and it is a settings row, not data).
|
||||
- Compose/forge writes a new `sections` + `section_items` row set — the normal P3 flow, identical write shape.
|
||||
|
||||
New READS only:
|
||||
|
||||
- `/api/stats` +1 COUNT (fresh).
|
||||
- `GET /api/section` / forge response +2 small derived reads: `decided_this_section` flags (from `swipe_events` WHERE section_id — read-only), `pool` counts (the three existing candidate SQLs, counted).
|
||||
- `/api/section` payload +1 prefs read (`rubric_debug`).
|
||||
|
||||
No new tables, no new columns, no imports, no re-seeding (the `swipe_events` seed in `db.init_db`, db.py 109–117, runs only when empty and is not re-run by anything here, and v1.1 ships no changes to `schema.sql` or `P3_SCHEMA`).
|
||||
|
||||
Rollback: revert the commit; the only residue is the `rubric_debug` pref row, harmless if left.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions (blocking decisions → Glenn / Alex)
|
||||
|
||||
1. **Item 9 — the "≥2 second looks" guarantee on rich days (confirmed needed to build).** Default specced: every batch carries ≥2 returns whenever eligible stock exists → a full 25-deck on a rich day reads **21 fresh + 2 second looks + 2 wildcards** (fresh capped at 23 = 25-2, and the two wildcard slots come out of fresh). Alternative for Glenn to veto: pure-fresh batches on rich days (23 fresh + 2 wildcards + 0 returns; returns fill only when fresh < 23). One switch in the allocator; everything else identical.
|
||||
2. **Item 10 — violet nod.** Default `#7c3aed`/`#a78bfa`/`#c4b5fd` (see amendment text). Trivial to re-tint if he picks another hue.
|
||||
3. **Item 1 — Backspace vs ArrowDown for undo.** BACKLOG names ↓; the live v1.0 handler already uses Backspace. Spec keeps BOTH (↓ primary per Glenn's words, Backspace retained as the muscle-memory alias). Confirm or drop Backspace.
|
||||
4. **Item 2 — dossier opened from a deck card, swiped via its own buttons:** `openDetail` from the deck currently allows committing the top card from inside the dossier (`dAction` 838–848). Under info-first flow this stays. If Glenn wants the sheet to be read-only from the deck, that's a v1.2 behaviour change — not in this spec.
|
||||
5. **Item 5 — undo-on-a-return residual:** un-deciding a second look that carries a lifetime verdict restores the swipes row but leaves the `section_id`-stamped event; the in-section count re-includes it on resync (clamped so it can never exceed the total, but it can sit AT the total with one undone card). A clean fix needs an event-deletion/„undo" semantics decision (v1.2 territory alongside analytics); accepted as-is for v1.1 and stated explicitly so the parent doesn't chase it as a leak.
|
||||
6. **Item 9 — one-shot return-pool burn:** with fill-to-25 the remaining eligible second looks (~≤30 as of 2026-11-12) drain within a batch or two. Accepted per BACKLOG's noted consequence; the drip-vs-burn preference belongs to Glenn before build if he cares.
|
||||
7. **Item 8 — copy retention:** "↺ brings the last card back" line kept only if still true after item 1's undo mapping (it is — undo exists either way). Any copy tweaks Glenn wants on "No more new bikes!" (exclamation, wording) noted before build.
|
||||
8. **Found while verifying (pre-existing, NOT v1.1 scope — flagged):** `maybe_exhaust` uses lifetime `decision` (sections.py 462) and the FE send of `photo_idx` is conditional on `nPhotos>1` (683) so single-photo cards don't update rotation; both are covered or neutral after item 5, no action. Also verified: the live "keyboard arrows" feature in v1.0 was live-deployed ahead of the BACKLOG as part of the P2 build (DESIGN.md:63, index.html 1263) — v1.1 formalizes and extends it rather than introducing it.
|
||||
|
||||
---
|
||||
|
||||
## Proposed UI-SPEC amendments (verbatim replacement text — DO NOT apply during the docs phase)
|
||||
|
||||
### A) §11.4 "Doors" — replace the whole subsection's body paragraph
|
||||
|
||||
> ### 11.4 Doors (supersedes the old tap→dossier)
|
||||
>
|
||||
> - ⓘ (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.
|
||||
> - 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).
|
||||
|
||||
### B) §11.3 "Fullscreen viewer" — amend the entry sentence
|
||||
|
||||
> - Tap the CENTER third → fullscreen viewer opens on the photo currently shown.
|
||||
>
|
||||
> becomes:
|
||||
>
|
||||
> - 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.
|
||||
|
||||
### C) §2 card geometry — photo fit: add a new bullet after "Photo full-bleed"
|
||||
|
||||
> - Photo fit is **contain**: 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).
|
||||
|
||||
### D) §10.3 Card bands — replace the SECOND LOOK palette line
|
||||
|
||||
> - **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)`.
|
||||
>
|
||||
> becomes:
|
||||
>
|
||||
> - **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`.
|
||||
|
||||
### E) §10.5 Dossier "Why this rank" — add a debug-visibility paragraph
|
||||
|
||||
> - 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).
|
||||
|
||||
### F) §10.1 Sections — replace the composition sentence within the section-progress bullet
|
||||
|
||||
> - Section composition (engine side — RANKING.md): ≤5 SECOND LOOK cards + 2 WILDCARD cards + the remainder new.
|
||||
>
|
||||
> becomes:
|
||||
>
|
||||
> - 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.
|
||||
|
||||
### G) §2 Empty state + §10.2 report — add the loud exhausted state
|
||||
|
||||
> - §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.
|
||||
|
||||
### H) §1 Globals — header pill
|
||||
|
||||
> - 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).
|
||||
|
||||
### I) §10.2 Forge — dry-catalog note
|
||||
|
||||
> - 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.
|
||||
|
||||
(Collecting all in one place per protocol — the file itself is not touched in this phase.)
|
||||
|
||||
---
|
||||
|
||||
## Verification plan (summary the parent will re-run after the build)
|
||||
|
||||
1. Fresh-clone checkout of the v1.1 build, temp DB copies only (never the live DB): existing selfcheck + new v1.1 checks, all green.
|
||||
2. Per item with the acceptance criteria above: 1 (keyboard: key dispatch, guards, scroll, Esc stack) · 2 (tap-flow walk + B1 §11.7 suite) · 3 (computed styles + screenshots vs demo panel C) · 4 (prefs API 200/400, dossier DOM with pref off/on) · 5 (staged returns-only section: 0/2 → 1/2 → 2/2 → report, clamp, list-edit neutrality) · 6 (dry forge → caption + status line) · 7 (stats fresh count + pill text) · 8 (0/0/0 pool → loud empty2, report mask gone) · 9 (A1–A8 compose matrix on generated pools) · 10 (violet band computed styles, wildcard unchanged, segments coexistence).
|
||||
3. Full section loop regression: compose → strikes → rounds → report → forge on a rich DB and a dry DB; trends, deck feed, undos unchanged in shape.
|
||||
4. Zero-mutation audit: diff against `381b6c8` shows no `schema.sql`/`P3_SCHEMA`/migration writes; `rubric_debug` pref row is the only new write.
|
||||
5. Glenn phone pass (human): keyboard on MacBook; center-tap data-first on phone; violet band visible; fresh counter reads; empty2 appears when shown a dry copy (staged), loud and clear.
|
||||
Reference in New Issue
Block a user