[object Object]

← back to Doing Viewer

doing-viewer: live 3-column board (doing / what it's doing / plain words via local qwen3)

b2a0530b37892a8318c3525508128dab4143c117 · 2026-08-20 12:08:55 -0700 · Steve

Files touched

Diff

commit b2a0530b37892a8318c3525508128dab4143c117
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 20 12:08:55 2026 -0700

    doing-viewer: live 3-column board (doing / what it's doing / plain words via local qwen3)
---
 .gitignore        |   6 +++
 public/index.html | 133 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js         | 137 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 276 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b46ca66
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+data/eli12-cache.json
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..712572b
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,133 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>What's Doing — live board</title>
+<style>
+  :root{
+    --bg:#0e1116; --panel:#161b22; --panel2:#1b2230; --line:#252c38;
+    --ink:#e6edf3; --dim:#8b98a9; --accent:#4aa8ff; --doing:#ffb454;
+    --kid:#7ee787; --kidbg:#12241a;
+  }
+  *{box-sizing:border-box}
+  body{margin:0;background:var(--bg);color:var(--ink);
+    font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
+  header{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;
+    padding:14px 20px;border-bottom:1px solid var(--line);background:var(--panel);
+    position:sticky;top:0;z-index:5}
+  header h1{margin:0;font-size:18px;font-weight:700;letter-spacing:.2px}
+  .pill{font-size:12px;color:var(--dim);background:var(--panel2);border:1px solid var(--line);
+    padding:2px 9px;border-radius:999px}
+  .pill b{color:var(--doing)}
+  .spacer{flex:1}
+  #clock{font-variant-numeric:tabular-nums;color:var(--dim);font-size:12px}
+  .dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--kid);
+    margin-right:6px;animation:pulse 2s infinite}
+  @keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
+
+  .head-row,.card{display:grid;grid-template-columns:1.15fr 1.6fr 1.25fr;gap:0}
+  .head-row{position:sticky;top:53px;z-index:4;background:var(--bg);
+    border-bottom:1px solid var(--line)}
+  .head-row div{padding:10px 18px;font-size:11px;letter-spacing:.14em;text-transform:uppercase;
+    color:var(--dim);font-weight:700}
+  .head-row .h3{color:var(--kid)}
+
+  .list{padding-bottom:60px}
+  .card{border-bottom:1px solid var(--line)}
+  .card:hover{background:#12161d}
+  .cell{padding:16px 18px;border-right:1px solid var(--line)}
+  .cell:last-child{border-right:0}
+
+  /* col 1 — what's doing */
+  .c1 .id{font-size:11px;color:var(--accent);font-family:ui-monospace,Menlo,monospace}
+  .c1 .title{font-weight:650;margin:3px 0 7px;font-size:14.5px}
+  .c1 .meta{font-size:11.5px;color:var(--dim)}
+  .c1 .meta .proj{color:#c9a6ff}
+  .badge{display:inline-block;font-size:10.5px;padding:1px 7px;border-radius:5px;
+    background:rgba(255,180,84,.14);color:var(--doing);border:1px solid rgba(255,180,84,.3);
+    margin-top:8px;font-weight:600}
+
+  /* col 2 — what it's doing */
+  .c2 .who{font-size:11px;color:var(--dim);margin-bottom:5px}
+  .c2 .who b{color:#8fd3ff}
+  .c2 .act{font-size:13px;color:#d7dee7;white-space:pre-wrap;
+    max-height:150px;overflow:auto}
+  .c2 .age{font-size:11px;color:var(--dim);margin-top:8px}
+  .fresh{color:var(--kid)!important}
+  .warn{color:var(--doing)!important}
+  .stale{color:#ff7b72!important}
+
+  /* col 3 — in plain words */
+  .c3{background:var(--kidbg)}
+  .c3 .kid{font-size:14.5px;line-height:1.6;color:#d9f7e4}
+  .c3 .thinking{color:var(--dim);font-style:italic}
+  .c3 .via{font-size:10.5px;color:#4d7a5e;margin-top:9px}
+  .empty{padding:60px;text-align:center;color:var(--dim)}
+  @media(max-width:860px){
+    .head-row{display:none}
+    .card{grid-template-columns:1fr}
+    .cell{border-right:0}
+    .c3{border-top:1px dashed #2c4a38}
+  }
+</style>
+</head>
+<body>
+<header>
+  <h1><span class="dot"></span>What's Doing</h1>
+  <span class="pill"><b id="count">–</b> tasks in flight</span>
+  <span class="pill" id="pend"></span>
+  <div class="spacer"></div>
+  <span id="clock">connecting…</span>
+</header>
+
+<div class="head-row">
+  <div>1 · What's doing</div>
+  <div>2 · What it's doing right now</div>
+  <div class="h3">3 · In plain words 🧒</div>
+</div>
+
+<div class="list" id="list"><div class="empty">Loading the board…</div></div>
+
+<script>
+const esc = s => (s||'').replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
+function ageClass(h){ return h<6?'fresh':h<48?'warn':'stale'; }
+function agoTxt(ts,h){
+  const m=Math.round(h*60);
+  if(m<60) return m+' min ago';
+  if(h<48) return h.toFixed(1)+' h ago';
+  return (h/24).toFixed(1)+' days ago';
+}
+async function load(){
+  let d;
+  try{ d = await (await fetch('/api/doing',{cache:'no-store'})).json(); }
+  catch(e){ document.getElementById('clock').textContent='reconnecting…'; return; }
+  document.getElementById('count').textContent = d.count;
+  document.getElementById('pend').textContent = d.pending ? ('✍️ writing '+d.pending+' plain-word summaries…') : '✅ all summarized';
+  document.getElementById('clock').textContent = 'live · as of '+new Date(d.now).toLocaleTimeString();
+  const list = document.getElementById('list');
+  if(!d.items.length){ list.innerHTML='<div class="empty">Nothing is doing right now. 🎉</div>'; return; }
+  list.innerHTML = d.items.map(i=>`
+    <div class="card">
+      <div class="cell c1">
+        <div class="id">${esc(i.id)}</div>
+        <div class="title">${esc(i.title)}</div>
+        <div class="meta"><span class="proj">${esc(i.project||'—')}</span></div>
+        <div class="badge">DOING</div>
+      </div>
+      <div class="cell c2">
+        <div class="who">agent <b>${esc(i.agent||'—')}</b></div>
+        <div class="act">${esc(i.doingText)||'<span style="color:var(--dim)">just started — nothing logged yet</span>'}</div>
+        <div class="age ${ageClass(i.ageH)}">last activity ${agoTxt(i.doingTs,i.ageH)}</div>
+      </div>
+      <div class="cell c3">
+        ${ i.eli12
+            ? `<div class="kid">${esc(i.eli12)}</div><div class="via">explained by a local model, free</div>`
+            : `<div class="kid thinking">🤔 getting the simple version…</div>` }
+      </div>
+    </div>`).join('');
+}
+load(); setInterval(load, 8000);
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..45e1b1f
--- /dev/null
+++ b/server.js
@@ -0,0 +1,137 @@
+#!/usr/bin/env node
+// doing-viewer — a live 3-column board of every ticket that is DOING right now:
+//   col 1  What's doing        (the task)
+//   col 2  What it's doing      (the latest Claude Code activity on it)
+//   col 3  In plain words       (a 12-year-old-friendly explanation, local LLM, $0)
+//
+// Zero-dependency: pure node http + the ticket system's own lib.js (single source
+// of truth for status). The plain-words column is generated by local Ollama
+// (qwen3:14b) and cached by activity fingerprint so it only re-writes when a card
+// actually does something new.
+
+const http = require('http');
+const fs   = require('fs');
+const path = require('path');
+const os   = require('os');
+
+const PORT       = process.env.PORT || 9790;
+const TICKET_LIB = path.join(os.homedir(), 'Projects', 'ticket-system', 'lib.js');
+const CACHE_FILE = path.join(__dirname, 'data', 'eli12-cache.json');
+const OLLAMA     = 'http://127.0.0.1:11434/api/generate';
+const MODEL      = process.env.ELI12_MODEL || 'qwen3:14b';
+
+const { tickets } = require(TICKET_LIB);
+
+// ── plain-words cache ────────────────────────────────────────────────────────
+let cache = {};
+try { cache = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); } catch {}
+let cacheDirty = false;
+function saveCache() {
+  if (!cacheDirty) return;
+  try { fs.writeFileSync(CACHE_FILE, JSON.stringify(cache)); cacheDirty = false; } catch {}
+}
+setInterval(saveCache, 5000);
+
+// ── build the doing list from the shared event log ───────────────────────────
+function doingList() {
+  const map = tickets();
+  const out = [];
+  for (const t of map.values()) {
+    if (t.status !== 'doing') continue;
+    const lastAction = t.actions.length ? t.actions[t.actions.length - 1] : null;
+    const lastComment = t.comments.length ? t.comments[t.comments.length - 1] : null;
+    // "what it's doing" = newest of last action / last comment
+    let doingText = '', doingTs = t.updated_at, doingAgent = t.assignee || t.agent || '';
+    const cand = [lastAction, lastComment].filter(Boolean).sort((a, b) => (a.ts > b.ts ? -1 : 1))[0];
+    if (cand) { doingText = cand.text || ''; doingTs = cand.ts; doingAgent = cand.agent || doingAgent; }
+    const ageH = (Date.now() - new Date(t.updated_at).getTime()) / 3.6e6;
+    const fp = t.id + ':' + doingTs;               // activity fingerprint
+    out.push({
+      id: t.id, title: t.title, project: t.project,
+      agent: doingAgent, doingText, doingTs, ageH,
+      fp, eli12: cache[fp] ? cache[fp].text : null,
+    });
+  }
+  out.sort((a, b) => a.ageH - b.ageH);             // freshest activity first
+  return out;
+}
+
+// ── local LLM: explain like I'm 12 ($0, cached) ──────────────────────────────
+function stripThink(s) { return String(s).replace(/<think>[\s\S]*?<\/think>/gi, '').trim(); }
+
+function ollamaEli12(item) {
+  return new Promise((resolve) => {
+    const prompt =
+`You explain computer work to a smart 12-year-old. In 1-2 short, friendly sentences, plainly say what this task is trying to do and how it's going right now. No tech jargon, no code words, no lists. Use an everyday analogy only if it helps.
+
+TASK: ${item.title}
+LATEST ACTIVITY: ${item.doingText || '(just started, nothing logged yet)'}
+
+Plain answer:`;
+    const body = JSON.stringify({
+      model: MODEL, prompt, stream: false, think: false,
+      options: { temperature: 0.4, num_predict: 120 },
+    });
+    const req = http.request(OLLAMA, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
+      timeout: 60000,
+    }, (res) => {
+      let d = '';
+      res.on('data', c => d += c);
+      res.on('end', () => {
+        try { resolve(stripThink(JSON.parse(d).response) || null); }
+        catch { resolve(null); }
+      });
+    });
+    req.on('error', () => resolve(null));
+    req.on('timeout', () => { req.destroy(); resolve(null); });
+    req.end(body);
+  });
+}
+
+// heuristic fallback so the column is never empty even if Ollama is down
+function heuristicEli12(item) {
+  const t = (item.title || '').replace(/[_\-]+/g, ' ');
+  const a = item.doingText ? ` Right now it just: "${item.doingText.slice(0, 120)}".` : '';
+  return `This task is about: ${t}.${a}`;
+}
+
+// background generator — one card per tick, only missing fingerprints
+let busy = false;
+async function tick() {
+  if (busy) return; busy = true;
+  try {
+    const items = doingList().filter(i => !cache[i.fp]);
+    if (items.length) {
+      const item = items[0];
+      let text = await ollamaEli12(item);
+      let via = 'qwen3:14b (local, $0)';
+      if (!text) { text = heuristicEli12(item); via = 'heuristic (ollama unavailable)'; }
+      cache[item.fp] = { text, via, at: new Date().toISOString() };
+      cacheDirty = true;
+    }
+  } catch {} finally { busy = false; }
+}
+setInterval(tick, 4000);
+tick();
+
+// ── http ─────────────────────────────────────────────────────────────────────
+const INDEX = fs.readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf8');
+http.createServer((req, res) => {
+  if (req.url === '/api/doing') {
+    const list = doingList();
+    const pending = list.filter(i => !i.eli12).length;
+    res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+    res.end(JSON.stringify({ now: new Date().toISOString(), count: list.length, pending, items: list }));
+    return;
+  }
+  if (req.url === '/' || req.url === '/index.html') {
+    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
+    res.end(INDEX);
+    return;
+  }
+  res.writeHead(404); res.end('not found');
+}).listen(PORT, () => console.log(`doing-viewer on http://127.0.0.1:${PORT}`));
+
+process.on('SIGTERM', () => { saveCache(); process.exit(0); });

(oldest)  ·  back to Doing Viewer  ·  doing-viewer: add Basic Auth (admin/DW2024!) gate 4e2d1e3 →