feat(v1.1): items 7-8 — fresh counter pill + loud 'No more new bikes!' empty state

Item 7: /api/stats gains fresh (actives with no swipe row — the deck's
new-pool predicate verbatim); the header pill reads 'N live · M fresh'
(new badge unchanged); old APIs without fresh render no segment.

Item 8: sections.pool_counts() answers can-the-forge-serve-anything from
the composer's own candidate SQLs (stock answer, LAST_TWO not applied)
and rides every section_state body. The FE adds #empty2 ('No more new
bikes!' + 08:30 line + undo note): shown whenever the pile/buffer drain
and pool is 0/0/0 (report masked), on a zero-item dry forge too; undo
hides it; legacy .empty untouched. UpdateUI repaints the caption per
hydrate; the stats key-set selfcheck gains fresh.
This commit is contained in:
Torres
2026-11-13 13:42:08 -04:00
parent 0d2e5a592f
commit a1455aade4
4 changed files with 155 additions and 3 deletions

View File

@@ -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'],
@@ -463,6 +472,7 @@ def create_app(db_path, img_dir, state_dir=None):
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))

View File

@@ -442,6 +442,20 @@ def section_decided_ids(conn, section):
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)."""

View File

@@ -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()
@@ -1362,6 +1372,93 @@ def test_v11_item5_section_counter():
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)
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),
@@ -1381,6 +1478,10 @@ if API_DEPS:
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),
]
if API_DEPS:

View File

@@ -97,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}
@@ -212,6 +216,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>
@@ -652,6 +657,12 @@ async function loadSection(){
finally{ deckLoading=false; }
maybeRefill();
}
/* item 8: the deck can serve nothing at all (pool all-zero) -> loud empty */
function poolDead(){
return !!(section && section.pool
&& section.pool.fresh===0 && section.pool.returns===0
&& section.pool.wildcards===0);
}
function maybeRefill(){
if(legacyMode){
if(buffer.length<DEPTH&&deckCursor&&!deckLoaded) loadDeck();
@@ -659,23 +670,31 @@ function maybeRefill(){
return;
}
if(!pile.length&&!buffer.length&&!deckLoading){
if(section&&sectDecided>=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&&sectDecided>=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');
}
}
@@ -723,6 +742,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'); };
@@ -1163,7 +1183,8 @@ 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;
@@ -1262,6 +1283,12 @@ $('btnForge').onclick=async()=>{
fillPile();
updateSectionUI(body);
if(pile.length){ $('report').classList.remove('on'); }
// item 8: a zero-item forge on a fully drained catalog -> empty2,
// no lone Forge button inviting a wasted agent run
if((body.items||[]).length===0 && poolDead()){
$('report').classList.remove('on');
$('empty2').classList.add('on');
}
}else{
$('forgeStatus').textContent= body.detail||'Forge failed';
paintMode();