← back to Feature Harvest
feature-harvest: interactive DW/WPB review deck over a year of shipped apps + DW-Programming tools
fae947390e5a2e5dc5d8f0d0c16d000c835fcc15 · 2026-06-26 08:39:31 -0700 · Steve
Files touched
A .gitignoreA README.mdA build-corpus.jsA data/verdicts.jsonA package.jsonA public/index.htmlA server.js
Diff
commit fae947390e5a2e5dc5d8f0d0c16d000c835fcc15
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Jun 26 08:39:31 2026 -0700
feature-harvest: interactive DW/WPB review deck over a year of shipped apps + DW-Programming tools
---
.gitignore | 10 ++
README.md | 34 +++++
build-corpus.js | 165 +++++++++++++++++++++++
data/verdicts.json | 1 +
package.json | 10 ++
public/index.html | 388 +++++++++++++++++++++++++++++++++++++++++++++++++++++
server.js | 149 ++++++++++++++++++++
7 files changed, 757 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e7f791a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+data/wins-snapshot.json
+data/items.json
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..661d199
--- /dev/null
+++ b/README.md
@@ -0,0 +1,34 @@
+# Feature Harvest
+
+Interactive review deck for a year of work. Surfaces every shipped app/feature and
+DW programming tool, and lets you triage each one into **→ DW**, **→ WPB**, **Both**,
+or **Skip** — then exports the two shortlists so the best ideas get ported into
+designerwallcoverings.com or wallpapersback.com.
+
+## Corpus (3 sources, ~1,682 items)
+- **Shipped wins** — the CNCP "Today's Wins" log (`:3333/api/wins`), snapshotted locally.
+- **DW apps** — every standalone tool under `Designer-Wallcoverings/DW-Programming/*`
+ (the recolor engine, SearchByColorWheel/color-dots, vendor taggers, dashboards…).
+- **WPB tools** — the curator/viewer HTML tools in the wallco-ai + wallpapersback storefronts.
+
+## Run
+```sh
+node build-corpus.js # (re)aggregate data/items.json from the 3 sources
+node server.js # serve the review deck (auto-picks a free port)
+```
+Login: `admin` / `DW2024!` (override with `FH_USER` / `FH_PASS`).
+
+## Review UX
+- **Queue mode** — one card at a time. Keys: `D`=DW, `W`=WPB, `B`=Both, `S`=Skip,
+ `U`=unset, `←`/`→` navigate. Auto-advances through the unreviewed queue.
+- **Board mode** — five columns (Unreviewed / DW / WPB / Both / Skip), drag cards
+ between piles. Density slider sets column count.
+- Filter by source / status / project, search, sort. Per-card note field.
+- **⬇ Export shortlist** → markdown of the DW + WPB + Both piles with your notes.
+
+## Data
+- `data/verdicts.json` — your decisions (tracked in git, durable).
+- `data/items.json`, `data/wins-snapshot.json` — regenerable corpus (gitignored).
+- **↻ Refresh** re-pulls wins from CNCP and rebuilds; falls back to cache if CNCP is down.
+
+Local-only. No deploy.
diff --git a/build-corpus.js b/build-corpus.js
new file mode 100644
index 0000000..0051802
--- /dev/null
+++ b/build-corpus.js
@@ -0,0 +1,165 @@
+#!/usr/bin/env node
+/*
+ * build-corpus.js — aggregate a year of work into one reviewable list (data/items.json).
+ * Sources:
+ * A) CNCP wins → shipped features/apps (source: "win")
+ * B) DW-Programming/* → standalone DW apps/tools (source: "dw-app")
+ * C) wallco/WPB curators → AI-storefront review tools (source: "wpb-tool")
+ * Each item: { id, source, title, project, date, summary, value, commit, path, port, hasUI }
+ */
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const HOME = os.homedir();
+const OUT = path.join(__dirname, 'data', 'items.json');
+const items = [];
+
+// ---- A) CNCP wins -------------------------------------------------
+function addWins() {
+ const snap = path.join(__dirname, 'data', 'wins-snapshot.json');
+ let wins = [];
+ try { wins = JSON.parse(fs.readFileSync(snap, 'utf8')); } catch {}
+ for (const w of wins) {
+ items.push({
+ id: w.id,
+ source: 'win',
+ title: w.title,
+ project: w.project || '',
+ date: w.date || (w.createdAt ? new Date(w.createdAt).toISOString().slice(0, 10) : ''),
+ ts: w.createdAt || 0,
+ summary: w.summary || '',
+ value: w.valueToday || '',
+ commit: w.commit || '',
+ path: '',
+ port: '',
+ hasUI: false,
+ });
+ }
+ return wins.length;
+}
+
+// ---- helpers for filesystem apps ----------------------------------
+function firstReadmeLine(dir) {
+ for (const name of ['README.md', 'readme.md', 'VERSION_NOTES.md', 'VERSION.md']) {
+ const f = path.join(dir, name);
+ if (fs.existsSync(f)) {
+ const lines = fs.readFileSync(f, 'utf8').split('\n').map((l) => l.trim());
+ // first non-empty, non-heading-only line, else first heading text
+ const para = lines.find((l) => l && !l.startsWith('#') && !l.startsWith('---'));
+ const head = lines.find((l) => l.startsWith('#'));
+ const txt = (para || (head ? head.replace(/^#+\s*/, '') : '') || '').slice(0, 400);
+ if (txt) return txt;
+ }
+ }
+ return '';
+}
+function pkgDesc(dir) {
+ const f = path.join(dir, 'package.json');
+ if (fs.existsSync(f)) {
+ try { return (JSON.parse(fs.readFileSync(f, 'utf8')).description || '').slice(0, 400); } catch {}
+ }
+ return '';
+}
+function detectPort(dir) {
+ for (const name of ['ecosystem.config.js', '.env', 'server.js', 'index.js']) {
+ const f = path.join(dir, name);
+ if (fs.existsSync(f)) {
+ try {
+ const m = fs.readFileSync(f, 'utf8').match(/(?:PORT|port)\s*[:=]\s*['"]?(\d{3,5})/);
+ if (m) return m[1];
+ } catch {}
+ }
+ }
+ return '';
+}
+function hasUI(dir) {
+ if (fs.existsSync(path.join(dir, 'public', 'index.html'))) return true;
+ try { return fs.readdirSync(dir).some((f) => f.endsWith('.html')); } catch { return false; }
+}
+function dirDate(dir) {
+ try {
+ const t = fs.statSync(dir).mtime;
+ return { date: t.toISOString().slice(0, 10), ts: t.getTime() };
+ } catch { return { date: '', ts: 0 }; }
+}
+
+// ---- B) DW-Programming app folders --------------------------------
+const SKIP_DIRS = new Set([
+ 'config', 'data', 'lib', 'ls', 'scripts', 'state', 'templates',
+ 'shared-enhancements', 'node_modules', '.git',
+]);
+function addDirApps(parent, source, projectLabel) {
+ if (!fs.existsSync(parent)) return 0;
+ let n = 0;
+ for (const name of fs.readdirSync(parent)) {
+ if (SKIP_DIRS.has(name) || name.startsWith('.')) continue;
+ const dir = path.join(parent, name);
+ let st; try { st = fs.statSync(dir); } catch { continue; }
+ if (!st.isDirectory()) continue;
+ const { date, ts } = dirDate(dir);
+ const summary = firstReadmeLine(dir) || pkgDesc(dir) || '';
+ items.push({
+ id: `${source}:${name}`,
+ source,
+ title: name,
+ project: projectLabel,
+ date, ts,
+ summary,
+ value: '',
+ commit: '',
+ path: dir,
+ port: detectPort(dir),
+ hasUI: hasUI(dir),
+ });
+ n++;
+ }
+ return n;
+}
+
+// ---- C) Standalone curator/viewer HTML tools (wallco + WPB) -------
+function addHtmlTools() {
+ const roots = [
+ { dir: path.join(HOME, 'Projects/wallco-ai/public'), source: 'wpb-tool', label: 'wallco-ai' },
+ { dir: path.join(HOME, 'Projects/WallpapersBack/wallpapersback-fork/public'), source: 'wpb-tool', label: 'wallpapersback' },
+ ];
+ let n = 0;
+ for (const { dir, source, label } of roots) {
+ if (!fs.existsSync(dir)) continue;
+ for (const f of fs.readdirSync(dir)) {
+ if (!f.endsWith('.html')) continue;
+ if (['index.html', 'privacy.html', 'about.html'].includes(f)) continue;
+ const full = path.join(dir, f);
+ const { date, ts } = dirDate(full);
+ items.push({
+ id: `${source}:${label}:${f}`,
+ source,
+ title: f.replace(/\.html$/, '').replace(/[-_]/g, ' '),
+ project: label,
+ date, ts,
+ summary: `Standalone review/curator tool (${f}) in the ${label} storefront.`,
+ value: '',
+ commit: '',
+ path: full,
+ port: '',
+ hasUI: true,
+ });
+ n++;
+ }
+ }
+ return n;
+}
+
+// ---- run ----------------------------------------------------------
+const nWins = addWins();
+const nDW = addDirApps(path.join(HOME, 'Projects/Designer-Wallcoverings/DW-Programming'), 'dw-app', 'DW-Programming');
+const nTools = addHtmlTools();
+
+// stable sort: newest first
+items.sort((a, b) => (b.ts || 0) - (a.ts || 0));
+fs.writeFileSync(OUT, JSON.stringify(items, null, 2));
+console.log(`corpus built → ${OUT}`);
+console.log(` wins: ${nWins}`);
+console.log(` dw-apps: ${nDW}`);
+console.log(` wpb-tools:${nTools}`);
+console.log(` TOTAL: ${items.length}`);
diff --git a/data/verdicts.json b/data/verdicts.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/data/verdicts.json
@@ -0,0 +1 @@
+{}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..046e33d
--- /dev/null
+++ b/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "feature-harvest",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Interactive review deck — triage a year of shipped apps/features into a DW or WPB shortlist.",
+ "scripts": {
+ "build": "node build-corpus.js",
+ "start": "node server.js"
+ }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..7eb8a82
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,388 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<title>Feature Harvest — DW / WPB review</title>
+<style>
+ :root{
+ --bg:#0f1115; --panel:#171a21; --panel2:#1d212b; --line:#2a2f3a;
+ --ink:#e8eaf0; --mut:#8a92a6; --gold:#caa45a; --teal:#34a5f4;
+ --dw:#caa45a; --wpb:#b07cf0; --both:#3ddc97; --skip:#5a6172;
+ --win:#34a5f4; --app:#e0a93b; --tool:#b07cf0;
+ --r:14px; --shadow:0 8px 30px rgba(0,0,0,.35);
+ }
+ *{box-sizing:border-box}
+ body{margin:0;background:var(--bg);color:var(--ink);
+ font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,sans-serif}
+ a{color:var(--teal)}
+ /* top bar */
+ header{position:sticky;top:0;z-index:10;background:rgba(15,17,21,.92);
+ backdrop-filter:blur(8px);border-bottom:1px solid var(--line);padding:12px 18px}
+ .row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
+ .brand{font-weight:700;letter-spacing:.3px;font-size:17px}
+ .brand span{color:var(--gold)}
+ .counts{display:flex;gap:8px;flex-wrap:wrap;margin-left:auto}
+ .chip{padding:3px 10px;border-radius:999px;font-size:12.5px;font-weight:600;
+ background:var(--panel2);border:1px solid var(--line);white-space:nowrap}
+ .chip.dw{color:var(--dw)} .chip.wpb{color:var(--wpb)}
+ .chip.both{color:var(--both)} .chip.skip{color:var(--skip)}
+ .chip.left{color:var(--ink)}
+ button,select,input{font:inherit;color:var(--ink)}
+ .btn{background:var(--panel2);border:1px solid var(--line);border-radius:10px;
+ padding:7px 13px;cursor:pointer;transition:.12s}
+ .btn:hover{border-color:var(--gold);background:#222734}
+ .btn.primary{background:var(--gold);color:#1a1407;border-color:var(--gold);font-weight:700}
+ .btn.ghost{background:transparent}
+ .progress{height:5px;background:var(--panel2);border-radius:4px;margin-top:10px;overflow:hidden}
+ .progress > i{display:block;height:100%;background:linear-gradient(90deg,var(--gold),var(--teal));width:0}
+ /* controls */
+ .controls{margin-top:10px;gap:8px}
+ .controls select,.controls input[type=search]{
+ background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:6px 10px}
+ .seg{display:flex;border:1px solid var(--line);border-radius:9px;overflow:hidden}
+ .seg button{background:transparent;border:0;padding:6px 13px;cursor:pointer;color:var(--mut)}
+ .seg button.on{background:var(--gold);color:#1a1407;font-weight:700}
+ .dens{display:flex;align-items:center;gap:6px;color:var(--mut);font-size:12.5px}
+ /* main */
+ main{padding:18px;max-width:1400px;margin:0 auto}
+ /* QUEUE mode */
+ .queue-wrap{max-width:760px;margin:0 auto}
+ .qnav{display:flex;justify-content:space-between;align-items:center;color:var(--mut);
+ font-size:13px;margin-bottom:10px}
+ /* card */
+ .card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);
+ box-shadow:var(--shadow);overflow:hidden}
+ .card .top{padding:6px 16px;display:flex;gap:8px;align-items:center;
+ border-bottom:1px solid var(--line);background:var(--panel2)}
+ .src{padding:2px 9px;border-radius:6px;font-size:11px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
+ .src.win{background:rgba(52,165,244,.16);color:var(--win)}
+ .src.dw-app{background:rgba(224,169,59,.16);color:var(--app)}
+ .src.wpb-tool{background:rgba(176,124,240,.16);color:var(--tool)}
+ .when{color:var(--mut);font-size:12px;margin-left:auto;white-space:nowrap}
+ .verdict-tag{font-size:11px;font-weight:800;padding:2px 8px;border-radius:6px}
+ .vt-dw{background:rgba(202,164,90,.18);color:var(--dw)}
+ .vt-wpb{background:rgba(176,124,240,.18);color:var(--wpb)}
+ .vt-both{background:rgba(61,220,151,.18);color:var(--both)}
+ .vt-skip{background:rgba(90,97,114,.25);color:var(--skip)}
+ .card .body{padding:16px}
+ .card h2{margin:0 0 4px;font-size:20px;line-height:1.25}
+ .meta{color:var(--mut);font-size:12.5px;margin-bottom:12px}
+ .meta code{background:var(--panel2);padding:1px 6px;border-radius:5px;color:var(--gold)}
+ .summary{white-space:pre-wrap;color:#d3d7e0}
+ .value{margin-top:10px;padding:10px 12px;border-left:3px solid var(--both);
+ background:rgba(61,220,151,.06);border-radius:0 8px 8px 0;font-size:14px}
+ .value b{color:var(--both)}
+ .pathline{margin-top:10px;font-size:12px;color:var(--mut)}
+ /* verdict bar */
+ .verdict{display:flex;gap:8px;padding:14px 16px;border-top:1px solid var(--line);
+ background:var(--panel2);flex-wrap:wrap;align-items:center}
+ .vbtn{flex:1;min-width:90px;border:1px solid var(--line);border-radius:10px;padding:11px;
+ cursor:pointer;font-weight:700;background:var(--panel);transition:.12s;text-align:center}
+ .vbtn:hover{transform:translateY(-1px)}
+ .vbtn .k{font-size:11px;opacity:.6;font-weight:600}
+ .vbtn.dw{border-color:var(--dw)} .vbtn.dw.on{background:var(--dw);color:#1a1407}
+ .vbtn.wpb{border-color:var(--wpb)} .vbtn.wpb.on{background:var(--wpb);color:#150b22}
+ .vbtn.both{border-color:var(--both)} .vbtn.both.on{background:var(--both);color:#04130c}
+ .vbtn.skip{border-color:var(--skip)} .vbtn.skip.on{background:var(--skip);color:#fff}
+ .note{width:100%;margin-top:10px;background:var(--panel);border:1px solid var(--line);
+ border-radius:9px;padding:9px 11px;resize:vertical;min-height:38px;color:var(--ink)}
+ /* BOARD mode */
+ .board{display:grid;grid-template-columns:repeat(5,1fr);gap:14px;align-items:start}
+ @media(max-width:1100px){.board{grid-template-columns:repeat(2,1fr)}}
+ .col{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px;min-height:120px}
+ .col h3{margin:2px 4px 10px;font-size:13px;text-transform:uppercase;letter-spacing:.5px;
+ display:flex;justify-content:space-between}
+ .col.dw h3{color:var(--dw)} .col.wpb h3{color:var(--wpb)}
+ .col.both h3{color:var(--both)} .col.skip h3{color:var(--skip)} .col.left h3{color:var(--ink)}
+ .mini{background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:8px 10px;
+ margin-bottom:8px;cursor:grab;font-size:13px}
+ .mini:hover{border-color:var(--gold)}
+ .mini .mt{font-weight:600;line-height:1.3}
+ .mini .ms{color:var(--mut);font-size:11px;margin-top:3px;display:flex;gap:6px;align-items:center}
+ .col.drop{outline:2px dashed var(--gold);outline-offset:-4px}
+ .grid-board[data-cols]{}
+ .empty{color:var(--mut);text-align:center;padding:40px;font-style:italic}
+ .hint{color:var(--mut);font-size:12px}
+ kbd{background:var(--panel2);border:1px solid var(--line);border-radius:5px;padding:1px 6px;font-size:11px}
+</style>
+</head>
+<body>
+<header>
+ <div class="row">
+ <div class="brand">Feature <span>Harvest</span> · review a year of apps → DW / WPB</div>
+ <div class="counts" id="counts"></div>
+ </div>
+ <div class="row controls">
+ <div class="seg" id="mode">
+ <button data-mode="queue" class="on">Queue</button>
+ <button data-mode="board">Board</button>
+ </div>
+ <select id="fSource">
+ <option value="">All sources</option>
+ <option value="win">Shipped wins</option>
+ <option value="dw-app">DW apps</option>
+ <option value="wpb-tool">WPB tools</option>
+ </select>
+ <select id="fStatus">
+ <option value="unreviewed">Unreviewed</option>
+ <option value="">All statuses</option>
+ <option value="dw">→ DW</option>
+ <option value="wpb">→ WPB</option>
+ <option value="both">→ Both</option>
+ <option value="skip">Skipped</option>
+ </select>
+ <select id="fProject"><option value="">All projects</option></select>
+ <select id="fSort">
+ <option value="new">Newest</option>
+ <option value="old">Oldest</option>
+ <option value="title">Title A→Z</option>
+ </select>
+ <input type="search" id="fSearch" placeholder="search title / summary…" style="min-width:200px" />
+ <div class="dens" id="densWrap" style="display:none">density
+ <input type="range" id="dens" min="2" max="6" value="5" />
+ </div>
+ <button class="btn" id="refreshBtn" title="Re-pull wins from CNCP + rebuild">↻ Refresh</button>
+ <button class="btn primary" id="exportBtn">⬇ Export shortlist</button>
+ </div>
+ <div class="progress"><i id="bar"></i></div>
+</header>
+<main>
+ <div id="queue" class="queue-wrap"></div>
+ <div id="board" style="display:none"></div>
+</main>
+
+<script>
+const $ = (s,r=document)=>r.querySelector(s);
+const $$ = (s,r=document)=>[...r.querySelectorAll(s)];
+const SRC_LABEL = {win:'Shipped win', 'dw-app':'DW app', 'wpb-tool':'WPB tool'};
+const DEC = {dw:'→ DW', wpb:'→ WPB', both:'Both', skip:'Skip'};
+let ITEMS=[], V={}, qi=0;
+const state = {
+ mode:'queue',
+ source:'', status:'unreviewed', project:'', sort:'new', search:'',
+ cols: +(localStorage.getItem('fh_cols')||5),
+};
+
+// ---- load ----
+async function load(){
+ const r = await fetch('/api/items'); const d = await r.json();
+ ITEMS = d.items; V = d.verdicts || {};
+ buildProjectFacet();
+ render();
+}
+function buildProjectFacet(){
+ const sel = $('#fProject');
+ const seen = new Set();
+ const opts = [];
+ for(const it of ITEMS){ const p=it.project||'(none)'; if(!seen.has(p)){seen.add(p); opts.push(p);} }
+ opts.sort();
+ sel.innerHTML = '<option value="">All projects</option>' +
+ opts.map(p=>`<option value="${esc(p)}">${esc(p)}</option>`).join('');
+}
+
+// ---- helpers ----
+function esc(s){return (s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
+function whenStr(it){
+ if(!it.ts) return it.date||'';
+ return new Date(it.ts).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
+}
+function filtered(){
+ let a = ITEMS;
+ if(state.source) a=a.filter(i=>i.source===state.source);
+ if(state.project) a=a.filter(i=>(i.project||'(none)')===state.project);
+ if(state.status==='unreviewed') a=a.filter(i=>!V[i.id]);
+ else if(state.status) a=a.filter(i=>V[i.id]&&V[i.id].decision===state.status);
+ if(state.search){const q=state.search.toLowerCase();
+ a=a.filter(i=>(i.title+' '+i.summary+' '+i.value+' '+i.project).toLowerCase().includes(q));}
+ a=[...a];
+ if(state.sort==='new') a.sort((x,y)=>(y.ts||0)-(x.ts||0));
+ else if(state.sort==='old') a.sort((x,y)=>(x.ts||0)-(y.ts||0));
+ else if(state.sort==='title') a.sort((x,y)=>(x.title||'').localeCompare(y.title||''));
+ return a;
+}
+function counts(){
+ const c={dw:0,wpb:0,both:0,skip:0,left:0};
+ for(const it of ITEMS){const v=V[it.id]; if(v&&c[v.decision]!=null)c[v.decision]++; else c.left++;}
+ return c;
+}
+function renderCounts(){
+ const c=counts();
+ $('#counts').innerHTML =
+ `<span class="chip left">${ITEMS.length} total</span>`+
+ `<span class="chip dw">DW ${c.dw}</span>`+
+ `<span class="chip wpb">WPB ${c.wpb}</span>`+
+ `<span class="chip both">Both ${c.both}</span>`+
+ `<span class="chip skip">Skip ${c.skip}</span>`+
+ `<span class="chip left">${c.left} left</span>`;
+ const reviewed=ITEMS.length-c.left;
+ $('#bar').style.width = (ITEMS.length? (reviewed/ITEMS.length*100):0)+'%';
+}
+
+// ---- verdict ----
+async function setVerdict(id, decision){
+ const cur = V[id] && V[id].decision;
+ if(cur===decision){ decision='unset'; } // toggle off
+ const note = (V[id]&&V[id].note)||'';
+ if(decision==='unset') delete V[id]; else V[id]={decision,note,ts:Date.now()};
+ renderCounts();
+ fetch('/api/verdict',{method:'POST',headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({id,decision,note})});
+}
+function setNote(id,note){
+ const decision = (V[id]&&V[id].decision)||'skip';
+ if(!V[id]) V[id]={decision,note,ts:Date.now()}; else V[id].note=note;
+ fetch('/api/verdict',{method:'POST',headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({id,decision:V[id].decision,note})});
+}
+
+// ---- render dispatch ----
+function render(){
+ renderCounts();
+ $('#queue').style.display = state.mode==='queue'?'block':'none';
+ $('#board').style.display = state.mode==='board'?'block':'none';
+ $('#densWrap').style.display = state.mode==='board'?'flex':'none';
+ if(state.mode==='queue') renderQueue(); else renderBoard();
+}
+
+// ---- QUEUE ----
+function renderQueue(){
+ const list = filtered();
+ if(!list.length){ $('#queue').innerHTML = `<div class="empty">Nothing matches these filters. 🎉</div>`; return; }
+ if(qi>=list.length) qi=list.length-1; if(qi<0) qi=0;
+ const it = list[qi];
+ $('#queue').innerHTML = `
+ <div class="qnav">
+ <button class="btn ghost" id="prev">← Prev</button>
+ <div>${qi+1} / ${list.length} · <span class="hint">keys <kbd>D</kbd><kbd>W</kbd><kbd>B</kbd><kbd>S</kbd> · <kbd>←</kbd><kbd>→</kbd></span></div>
+ <button class="btn ghost" id="next">Next →</button>
+ </div>
+ ${cardHTML(it,true)}`;
+ $('#prev').onclick=()=>{qi--;renderQueue();};
+ $('#next').onclick=()=>{qi++;renderQueue();};
+ wireCard(it, ()=>{ // after a verdict in queue: advance to next unreviewed-in-list
+ const nl=filtered();
+ if(state.status==='unreviewed'){ if(qi>=nl.length)qi=nl.length-1; renderQueue(); }
+ else { qi++; renderQueue(); }
+ });
+}
+function cardHTML(it, big){
+ const v = V[it.id];
+ const tag = v ? `<span class="verdict-tag vt-${v.decision}">${DEC[v.decision]}</span>` : '';
+ const path = it.path ? `<div class="pathline">📁 <code>${esc(it.path)}</code>${it.port?` · port ${esc(it.port)}`:''}${it.hasUI?' · has UI':''}</div>` : '';
+ const commit = it.commit ? ` · <code>${esc(it.commit)}</code>` : '';
+ const val = it.value ? `<div class="value"><b>Value:</b> ${esc(it.value)}</div>` : '';
+ return `
+ <div class="card" data-id="${esc(it.id)}">
+ <div class="top">
+ <span class="src ${it.source}">${SRC_LABEL[it.source]||it.source}</span>
+ ${tag}
+ <span class="when" title="${esc(it.date)}">🕓 ${esc(whenStr(it))}</span>
+ </div>
+ <div class="body">
+ <h2>${esc(it.title)}</h2>
+ <div class="meta">${esc(it.project||'')}${commit}</div>
+ <div class="summary">${esc(it.summary||'')||'<span class="hint">no description captured</span>'}</div>
+ ${val}${path}
+ </div>
+ <div class="verdict">
+ <div class="vbtn dw ${v&&v.decision==='dw'?'on':''}" data-d="dw">→ DW <span class="k">D</span></div>
+ <div class="vbtn wpb ${v&&v.decision==='wpb'?'on':''}" data-d="wpb">→ WPB <span class="k">W</span></div>
+ <div class="vbtn both ${v&&v.decision==='both'?'on':''}" data-d="both">Both <span class="k">B</span></div>
+ <div class="vbtn skip ${v&&v.decision==='skip'?'on':''}" data-d="skip">Skip <span class="k">S</span></div>
+ <textarea class="note" placeholder="note (why it fits / how to adapt)…">${esc(v&&v.note||'')}</textarea>
+ </div>
+ </div>`;
+}
+function wireCard(it, after){
+ const card = $(`.card[data-id="${cssEsc(it.id)}"]`);
+ if(!card) return;
+ $$('.vbtn',card).forEach(b=>b.onclick=()=>{ setVerdict(it.id,b.dataset.d); after&&after(); });
+ const note=$('.note',card);
+ if(note) note.onchange=()=>setNote(it.id, note.value);
+}
+function cssEsc(s){ return (window.CSS&&CSS.escape)?CSS.escape(s):s.replace(/["\\]/g,'\\$&'); }
+
+// ---- BOARD ----
+function renderBoard(){
+ const list = filtered();
+ const cols=[
+ {k:'left',label:'Unreviewed',test:it=>!V[it.id]},
+ {k:'dw',label:'→ DW',test:it=>V[it.id]&&V[it.id].decision==='dw'},
+ {k:'wpb',label:'→ WPB',test:it=>V[it.id]&&V[it.id].decision==='wpb'},
+ {k:'both',label:'Both',test:it=>V[it.id]&&V[it.id].decision==='both'},
+ {k:'skip',label:'Skip',test:it=>V[it.id]&&V[it.id].decision==='skip'},
+ ];
+ $('#board').innerHTML = `<div class="board">`+cols.map(c=>{
+ const its=list.filter(c.test);
+ return `<div class="col ${c.k}" data-col="${c.k}">
+ <h3>${c.label}<span>${its.length}</span></h3>
+ ${its.map(miniHTML).join('')||'<div class="hint" style="padding:8px">—</div>'}
+ </div>`;
+ }).join('')+`</div>`;
+ // drag
+ $$('.mini').forEach(m=>{
+ m.draggable=true;
+ m.addEventListener('dragstart',e=>e.dataTransfer.setData('text/plain',m.dataset.id));
+ });
+ $$('.col').forEach(col=>{
+ col.addEventListener('dragover',e=>{e.preventDefault();col.classList.add('drop');});
+ col.addEventListener('dragleave',()=>col.classList.remove('drop'));
+ col.addEventListener('drop',e=>{
+ e.preventDefault();col.classList.remove('drop');
+ const id=e.dataTransfer.getData('text/plain');
+ const dec=col.dataset.col==='left'?'unset':col.dataset.col;
+ setVerdict(id, dec==='unset'?'unset':dec);
+ if(V[id]&&V[id].decision===dec){} // no-op guard
+ // setVerdict toggles if same; for board force the target:
+ const cur=V[id]&&V[id].decision;
+ if(dec!=='unset' && cur!==dec){ V[id]={decision:dec,note:(V[id]&&V[id].note)||'',ts:Date.now()};
+ fetch('/api/verdict',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,decision:dec,note:V[id].note})});
+ } else if(dec==='unset'){ delete V[id]; }
+ renderBoard();renderCounts();
+ });
+ });
+ $('#board .board').style.gridTemplateColumns=`repeat(${state.cols},1fr)`;
+}
+function miniHTML(it){
+ return `<div class="mini" data-id="${esc(it.id)}" title="${esc(it.title)}">
+ <div class="mt">${esc(it.title)}</div>
+ <div class="ms"><span class="src ${it.source}" style="font-size:9px;padding:1px 5px">${(SRC_LABEL[it.source]||it.source)}</span> ${esc(it.project||'')}</div>
+ </div>`;
+}
+
+// ---- controls wiring ----
+$$('#mode button').forEach(b=>b.onclick=()=>{
+ state.mode=b.dataset.mode; $$('#mode button').forEach(x=>x.classList.toggle('on',x===b)); render();
+});
+$('#fSource').onchange=e=>{state.source=e.target.value;qi=0;render();};
+$('#fStatus').onchange=e=>{state.status=e.target.value;qi=0;render();};
+$('#fProject').onchange=e=>{state.project=e.target.value;qi=0;render();};
+$('#fSort').onchange=e=>{state.sort=e.target.value;qi=0;render();};
+$('#fSearch').oninput=e=>{state.search=e.target.value;qi=0;render();};
+$('#dens').value=state.cols;
+$('#dens').oninput=e=>{state.cols=+e.target.value;localStorage.setItem('fh_cols',state.cols);renderBoard();};
+$('#exportBtn').onclick=()=>window.open('/api/export','_blank');
+$('#refreshBtn').onclick=async()=>{
+ $('#refreshBtn').textContent='↻ …';
+ const r=await fetch('/api/refresh',{method:'POST'}); const d=await r.json();
+ $('#refreshBtn').textContent='↻ Refresh';
+ await load();
+ alert(d.ok?`Refreshed — ${d.count} items`:`Kept cached corpus (${d.count}) — ${d.error||'CNCP unreachable'}`);
+};
+// keyboard (queue mode)
+document.addEventListener('keydown',e=>{
+ if(state.mode!=='queue') return;
+ if(/input|textarea|select/i.test(e.target.tagName)) return;
+ const list=filtered(); if(!list.length) return; const it=list[Math.min(qi,list.length-1)];
+ const map={d:'dw',w:'wpb',b:'both',s:'skip',u:'unset'};
+ if(map[e.key.toLowerCase()]){ e.preventDefault();
+ setVerdict(it.id, map[e.key.toLowerCase()]==='unset'?'unset':map[e.key.toLowerCase()]);
+ if(state.status==='unreviewed') renderQueue(); else {qi++;renderQueue();}
+ } else if(e.key==='ArrowRight'){qi++;renderQueue();}
+ else if(e.key==='ArrowLeft'){qi--;renderQueue();}
+});
+load();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..d668f87
--- /dev/null
+++ b/server.js
@@ -0,0 +1,149 @@
+#!/usr/bin/env node
+/*
+ * feature-harvest — interactive review deck for a year of shipped apps (CNCP wins),
+ * triaging each into a "Add to DW" / "Add to WPB" / "Both" / "Skip" pile so the best
+ * ideas get ported into designerwallcoverings.com or wallpapersback.com.
+ *
+ * Zero npm dependencies (pure Node http). Basic-auth gated (admin / DW2024!).
+ * Source of truth for verdicts = data/verdicts.json (server-side, durable).
+ * Corpus = data/wins-snapshot.json, refreshable live from CNCP (:3333).
+ */
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = __dirname;
+const DATA = path.join(ROOT, 'data');
+const SNAP = path.join(DATA, 'wins-snapshot.json');
+const ITEMS = path.join(DATA, 'items.json');
+const VERDICTS = path.join(DATA, 'verdicts.json');
+const CNCP_WINS = 'http://127.0.0.1:3333/api/wins';
+
+const USER = process.env.FH_USER || 'admin';
+const PASS = process.env.FH_PASS || 'DW2024!';
+const PREFERRED_PORTS = [9771, 9779, 9787, 9793, 0];
+
+// ---- tiny helpers -------------------------------------------------
+function readJSON(file, fallback) {
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
+ catch { return fallback; }
+}
+function writeJSON(file, obj) {
+ fs.writeFileSync(file, JSON.stringify(obj, null, 2));
+}
+function send(res, code, body, type = 'application/json') {
+ res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-store' });
+ res.end(typeof body === 'string' ? body : JSON.stringify(body));
+}
+function authed(req) {
+ const h = req.headers.authorization || '';
+ if (!h.startsWith('Basic ')) return false;
+ const [u, p] = Buffer.from(h.slice(6), 'base64').toString().split(':');
+ return u === USER && p === PASS;
+}
+// Fetch JSON over http (Node http, no deps). Resolves null on any failure.
+function fetchJSON(url, cb) {
+ const req = http.get(url, { timeout: 8000 }, (r) => {
+ let d = '';
+ r.on('data', (c) => (d += c));
+ r.on('end', () => { try { cb(JSON.parse(d)); } catch { cb(null); } });
+ });
+ req.on('error', () => cb(null));
+ req.on('timeout', () => { req.destroy(); cb(null); });
+}
+
+// ---- corpus + verdicts --------------------------------------------
+function loadWins() { return readJSON(SNAP, []); }
+function loadItems() { return readJSON(ITEMS, loadWins()); }
+function loadVerdicts() { return readJSON(VERDICTS, {}); }
+if (!fs.existsSync(VERDICTS)) writeJSON(VERDICTS, {});
+
+function exportMarkdown() {
+ const list = loadItems();
+ const byId = Object.fromEntries(list.map((w) => [w.id, w]));
+ const v = loadVerdicts();
+ const piles = { dw: [], wpb: [], both: [] };
+ for (const [id, rec] of Object.entries(v)) {
+ if (!piles[rec.decision]) continue;
+ const w = byId[id];
+ if (w) piles[rec.decision].push({ w, note: rec.note });
+ }
+ const fmt = (rows) =>
+ rows.length
+ ? rows.map(({ w, note }) =>
+ `### ${w.title}\n` +
+ `*${w.source} · ${w.project} · ${w.date}*${w.commit ? ` · \`${w.commit}\`` : ''}${w.path ? `\n\`${w.path}\`` : ''}\n\n` +
+ `${w.summary || ''}\n\n` +
+ (w.value ? `**Value:** ${w.value}\n\n` : '') +
+ (note ? `**Steve's note:** ${note}\n\n` : '')
+ ).join('\n---\n\n')
+ : '_(none yet)_\n';
+ return (
+ `# Feature Harvest — Shortlist\n\nGenerated ${new Date().toLocaleString()}\n\n` +
+ `## ➜ Add to Designer Wallcoverings (${piles.dw.length})\n\n${fmt(piles.dw)}\n\n` +
+ `## ➜ Add to Wallpaper's Back (${piles.wpb.length})\n\n${fmt(piles.wpb)}\n\n` +
+ `## ➜ Add to Both (${piles.both.length})\n\n${fmt(piles.both)}\n`
+ );
+}
+
+// ---- routes -------------------------------------------------------
+const server = http.createServer((req, res) => {
+ if (!authed(req)) {
+ res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="feature-harvest"' });
+ return res.end('Auth required');
+ }
+ const u = new URL(req.url, 'http://x');
+
+ if (u.pathname === '/' || u.pathname === '/index.html') {
+ return send(res, 200, fs.readFileSync(path.join(ROOT, 'public', 'index.html'), 'utf8'), 'text/html');
+ }
+ if (u.pathname === '/api/items') {
+ return send(res, 200, { items: loadItems(), verdicts: loadVerdicts() });
+ }
+ if (u.pathname === '/api/verdict' && req.method === 'POST') {
+ let body = '';
+ req.on('data', (c) => (body += c));
+ req.on('end', () => {
+ let p;
+ try { p = JSON.parse(body); } catch { return send(res, 400, { error: 'bad json' }); }
+ const v = loadVerdicts();
+ if (p.decision === 'unset') delete v[p.id];
+ else v[p.id] = { decision: p.decision, note: p.note || '', ts: Date.now() };
+ writeJSON(VERDICTS, v);
+ send(res, 200, { ok: true });
+ });
+ return;
+ }
+ if (u.pathname === '/api/refresh' && req.method === 'POST') {
+ return fetchJSON(CNCP_WINS, (data) => {
+ if (Array.isArray(data) && data.length) {
+ writeJSON(SNAP, data);
+ // rebuild unified corpus (wins + dw-apps + wpb-tools)
+ require('child_process').execFile('node', [path.join(ROOT, 'build-corpus.js')], (err) => {
+ send(res, 200, { ok: !err, count: loadItems().length, wins: data.length });
+ });
+ } else {
+ send(res, 200, { ok: false, error: 'CNCP unreachable — kept cached corpus', count: loadItems().length });
+ }
+ });
+ }
+ if (u.pathname === '/api/export') {
+ return send(res, 200, exportMarkdown(), 'text/markdown; charset=utf-8');
+ }
+ send(res, 404, { error: 'not found' });
+});
+
+// ---- listen with port fallback ------------------------------------
+(function listen(i) {
+ const port = PREFERRED_PORTS[i];
+ server.once('error', (e) => {
+ if (e.code === 'EADDRINUSE' && i < PREFERRED_PORTS.length - 1) listen(i + 1);
+ else throw e;
+ });
+ server.listen(port, () => {
+ const p = server.address().port;
+ console.log(`\n feature-harvest → http://127.0.0.1:${p}`);
+ console.log(` login: ${USER} / ${PASS}`);
+ console.log(` corpus: ${loadItems().length} items (wins + DW apps + WPB tools)\n`);
+ });
+})(0);
(oldest)
·
back to Feature Harvest
·
feature-harvest: add static-render preview thumbnails for ap 71db8fd →