Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7088304eda | ||
|
|
beeace231d | ||
|
|
f252ea06c7 | ||
|
|
79e7a4f61f | ||
|
|
618cd4a61c |
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
|
||||
|
||||
|
||||
@@ -544,6 +544,33 @@ def walk_rich():
|
||||
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)
|
||||
|
||||
@@ -934,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():
|
||||
@@ -959,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'):
|
||||
@@ -1751,6 +1788,8 @@ 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),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -415,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 ---------- */
|
||||
@@ -660,6 +661,7 @@ 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();
|
||||
@@ -1206,6 +1208,19 @@ async function loadStats(){
|
||||
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;
|
||||
@@ -1297,6 +1312,7 @@ $('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
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -90,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 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.
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user