app: no-build PWA frontend (fidelity port) + image mirror

- app/static/index.html: single-file app — DOM/CSS/stack engine ported
  verbatim from reference/mockup-001-fidelity.html (DEPTH/SC/TY/DIM, drag/
  fly/undo, one .topcard, no-lock commits, stamps, dossier sheet,
  pull-to-close, empty state); production deltas only (UI-SPEC §7): deck
  buffer + cursor refill (40/page), optimistic swipe persistence with
  2s/5s/10s/15s retry queue + toast, badge-once-per-session, real list
  filters + search (server-side), real Matches screen, /img/{id} everywhere
  with placeholder fallback, SW registration, safe areas, desktop centering
- manifest.json + sw.js (precache shell, network-first API, cache-first imgs)
- icons: 192/512/maskable/apple-touch-180, dark tile with the brand
  accent-gradient mark (Pillow)
- app/imgfetch.py: local mirror, hunt's Chrome-120 UA, ~1s pacing,
  skip-existing, .part writes atomic, per-item failure isolation,
  fetched/skipped/failed summary; verified: --limit 15 -> 15 fetched, full
  run 5 more, re-run 0 fetched 20 skipped (20/20 mirrored)
This commit is contained in:
Torres
2026-11-10 21:21:27 -04:00
parent 5df2a20bac
commit 57fae0df49
9 changed files with 943 additions and 0 deletions

114
app/imgfetch.py Normal file
View File

@@ -0,0 +1,114 @@
"""Bike-Tinder local image mirror.
Downloads each active listing's card image once into data/img/{id}.webp.
.venv/bin/python -m app.imgfetch --db data/bike-tinder.sqlite3
.venv/bin/python -m app.imgfetch --limit 15
Polite by design: ~1 req/s (sleep between fetches), skip-existing, crash-safe
(partial bytes land in .part and are atomically renamed), per-item failures
never kill the run. Honest end summary: `fetched N · skipped M · failed K`.
UA + habits match the hunt scanner (reference/motorcycle-hunt-scan.py) so the
CDN sees the same client as the crawl. Stdlib only — no requests dependency.
"""
import argparse
import os
import sys
import time
import urllib.error
import urllib.request
from . import db
# Same UA string the hunt scanner uses.
UA = ('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')
SLEEP = 1.0
TIMEOUT = 20
def is_webp(buf):
return (len(buf) >= 12 and buf[:4] == b'RIFF' and buf[8:12] == b'WEBP')
def fetch_one(img_url, dst):
"""Download img_url -> dst. Returns 'fetched' or an error string."""
part = dst + '.part'
req = urllib.request.Request(
img_url, headers={
'User-Agent': UA,
'Accept-Language': 'en-CA,en;q=0.9',
'Accept': 'image/avif,image/webp,image/*,*/*;q=0.8',
})
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
if getattr(resp, 'status', 200) != 200:
return 'HTTP %s' % resp.status
buf = resp.read()
if not is_webp(buf):
return 'not webp (%d bytes, %r)' % (len(buf), buf[:8])
with open(part, 'wb') as fh:
fh.write(buf)
os.replace(part, dst) # atomic: never a half file under the final name
return 'fetched'
except urllib.error.HTTPError as e:
return 'HTTP %s' % e.code
except Exception as e: # noqa: BLE001 — one bad item never kills the run
return '%s: %s' % (type(e).__name__, e)
finally:
if os.path.exists(part):
try:
os.remove(part)
except OSError:
pass
def run_fetch(db_path, img_dir, limit=None, sleep=SLEEP, quiet=False):
conn = db.connect(db_path)
db.init_db(conn)
rows = conn.execute(
"SELECT id, img_url FROM listings"
" WHERE status='active' AND img_url IS NOT NULL"
" ORDER BY id").fetchall()
conn.close()
os.makedirs(img_dir, exist_ok=True)
fetched = skipped = failed = 0
for row in rows:
if limit is not None and fetched >= limit:
break
dst = os.path.join(img_dir, '%d.webp' % row['id'])
if os.path.exists(dst):
skipped += 1
continue
result = fetch_one(row['img_url'], dst)
if result == 'fetched':
fetched += 1
else:
failed += 1
if not quiet:
print(' fail id=%d %s' % (row['id'], result))
time.sleep(sleep)
return {'fetched': fetched, 'skipped': skipped, 'failed': failed}
def main(argv=None):
ap = argparse.ArgumentParser(description='Bike-Tinder image mirror')
ap.add_argument('--db', default='data/bike-tinder.sqlite3')
ap.add_argument('--img-dir', default='data/img')
ap.add_argument('--limit', type=int, default=None,
help='stop after this many downloads (small test runs)')
ap.add_argument('--sleep', type=float, default=SLEEP,
help='seconds between fetches (default %.1f)' % SLEEP)
args = ap.parse_args(argv)
res = run_fetch(args.db, args.img_dir, limit=args.limit, sleep=args.sleep)
print('fetched %d · skipped %d · failed %d' % (
res['fetched'], res['skipped'], res['failed']))
return 0
if __name__ == '__main__':
sys.exit(main())

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

