← back to Wallco Ai
hot-or-not: filter broken images, always 6 thumbs, NOT modal with reason chips + notes
9cf27ca5f227e597c1ff027083facf794ae7b0c1 · 2026-05-13 11:16:06 -0700 · Steve Abrams
- /api/design/random now scans a pool of 30 and returns the first whose
image file actually exists on disk (was ~50% broken)
- /api/hot-or-not/recent hydrates the 6-thumb row from prior votes per
voter_session
- /api/hot-or-not/reasons merges top historic reasons with a 12-item
default fallback
- POST /user-vote accepts reasons[] + note. If schema is migrated, writes
to wallco_votes; if not, appends to logs/hon-reasons-queue.jsonl for
later replay
- frontend always renders 6 history slots (dashed skeletons when empty),
uses onerror to hide any image that does slip through
- NOT button opens a modal with reason chips + free-text note before
firing the vote. Esc closes, Cmd/Ctrl+Enter submits
- sql/002_hot_or_not_reasons.sql is the prod migration — DRAFT, not yet
applied
Files touched
M server.jsA sql/002_hot_or_not_reasons.sql
Diff
commit 9cf27ca5f227e597c1ff027083facf794ae7b0c1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 13 11:16:06 2026 -0700
hot-or-not: filter broken images, always 6 thumbs, NOT modal with reason chips + notes
- /api/design/random now scans a pool of 30 and returns the first whose
image file actually exists on disk (was ~50% broken)
- /api/hot-or-not/recent hydrates the 6-thumb row from prior votes per
voter_session
- /api/hot-or-not/reasons merges top historic reasons with a 12-item
default fallback
- POST /user-vote accepts reasons[] + note. If schema is migrated, writes
to wallco_votes; if not, appends to logs/hon-reasons-queue.jsonl for
later replay
- frontend always renders 6 history slots (dashed skeletons when empty),
uses onerror to hide any image that does slip through
- NOT button opens a modal with reason chips + free-text note before
firing the vote. Esc closes, Cmd/Ctrl+Enter submits
- sql/002_hot_or_not_reasons.sql is the prod migration — DRAFT, not yet
applied
---
server.js | 332 +++++++++++++++++++++++++++++++++++++----
sql/002_hot_or_not_reasons.sql | 22 +++
2 files changed, 323 insertions(+), 31 deletions(-)
diff --git a/server.js b/server.js
index dd23078..0cc7fe1 100644
--- a/server.js
+++ b/server.js
@@ -11015,21 +11015,68 @@ function voterSession(req, res) {
app.post('/api/design/:id/user-vote', (req, res) => {
const id = parseInt(req.params.id, 10);
- const s = parseInt((req.body || {}).score, 10);
- const quick = !!(req.body || {}).quick;
+ const body = req.body || {};
+ const s = parseInt(body.score, 10);
+ const quick = !!body.quick;
if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
if (!Number.isFinite(s) || s < 1 || s > 5) return res.status(400).json({ error: 'score 1..5 required' });
+
+ // Reasons + note are NOT-only metadata — but accept them on any score for forward-compat.
+ // Reasons: array of short strings, cap at 12. Note: free text, cap at 2 KB.
+ const reasons = Array.isArray(body.reasons)
+ ? body.reasons.map(r => String(r || '').trim()).filter(Boolean).slice(0, 12)
+ : [];
+ const note = (typeof body.note === 'string') ? body.note.trim().slice(0, 2048) : '';
+
const session = voterSession(req, res);
+ const sqlEsc = (v) => String(v).replace(/'/g, "''");
try {
- psqlQuery(`INSERT INTO wallco_votes (design_id, voter_session, score, quick)
- VALUES (${id}, '${session}', ${s}, ${quick ? 'TRUE' : 'FALSE'})
- ON CONFLICT (design_id, voter_session) DO UPDATE SET score=EXCLUDED.score, created_at=now();`);
+ // Detect once whether the schema migration has been applied. Cached on first call.
+ if (typeof app.locals._hon_cols_ready === 'undefined') {
+ app.locals._hon_cols_ready = !!psqlQuery(
+ `SELECT 1 FROM information_schema.columns WHERE table_name='wallco_votes' AND column_name='reasons' LIMIT 1;`
+ );
+ }
+ const colsReady = app.locals._hon_cols_ready;
+
+ if (colsReady) {
+ const reasonsSql = reasons.length
+ ? `ARRAY[${reasons.map(r => `'${sqlEsc(r)}'`).join(',')}]::text[]`
+ : `NULL`;
+ const noteSql = note ? `'${sqlEsc(note)}'` : `NULL`;
+ psqlQuery(`INSERT INTO wallco_votes (design_id, voter_session, score, quick, reasons, note)
+ VALUES (${id}, '${sqlEsc(session)}', ${s}, ${quick ? 'TRUE' : 'FALSE'}, ${reasonsSql}, ${noteSql})
+ ON CONFLICT (design_id, voter_session) DO UPDATE SET
+ score=EXCLUDED.score, reasons=EXCLUDED.reasons, note=EXCLUDED.note, created_at=now();`);
+ } else {
+ // Graceful degrade: persist the vote, log reasons/note to disk for later replay.
+ psqlQuery(`INSERT INTO wallco_votes (design_id, voter_session, score, quick)
+ VALUES (${id}, '${sqlEsc(session)}', ${s}, ${quick ? 'TRUE' : 'FALSE'})
+ ON CONFLICT (design_id, voter_session) DO UPDATE SET score=EXCLUDED.score, created_at=now();`);
+ if (reasons.length || note) {
+ try {
+ const queue = path.join(__dirname, 'logs', 'hon-reasons-queue.jsonl');
+ fs.mkdirSync(path.dirname(queue), { recursive: true });
+ fs.appendFileSync(queue, JSON.stringify({
+ ts: new Date().toISOString(),
+ design_id: id, voter_session: session, score: s, reasons, note
+ }) + '\n');
+ } catch (qe) { /* non-fatal */ }
+ }
+ }
+
const agg = JSON.parse(psqlQuery(`SELECT row_to_json(t) FROM (SELECT
ROUND(AVG(score)::numeric, 2)::text AS avg, COUNT(*)::int AS cnt
FROM wallco_votes WHERE design_id=${id}) t;`));
psqlQuery(`UPDATE spoon_all_designs SET user_score_avg=${agg.avg || 'NULL'},
user_vote_count=${agg.cnt} WHERE id=${id};`);
- res.json({ ok: true, user_score_avg: parseFloat(agg.avg), user_vote_count: agg.cnt });
+ res.json({
+ ok: true,
+ user_score_avg: parseFloat(agg.avg),
+ user_vote_count: agg.cnt,
+ reasons_persisted: colsReady,
+ reasons_queued: !colsReady && (reasons.length > 0 || !!note)
+ });
} catch (e) { res.status(500).json({ error: e.message }); }
});
@@ -11291,27 +11338,116 @@ ${HAMBURGER_JS}
</html>`);
});
-// ── GET /api/design/random — random rate-able design for hot-or-not + swipe
+// ── GET /api/design/random — random rate-able design for hot-or-not + swipe.
+// Filters to designs whose image file actually exists on disk so the UI never
+// renders a broken-image card. Resolves the basename of local_path against
+// IMG_DIR; if it's not there, skip and try again. Bounded retries so the
+// loop can't run away if the entire table is broken.
app.get('/api/design/random', (req, res) => {
const excl = String(req.query.exclude || '').split(',').map(x => parseInt(x,10)).filter(Number.isFinite);
- const exclSql = excl.length ? `AND id NOT IN (${excl.join(',')})` : '';
+ const exclSet = new Set(excl);
try {
- const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, dominant_hex, category, local_path
+ // Pull a small candidate pool, then filter by file existence client-side.
+ const exclSql = excl.length ? `AND id NOT IN (${excl.join(',')})` : '';
+ const raw = psqlQuery(`SELECT COALESCE(json_agg(t), '[]'::json) FROM (SELECT id, dominant_hex, category, local_path
FROM spoon_all_designs WHERE local_path IS NOT NULL ${exclSql}
- ORDER BY random() LIMIT 1) t;`);
- if (!raw) return res.status(404).json({});
- const d = JSON.parse(raw);
- const fn = (d.local_path || '').split('/').pop();
- res.json({
- id: d.id,
- title: titleFor(d.category, d.dominant_hex, d.id),
- image_url: `/designs/img/${fn}`,
- category: d.category,
- dominant_hex: d.dominant_hex
- });
+ ORDER BY random() LIMIT 30) t;`);
+ const pool = JSON.parse(raw || '[]');
+ for (const d of pool) {
+ if (exclSet.has(d.id)) continue;
+ const fn = (d.local_path || '').split('/').pop();
+ if (!fn) continue;
+ const abs = path.join(IMG_DIR, fn);
+ try { if (!fs.existsSync(abs)) continue; } catch { continue; }
+ return res.json({
+ id: d.id,
+ title: titleFor(d.category, d.dominant_hex, d.id),
+ image_url: `/designs/img/${fn}`,
+ category: d.category,
+ dominant_hex: d.dominant_hex
+ });
+ }
+ return res.status(404).json({ error: 'no renderable design found in pool of 30' });
} catch (e) { res.status(500).json({ error: e.message }); }
});
+// ── Hot-or-Not: per-session recent picks + historic rejection-reason chips.
+// Recent picks are derived from wallco_votes (last 6 by voter_session). Skip
+// rows whose image file no longer exists. Always returns up to 6 — the UI
+// pads to 6 slots with skeletons if fewer come back.
+app.get('/api/hot-or-not/recent', (req, res) => {
+ const session = voterSession(req, res);
+ try {
+ const limit = Math.max(1, Math.min(parseInt(req.query.limit || '6', 10), 24));
+ const raw = psqlQuery(`SELECT COALESCE(json_agg(t), '[]'::json) FROM (
+ SELECT v.design_id AS id, v.score, v.created_at,
+ d.local_path, d.dominant_hex, d.category
+ FROM wallco_votes v
+ JOIN spoon_all_designs d ON d.id = v.design_id
+ WHERE v.voter_session = '${session.replace(/'/g, "''")}'
+ AND d.local_path IS NOT NULL
+ ORDER BY v.created_at DESC LIMIT ${limit * 3}
+ ) t;`);
+ const rows = JSON.parse(raw || '[]');
+ const out = [];
+ for (const r of rows) {
+ if (out.length >= limit) break;
+ const fn = (r.local_path || '').split('/').pop();
+ if (!fn) continue;
+ try { if (!fs.existsSync(path.join(IMG_DIR, fn))) continue; } catch { continue; }
+ out.push({
+ id: r.id,
+ score: r.score,
+ action: r.score >= 4 ? 'hot' : r.score <= 2 ? 'not' : 'skip',
+ title: titleFor(r.category, r.dominant_hex, r.id),
+ image_url: `/designs/img/${fn}`,
+ created_at: r.created_at
+ });
+ }
+ res.json({ picks: out });
+ } catch (e) { res.status(500).json({ error: e.message, picks: [] }); }
+});
+
+// Default rejection reasons — used when the historic top-N is too thin OR the
+// reasons column hasn't been migrated yet. Kept short + scannable. The
+// dynamic-from-history layer merges these with whatever's most-clicked.
+const HON_DEFAULT_REASONS = [
+ 'Colors clash',
+ 'Pattern feels AI-generated / artifacts',
+ 'Too busy',
+ 'Too plain',
+ 'Wrong scale for a wall',
+ "Doesn't tile cleanly",
+ 'Cliché / overdone motif',
+ 'Off-trend / dated',
+ 'Resolution looks low',
+ 'Wrong palette for me',
+ 'Composition feels off',
+ 'Tags / category seem wrong'
+];
+
+app.get('/api/hot-or-not/reasons', (_req, res) => {
+ try {
+ // If the column exists, prefer the live top-N (mixed with defaults to
+ // backfill). If not, fall through to the defaults silently.
+ const colExists = psqlQuery(`SELECT 1 FROM information_schema.columns WHERE table_name='wallco_votes' AND column_name='reasons' LIMIT 1;`);
+ if (colExists) {
+ const raw = psqlQuery(`SELECT COALESCE(json_agg(t), '[]'::json) FROM (
+ SELECT unnest(reasons) AS reason, COUNT(*) AS n
+ FROM wallco_votes WHERE reasons IS NOT NULL AND array_length(reasons,1) > 0
+ GROUP BY 1 ORDER BY n DESC LIMIT 12
+ ) t;`);
+ const live = JSON.parse(raw || '[]').map(r => r.reason).filter(Boolean);
+ const seen = new Set(live.map(s => s.toLowerCase()));
+ const merged = live.concat(HON_DEFAULT_REASONS.filter(d => !seen.has(d.toLowerCase()))).slice(0, 12);
+ return res.json({ reasons: merged, source: live.length ? 'historic+defaults' : 'defaults' });
+ }
+ res.json({ reasons: HON_DEFAULT_REASONS, source: 'defaults' });
+ } catch (e) {
+ res.json({ reasons: HON_DEFAULT_REASONS, source: 'defaults-fallback', error: e.message });
+ }
+});
+
// ── /hot-or-not — rapid-fire HOT/NOT/SKIP single-card
app.get('/hot-or-not', (_req, res) => {
res.type('html').send(`${htmlHead({
@@ -11346,6 +11482,26 @@ ${htmlHeader('')}
<div id="hon-history" style="display:grid;grid-template-columns:repeat(6,1fr);gap:6px"></div>
</div>
</main>
+
+<!-- NOT modal — reason checkboxes (top-N historic merged w/ defaults) + free-text note -->
+<div id="hon-not-modal" role="dialog" aria-modal="true" aria-labelledby="hon-not-title" style="display:none;position:fixed;inset:0;background:rgba(15,14,12,.78);z-index:9999;align-items:center;justify-content:center;padding:18px;backdrop-filter:blur(4px)">
+ <div style="background:#fbfaf6;color:#1a1a1a;max-width:480px;width:100%;border-radius:14px;box-shadow:0 28px 80px rgba(0,0,0,.5);overflow:hidden">
+ <div style="padding:18px 22px;border-bottom:1px solid #e6e2d8;display:flex;justify-content:space-between;align-items:center">
+ <h2 id="hon-not-title" style="margin:0;font-family:'Cormorant Garamond',serif;font-weight:400;font-size:22px">Why not?</h2>
+ <button id="hon-not-cancel" aria-label="Close" style="background:transparent;border:0;font-size:22px;cursor:pointer;color:#888;line-height:1;padding:4px 8px">×</button>
+ </div>
+ <div style="padding:14px 22px 6px;font-size:12px;color:#666">Pick any that apply. Add a note if you want to teach the system.</div>
+ <div id="hon-not-reasons" style="padding:8px 22px 4px;display:grid;grid-template-columns:1fr 1fr;gap:6px 14px;font-size:13px"></div>
+ <div style="padding:8px 22px 4px">
+ <label style="font-size:11px;color:#888;text-transform:uppercase;letter-spacing:.06em">Note for the LLM <span style="text-transform:none;color:#aaa">— optional</span></label>
+ <textarea id="hon-not-note" rows="3" placeholder="e.g., the scale is too tight for a master bedroom; pattern feels stamped, not drawn" style="width:100%;margin-top:6px;padding:10px 12px;border:1px solid #d8d2c5;border-radius:8px;font:13px/1.4 system-ui, sans-serif;background:#fff;resize:vertical;box-sizing:border-box"></textarea>
+ </div>
+ <div style="padding:14px 22px 18px;display:flex;justify-content:flex-end;gap:8px">
+ <button id="hon-not-skip" style="padding:10px 16px;border:1px solid #d8d2c5;background:#fff;border-radius:8px;cursor:pointer;font-size:13px;color:#666">Skip — just NOT</button>
+ <button id="hon-not-submit" style="padding:10px 18px;border:0;background:#c44;color:#fff;border-radius:8px;cursor:pointer;font-size:13px;font-weight:600;letter-spacing:.04em">Submit NOT</button>
+ </div>
+ </div>
+</div>
${FOOTER}
<style>
.hon-btn { padding:18px 12px; font-size:14px; font-weight:600; letter-spacing:.1em; border:0; border-radius:10px; cursor:pointer; transition:transform .12s, background .15s; }
@@ -11358,10 +11514,16 @@ ${FOOTER}
#hon-card.swipe-left { transform:translateX(-120%) rotate(-14deg); opacity:0 }
#hon-card.swipe-right { transform:translateX(120%) rotate(14deg); opacity:0 }
#hon-card.swipe-up { transform:translateY(-120%); opacity:0 }
- .hist-card img { width:100%; aspect-ratio:1; object-fit:cover; display:block; border-radius:4px; border:2px solid transparent }
- .hist-card.hot img { border-color:#d2b15c }
- .hist-card.not img { border-color:#c44 }
- .hist-card.skip img { border-color:#888 }
+ .hist-card { display:block; aspect-ratio:1; border-radius:5px; overflow:hidden; border:2px solid transparent; background:#f4f1ea }
+ .hist-card.hot { border-color:#d2b15c }
+ .hist-card.not { border-color:#c44 }
+ .hist-card.skip { border-color:#888 }
+ .hist-card.empty { border:1px dashed #d8d2c5; background:repeating-linear-gradient(135deg,#f4f1ea 0 8px,#ede9de 8px 16px) }
+ .hist-card img { width:100%; height:100%; object-fit:cover; display:block }
+ .hon-reason-chip { display:flex; align-items:center; gap:8px; padding:7px 10px; border:1px solid #e6e2d8; border-radius:8px; cursor:pointer; background:#fff; transition:background .12s, border-color .12s; user-select:none }
+ .hon-reason-chip:hover { background:#faf6ec }
+ .hon-reason-chip.checked { background:#fde6e2; border-color:#e8aaa5 }
+ .hon-reason-chip input { margin:0 }
</style>
<script>
var seen = [], picks = []; /* named picks because window.history shadows a global */
@@ -11370,6 +11532,37 @@ var todayKey = 'wc-day-' + new Date().toISOString().slice(0,10);
var todayCount = parseInt(localStorage.getItem(todayKey) || '0', 10);
function renderStreak(){ document.getElementById('streak').textContent = '🔥 ' + streak + ' in a row · ' + todayCount + ' votes today'; }
renderStreak();
+
+/* Always show 6 thumb slots. Empty slots are dashed placeholders. */
+function renderHistory(){
+ var grid = document.getElementById('hon-history');
+ var html = '';
+ for (var i = 0; i < 6; i++) {
+ var h = picks[i];
+ if (h) {
+ html += '<a class="hist-card '+h.action+'" href="/design/'+h.id+'" title="'+h.title.replace(/"/g,'"')+'">'
+ + '<img src="'+h.image_url+'" alt="'+h.title.replace(/"/g,'"')+'" onerror="this.parentNode.classList.add(\\'empty\\');this.remove()">'
+ + '</a>';
+ } else {
+ html += '<span class="hist-card empty" aria-label="empty slot"></span>';
+ }
+ }
+ grid.innerHTML = html;
+}
+renderHistory();
+
+/* Hydrate from server-side history so the row is always populated. */
+(async function hydrateRecent(){
+ try {
+ var r = await (await fetch('/api/hot-or-not/recent?limit=6')).json();
+ if (r && Array.isArray(r.picks) && r.picks.length) {
+ picks = r.picks.map(function(p){ return { id:p.id, action:p.action, title:p.title || ('Design #'+p.id), image_url:p.image_url }; });
+ r.picks.forEach(function(p){ if (!seen.includes(p.id)) seen.push(p.id); });
+ renderHistory();
+ }
+ } catch(e) { /* non-fatal */ }
+})();
+
async function load(){
var url = '/api/design/random' + (seen.length ? '?exclude=' + seen.slice(-30).join(',') : '');
var r = await (await fetch(url)).json();
@@ -11386,16 +11579,60 @@ async function load(){
document.getElementById('hon-card').dataset.hex = r.dominant_hex || '';
seen.push(r.id);
}
+
+/* ---------- NOT modal ---------- */
+var honReasonsCache = null;
+async function ensureReasons(){
+ if (honReasonsCache) return honReasonsCache;
+ try {
+ var r = await (await fetch('/api/hot-or-not/reasons')).json();
+ honReasonsCache = (r && Array.isArray(r.reasons)) ? r.reasons : [];
+ } catch(e) { honReasonsCache = []; }
+ return honReasonsCache;
+}
+
+async function openNotModal(){
+ var reasons = await ensureReasons();
+ var box = document.getElementById('hon-not-reasons');
+ box.innerHTML = reasons.map(function(r, i){
+ var v = r.replace(/"/g, '"');
+ return '<label class="hon-reason-chip" data-reason="'+v+'"><input type="checkbox" value="'+v+'">'+r+'</label>';
+ }).join('');
+ /* toggle .checked on chip when its input flips */
+ box.querySelectorAll('.hon-reason-chip').forEach(function(chip){
+ var input = chip.querySelector('input');
+ chip.addEventListener('click', function(e){
+ if (e.target !== input) { input.checked = !input.checked; }
+ chip.classList.toggle('checked', input.checked);
+ });
+ });
+ document.getElementById('hon-not-note').value = '';
+ var m = document.getElementById('hon-not-modal');
+ m.style.display = 'flex';
+ setTimeout(function(){ document.getElementById('hon-not-note').focus(); }, 60);
+}
+function closeNotModal(){ document.getElementById('hon-not-modal').style.display = 'none'; }
+
+function collectModalPayload(){
+ var reasons = [].slice.call(document.querySelectorAll('#hon-not-reasons input:checked')).map(function(i){ return i.value; });
+ var note = (document.getElementById('hon-not-note').value || '').trim();
+ return { reasons: reasons, note: note };
+}
+
async function act(action, evt){
var card = document.getElementById('hon-card');
var id = parseInt(card.dataset.id, 10);
if (!id) return;
- var anim = action==='hot' ? 'swipe-right' : action==='not' ? 'swipe-left' : 'swipe-up';
+
+ /* NOT opens the reason modal — actual vote fires from the modal's submit/skip. */
+ if (action === 'not') { window._honPendingId = id; openNotModal(); return; }
+
+ var anim = action==='hot' ? 'swipe-right' : 'swipe-up';
card.classList.add(anim);
- if (action === 'hot' || action === 'not') {
- var score = action === 'hot' ? 5 : 1;
+ if (action === 'hot') {
+ var score = 5;
fetch('/api/design/' + id + '/user-vote', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({score: score, quick: true}) });
- streak = (action === 'hot') ? streak + 1 : 0;
+ streak = streak + 1;
localStorage.setItem('wc-streak', String(streak));
}
todayCount++; localStorage.setItem(todayKey, String(todayCount));
@@ -11403,13 +11640,46 @@ async function act(action, evt){
if (window.WC) WC.reward(action, evt, {id: id, category: card.dataset.category, dominant_hex: card.dataset.hex});
picks.unshift({id: id, action: action, title: card.dataset.title, image_url: card.dataset.image});
picks = picks.slice(0, 6);
- document.getElementById('hon-history').innerHTML = picks.map(function(h){
- return '<a class="hist-card '+h.action+'" href="/design/'+h.id+'" title="'+h.title+'"><img src="'+h.image_url+'" alt="'+h.title+'"></a>';
- }).join('');
+ renderHistory();
+ setTimeout(load, 280);
+}
+
+/* NOT-with-reasons path: invoked from the modal. Mirrors act('not') flow
+ but carries reasons + note to the server. */
+function submitNot(reasons, note, evt){
+ var id = window._honPendingId;
+ if (!id) { closeNotModal(); return; }
+ window._honPendingId = null;
+ var card = document.getElementById('hon-card');
+ card.classList.add('swipe-left');
+ fetch('/api/design/' + id + '/user-vote', {
+ method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ score: 1, quick: false, reasons: reasons || [], note: note || '' })
+ });
+ streak = 0; localStorage.setItem('wc-streak', String(streak));
+ todayCount++; localStorage.setItem(todayKey, String(todayCount));
+ renderStreak();
+ if (window.WC) WC.reward('not', evt, {id: id, category: card.dataset.category, dominant_hex: card.dataset.hex});
+ picks.unshift({ id: id, action: 'not', title: card.dataset.title, image_url: card.dataset.image });
+ picks = picks.slice(0, 6);
+ renderHistory();
+ closeNotModal();
setTimeout(load, 280);
}
+
document.querySelectorAll('.hon-btn').forEach(function(b){ b.addEventListener('click', function(evt){ act(b.dataset.action, evt); }); });
+document.getElementById('hon-not-cancel').addEventListener('click', closeNotModal);
+document.getElementById('hon-not-skip').addEventListener('click', function(evt){ submitNot([], '', evt); });
+document.getElementById('hon-not-submit').addEventListener('click', function(evt){ var p = collectModalPayload(); submitNot(p.reasons, p.note, evt); });
+document.getElementById('hon-not-modal').addEventListener('click', function(e){ if (e.target === this) closeNotModal(); });
+
document.addEventListener('keydown', function(e){
+ var modalOpen = document.getElementById('hon-not-modal').style.display === 'flex';
+ if (modalOpen) {
+ if (e.key === 'Escape') { e.preventDefault(); closeNotModal(); }
+ else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); var p = collectModalPayload(); submitNot(p.reasons, p.note); }
+ return;
+ }
if (e.key === 'ArrowLeft') act('not');
else if (e.key === 'ArrowRight') act('hot');
else if (e.key === ' ' || e.key === 'ArrowUp') { e.preventDefault(); act('skip'); }
diff --git a/sql/002_hot_or_not_reasons.sql b/sql/002_hot_or_not_reasons.sql
new file mode 100644
index 0000000..8afc21a
--- /dev/null
+++ b/sql/002_hot_or_not_reasons.sql
@@ -0,0 +1,22 @@
+-- Hot-or-Not reasons + notes + image-file existence flag.
+-- Apply on Kamatera prod with: sudo -u postgres psql -d dw_unified -f 002_hot_or_not_reasons.sql
+-- DRAFT — not yet applied to prod as of 2026-05-13. Steve must approve.
+
+ALTER TABLE wallco_votes
+ ADD COLUMN IF NOT EXISTS reasons TEXT[],
+ ADD COLUMN IF NOT EXISTS note TEXT;
+
+ALTER TABLE spoon_all_designs
+ ADD COLUMN IF NOT EXISTS image_file_exists BOOLEAN;
+
+CREATE INDEX IF NOT EXISTS idx_wallco_votes_reasons
+ ON wallco_votes USING GIN (reasons);
+
+CREATE INDEX IF NOT EXISTS idx_wallco_votes_session_created
+ ON wallco_votes (voter_session, created_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_spoon_image_file_exists
+ ON spoon_all_designs (image_file_exists) WHERE image_file_exists IS TRUE;
+
+GRANT SELECT, INSERT, UPDATE, DELETE ON wallco_votes TO dw_admin;
+GRANT UPDATE ON spoon_all_designs TO dw_admin;
← bfb6108 snapshot: include gemini-2.5-flash-image-edit generator (10
·
back to Wallco Ai
·
designs: NEW badge on cards created in last 72h 96a4bc0 →