diff --git a/app/api.py b/app/api.py index 91133c8..f4a57d1 100644 --- a/app/api.py +++ b/app/api.py @@ -238,6 +238,14 @@ def create_app(db_path, img_dir, state_dir=None): live = conn.execute( "SELECT COUNT(*) AS n FROM listings WHERE status='active'" ).fetchone()['n'] + # item 7: fresh = active with no swipe row (the deck's new-pool + # predicate, verbatim). A swipe of ANY age removes freshness. + fresh = conn.execute( + "SELECT COUNT(*) AS n FROM listings l" + " WHERE l.status='active'" + " AND NOT EXISTS (SELECT 1 FROM swipes s" + " WHERE s.listing_id = l.id)" + ).fetchone()['n'] badge = get_pref(conn, 'badge_seen_at') or '' new = conn.execute( "SELECT COUNT(*) AS n FROM listings" @@ -253,6 +261,7 @@ def create_app(db_path, img_dir, state_dir=None): ' ORDER BY id DESC LIMIT 1').fetchone() return { 'live': live, + 'fresh': fresh, 'new': new, 'likes': per['like'], 'maybes': per['maybe'], @@ -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)) diff --git a/app/sections.py b/app/sections.py index a9e6139..5f09520 100644 --- a/app/sections.py +++ b/app/sections.py @@ -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).""" diff --git a/app/selfcheck.py b/app/selfcheck.py index 8a240e6..320393a 100644 --- a/app/selfcheck.py +++ b/app/selfcheck.py @@ -382,7 +382,17 @@ def test_api_stats_health(): conn.close() s = c.get('/api/stats').json() assert s['live'] == active, (s['live'], active) - assert set(s) == {'live', 'new', 'likes', 'maybes', 'nopes', 'last_import'} + assert set(s) == {'live', 'fresh', 'new', 'likes', 'maybes', + 'nopes', 'last_import'} # v1.1 item 7: + fresh + # fresh = actives with no swipe row (the deck's new-pool predicate) + conn = sqlite3.connect(os.path.join(d, 'test.sqlite3')) + expect_fresh = conn.execute( + "SELECT COUNT(*) AS n FROM listings l" + " WHERE l.status='active'" + " AND NOT EXISTS (SELECT 1 FROM swipes s" + " WHERE s.listing_id = l.id)").fetchone()[0] + conn.close() + assert s['fresh'] == expect_fresh, (s['fresh'], expect_fresh) assert s['last_import'] is not None and s['last_import']['ok'] is True assert s['new'] == expect_new, (s['new'], expect_new) h = c.get('/api/health').json() @@ -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: diff --git a/app/static/index.html b/app/static/index.html index 01bd9ca..59b8392 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -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