733
app/static/index.html Normal file
View File

@@ -0,0 +1,733 @@
<!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}
</style>
</head>
<body>
<div class="app">
<header>
<div class="brand"><span class="flame"></span>Bike-Tinder</div>
<div class="pill" id="pill">—</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>
<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="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="stub" id="stubTrends"><b>Trends — Phase 3</b>The deck learns your taste as you swipe.</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 from GET /api/deck (payloads)
let deckCursor=null, deckLoaded=false, deckLoading=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, fails, warned}
let qRunning=false, qTimer=null, qWarned=false;
function enqueue(listingId, act){ // act: 'like'|'nope'|'maybe'|null (delete)
pend.set(listingId,{act,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({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){
updateRowDecision(id, act); // optimistic paint
enqueue(id, act);
}
/* ---------- 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 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 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>
<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: cursor pages of 40; refill whenever the buffer thins */
async function loadDeck(){
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; }
}
function maybeRefill(){
if(buffer.length<DEPTH&&deckCursor&&!deckLoaded) loadDeck();
if(!pile.length&&!buffer.length&&(deckLoaded||(!deckCursor&&!deckLoading))) $('empty').classList.add('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);
setDecision(t.d.id, dir==='maybe'?'maybe':dir); // optimistic persist
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
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');
}
$('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||'#';
$('detail').classList.add('on'); $('dback').classList.add('on');
$('dbody').scrollTop=0;
}
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="rprice"></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(()=>{});
}
}
/* ---------- 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');
$('stubTrends').classList.toggle('on',name==='trends');
if(name==='list') fetchList(); // fresh decisions every visit
else if(name==='matches') fetchMatches();
});
/* ---------- 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();
loadDeck();
/* 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>

28
app/static/manifest.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "Bike-Tinder",
"short_name": "Bike-Tinder",
"description": "Swipe through Nova Scotia and New Brunswick motorcycle listings.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0b0b0e",
"theme_color": "#0b0b0e",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

67
app/static/sw.js Normal file
View File

@@ -0,0 +1,67 @@
/* Bike-Tinder service worker.
- Precache the app shell at install; index.html + sw.js are served with
Cache-Control: no-cache so updates are picked up.
- /api/* : network-first; offline -> 503 JSON (the page toasts "Offline").
- /img/* : cache-first with network fill (mirrored thumbnails stay usable
offline once seen).
- Everything else (shell/navigation): network-first, fall back to cache.
*/
const VERSION = 'bike-tinder-v1';
const SHELL = [
'/',
'/manifest.json',
'/apple-touch-icon.png',
'/icons/icon-192.png',
'/icons/icon-512.png',
'/icons/icon-512-maskable.png'
];
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(VERSION)
.then((c) => c.addAll(SHELL))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (e) => {
const req = e.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
if (url.pathname.startsWith('/api/')) {
e.respondWith(
fetch(req).catch(() => new Response(JSON.stringify({ error: 'offline' }),
{ status: 503, headers: { 'Content-Type': 'application/json' } }))
);
return;
}
if (url.pathname.startsWith('/img/')) {
e.respondWith(
caches.match(req).then((hit) => hit || fetch(req).then((res) => {
if (res.ok) {
const clone = res.clone();
caches.open(VERSION).then((c) => c.put(req, clone));
}
return res;
}).catch(() => new Response('', { status: 404 })))
);
return;
}
// shell + navigation: network first, cache fallback (offline launch)
e.respondWith(
fetch(req).then((res) => res).catch(() =>
caches.match(req).then((hit) => hit || caches.match('/'))
)
);
});