Files
Bike-Tinder/app/static/index.html
Torres 04463cadcd P3: personalization core — event log + taste engine + section engine + agent pass + API + frontend
- db.py: guarded P3 tables (swipe_events/sections/section_items/taste_profile/
  agent_runs), one-time event seeding from swipes, append_event round math
- taste.py: deterministic taste engine (action x round x recency weights,
  n/(n+3) shrink, ramp w=min(.85,N/60), percentile norm, agent adjustments
  +/-10, points + source-labeled breakdown + tank bands)
- sections.py: compose/read/exhaust/forge — 2 wildcards + <=5 returns
  (<=2 nopes, surprising only) + remainder new, <=4/family, >=3 price
  bands w/ repair, returns maybe>like>nope, last-two-section exclusion,
  ondemand/strict modes
- agentpass.py: mock (deterministic) / live (OpenRouter, one retry) /
  fallback; strict payload schema; --print-summary/--dry-run
- api.py: GET /api/section, POST /api/section/forge, GET /api/trends,
  POST /api/agent/run, POST /api/prefs; P3 payload fields (points, tank,
  slot, round_next, points_breakdown); events stamped with section_id
- frontend: section buffer + progress pill, report screen + forge + mode
  toggle, WILDCARD/SECOND LOOK bands, tank gauges (list + dossier), dossier
  Why-this-rank, real Trends tab, /api/deck fallback for pre-P3 API, sw v2
- selfcheck: 9 new P3 checks (25/25 green)
2026-11-10 23:18:26 -04:00

1042 lines
50 KiB
HTML

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