← back to Desktop Dotbar

.claude/worktrees/dotbar-always-show-states

246 lines

commit fde99cbb1105434e35ab6ff1b07c1d6fe11e3de0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 25 09:45:28 2026 -0700

    dotbar: always show every state — all 5 ticket states + all colour groups, offline shown not hidden
    
    Steve 2026-09-25: "nav bar should be including all from orig nav bar... blocked, parked,
    doing, idle etc., remember!!" The original Swift TicketBar kept its state tiles up
    permanently; the Electron port hid zero-count states AND hid the whole ticket row when
    the board was unreachable. After this morning's reboot pm2 came back empty, ticket-board
    (:9794) never restarted, and every ticket chip silently vanished.
    
    - ticket-states.js: blocked/open/idle/doing/parked always rendered; three states per chip
      (measured / measured-zero dimmed / NOT-measured "–" + offline), never a fake 0.
    - server: tickets snapshot carries ok:false on a failed fetch; /api/tickets returns states.
    - colour groups: every real colour always shown (zero dimmed, no pulse); only 'no dot' hides.
    - tests: healthy + 2 negative fault-injection cases (board never fetched / went down).
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01Bo75Y1rV4if7AbvCEQohXT

diff --git a/public/index.html b/public/index.html
index 54c69d1..ea8b129 100644
--- a/public/index.html
+++ b/public/index.html
@@ -111,6 +111,10 @@
   .orphan { -webkit-app-region:no-drag; font-size:11px; padding:5px 9px; border-radius:7px;
     border:1px dashed var(--line); background:transparent; color:var(--dim); white-space:nowrap; opacity:.7; }
   .empty { padding:14px; color:var(--dim); }
+  /* Always-shown states (Steve 2026-09-25): a zero count stays visible but dimmed; an unmeasured
+     ticket state (board unreachable) shows "–" with a dashed ring so it can't read as a real 0. */
+  .chip.zero { opacity:.42; }
+  .chip.offline { opacity:.6; border-style:dashed; }
 </style>
 </head>
 <body class="orient-top">
@@ -156,10 +160,10 @@ function renderBar(){
   bar.innerHTML = '';
   for (const g of data.groups){
     const chip = document.createElement('div');
-    chip.className = 'chip' + (openColor===g.color ? ' active':'');
+    chip.className = 'chip' + (openColor===g.color ? ' active':'') + (g.count ? '' : ' zero');
     // On a side dock the chip always shows its name (there's vertical room); on top only when open.
     const showName = (openColor===g.color) || cfg.orientation !== 'top';
-    chip.innerHTML = `<span class="dot${WAIT.has(g.color)?' pulse':''}" style="background:${g.css}"></span>`
+    chip.innerHTML = `<span class="dot${WAIT.has(g.color)&&g.count?' pulse':''}" style="background:${g.css}"></span>`
       + `<span class="cnt">${g.count}</span>`
       + (showName ? `<span class="nm">${g.name}</span>` : '');
     chip.onclick = () => toggle(g.color);
@@ -193,28 +197,29 @@ function renderBar(){
   snbtn.textContent = ram.on ? '🔫 ON' : '🔫 OFF';
   snbtn.onclick = toggleSniper;
   bar.appendChild(snbtn);
-  // Ticket-board states as INDIVIDUAL vertical chips (Steve 2026-09-23) — same dot+count+label
-  // style as the colour chips above, instead of one cramped horizontal 🔴/⚪/⚫ segment row. Each
-  // chip opens the Fleet board filtered to its section (idle maps to the board's `stopped`). Only
-  // non-zero states show (mirrors the empty-group filter on the colour chips). Placed in the top
-  // cluster BEFORE the spacers so they read as a continuation of the list, not a bottom footer.
-  if (tickets.updated) {
+  // Ticket-board states as INDIVIDUAL vertical chips — ALWAYS all five (Steve 2026-09-25: "nav bar
+  // should be including all from orig nav bar... blocked, parked, doing, idle"). Zero = dimmed;
+  // board unreachable / never fetched = "–" + dashed OFFLINE ring (never a fake 0, never hidden).
+  // The server computes `states` (ticket-states.js); fall back to the same list client-side.
+  {
     const showName = cfg.orientation !== 'top';   // a side dock has vertical room for the label
-    const TSTATES = [
-      { n: tickets.blocked, section:'blocked', label:'blocked', css:'#f87171' },
-      { n: tickets.open,    section:'open',    label:'open',    css:'#e5e7eb' },
-      { n: tickets.idle,    section:'stopped', label:'idle',    css:'#9ca3af' },
-      { n: tickets.doing,   section:'doing',   label:'doing',   css:'#60a5fa' },
-      { n: tickets.parked,  section:'parked',  label:'parked',  css:'#f9a8d4' },
-    ];
+    const measured = !!tickets.updated && tickets.ok !== false;
+    const TSTATES = (tickets.states && tickets.states.length) ? tickets.states : [
+      { key:'blocked', section:'blocked', label:'blocked', css:'#f87171' },
+      { key:'open',    section:'open',    label:'open',    css:'#e5e7eb' },
+      { key:'idle',    section:'stopped', label:'idle',    css:'#9ca3af' },
+      { key:'doing',   section:'doing',   label:'doing',   css:'#60a5fa' },
+      { key:'parked',  section:'parked',  label:'parked',  css:'#f9a8d4' },
+    ].map(t => ({ ...t, n: measured ? tickets[t.key] : null }));
     for (const t of TSTATES) {
-      if (!t.n) continue;                          // hide zero-count states, like the colour groups
+      const off = t.n === null || t.n === undefined;
       const tc = document.createElement('div');
-      tc.className = 'chip tkchip';
-      tc.title = `${t.n} ${t.label} — open the Fleet board`;
+      tc.className = 'chip tkchip' + (off ? ' offline' : (t.n ? '' : ' zero'));
+      tc.title = off ? `${t.label} — ticket board unreachable (not measured). Click to open the Fleet board`
+                     : `${t.n} ${t.label} — open the Fleet board`;
       tc.innerHTML = `<span class="dot" style="background:${t.css}"></span>`
-        + `<span class="cnt">${t.n}</span>`
-        + (showName ? `<span class="nm">${t.label}</span>` : '');
+        + `<span class="cnt">${off ? '–' : t.n}</span>`
+        + (showName ? `<span class="nm">${t.label}${off ? ' · offline' : ''}</span>` : '');
       tc.onclick = () => openBoard(t.section);
       bar.appendChild(tc);
     }
@@ -370,7 +375,8 @@ async function tick(){
   data = d;
   // Fetch ticket-board summary in parallel (Steve 2026-09-23).
   const t = await fetchTickets();
-  if (t && t.updated) tickets = t;
+  if (t && (t.updated || t.states)) tickets = t;
+  else if (!t) tickets = { ...tickets, ok: false, states: null };   // dotbar API unreachable -> show OFFLINE, not stale counts
   const openStillHasItems = openColor==='ram'
     ? true                                   // RAM panel is always valid; never auto-close it
     : openColor==='parked'
diff --git a/server.js b/server.js
index 57ce537..baa7cd1 100755
--- a/server.js
+++ b/server.js
@@ -9,6 +9,7 @@ const { execFile, spawn } = require('child_process');
 const fs = require('fs');
 const path = require('path');
 const jevDots = require('./jev-dots');  // re-classifies each dot COLOR via Jev ($0 builtin; paid flip gated)
+const { ticketStates } = require('./ticket-states');  // always-render ticket-state row (blocked/open/idle/doing/parked)
 
 const ALLCOLORDOTS = `${process.env.HOME}/.claude/skills/allcolordots/allcolordots.sh`;
 const ROUTER = `${process.env.HOME}/.claude/skills/dot-screen-router/router.sh`;
@@ -119,7 +120,7 @@ async function getDots() {
   }
   const out = ORDER
     .map(color => ({ color, ...META[color], count: groups[color].length, sessions: groups[color] }))
-    .filter(g => g.count > 0 || g.color === 'green'); // always show green; hide empty others
+    .filter(g => g.count > 0 || g.color !== 'none'); // ALWAYS show every real colour (dimmed at 0, Steve 2026-09-25); hide only an empty 'no dot'
   const parkedItems = parked.map(e => {
     const tty = e.tty || (e.kind === 'tab' ? e.id : '');
     return {
@@ -256,7 +257,7 @@ function send(res, code, body, type = 'application/json') {
 // serve a cached snapshot — /api/dots must return instantly for a smooth bar.
 let snapshot = { updated: 0, total: 0, groups: [], stale: true };
 let ramSnapshot = { updated: 0, ram_pct: 0, cpu_pct: 0, on: false, hogs: [] };
-let ticketsSnapshot = { updated: 0, blocked: 0, open: 0, idle: 0, doing: 0, parked: 0, latest: null };
+let ticketsSnapshot = { updated: 0, ok: false, blocked: 0, open: 0, idle: 0, doing: 0, parked: 0, latest: null };
 let refreshing = false;
 async function refreshRam() { try { ramSnapshot = await getRam(); } catch (e) { /* keep last */ } }
 // Fetch ticket-board summary: blocked / open / idle(stopped) / doing / parked + latest event.
@@ -292,11 +293,16 @@ async function getTickets() {
     }
     if (newest) tickets.latest = newest.label;
   }
-  return { updated: Date.now(), ...tickets };
+  return { updated: Date.now(), ok: true, ...tickets };
 }
 // Keep the last-good snapshot when getTickets signals a failed fetch (null), mirroring getDots.
 async function refreshTickets() {
-  try { const t = await getTickets(); if (t) ticketsSnapshot = t; } catch (e) { /* keep last */ }
+  // A failed fetch keeps the last-good counts but flips ok:false so the bar shows the row as
+  // OFFLINE ("–") instead of presenting stale/zero counts as if they were measured.
+  let t = null;
+  try { t = await getTickets(); } catch (e) { t = null; }
+  if (t) ticketsSnapshot = t;
+  else ticketsSnapshot = { ...ticketsSnapshot, ok: false, failed_at: Date.now() };
 }
 async function refresh() {
   if (refreshing) return;
@@ -323,7 +329,7 @@ const server = http.createServer(async (req, res) => {
     if (url.pathname === '/api/ram/toggle' && req.method === 'POST') {
       const r = await toggleSniper(); await refreshRam(); return send(res, 200, { ...ramSnapshot, ...r });
     }
-    if (url.pathname === '/api/tickets') { if (!ticketsSnapshot.updated) await refreshTickets(); return send(res, 200, ticketsSnapshot); }
+    if (url.pathname === '/api/tickets') { if (!ticketsSnapshot.updated) await refreshTickets(); return send(res, 200, { ...ticketsSnapshot, states: ticketStates(ticketsSnapshot) }); }
     if (url.pathname === '/api/reveal' && req.method === 'POST') {
       let raw = '';
       req.on('data', c => (raw += c));
diff --git a/test/ticket-states.test.js b/test/ticket-states.test.js
new file mode 100644
index 0000000..a21b3a8
--- /dev/null
+++ b/test/ticket-states.test.js
@@ -0,0 +1,39 @@
+'use strict';
+// ticket-states: the bar must ALWAYS carry all five ticket states (Steve 2026-09-25), and an
+// unreachable board must read as NOT-MEASURED — never as hidden, never as a fake 0. Zero deps.
+const test = require('node:test');
+const assert = require('node:assert');
+const { ticketStates, TICKET_STATES } = require('../ticket-states');
+
+const KEYS = ['blocked', 'open', 'idle', 'doing', 'parked'];
+
+test('healthy board: all five states present with measured counts, zeros kept', () => {
+  const s = ticketStates({ updated: Date.now(), ok: true, blocked: 78, open: 33, idle: 0, doing: 20, parked: 5 });
+  assert.deepEqual(s.map(x => x.key), KEYS);
+  assert.deepEqual(s.map(x => x.n), [78, 33, 0, 20, 5]);
+  const idle = s.find(x => x.key === 'idle');
+  assert.equal(idle.zero, true, 'a zero state is KEPT (dimmed), not dropped');
+  assert.equal(s.find(x => x.key === 'idle').section, 'stopped', 'idle opens the board stopped section');
+});
+
+// NEGATIVE (fault-injection): the exact 2026-09-25 failure — ticket-board down after a reboot.
+test('NEGATIVE: never-fetched board -> five chips, every one NOT-measured (null, not 0)', () => {
+  const s = ticketStates({ updated: 0, ok: false, blocked: 0, open: 0, idle: 0, doing: 0, parked: 0 });
+  assert.equal(s.length, 5, 'row must not disappear when the board is down');
+  for (const x of s) {
+    assert.strictEqual(x.n, null, `${x.key} must be null (unmeasured), got ${x.n}`);
+    assert.equal(x.measured, false);
+  }
+});
+
+test('NEGATIVE: board went down after a good fetch -> stale counts are NOT presented as measured', () => {
+  const s = ticketStates({ updated: Date.now() - 60000, ok: false, blocked: 78, open: 33, idle: 4, doing: 20, parked: 5 });
+  assert.ok(s.every(x => x.n === null), 'ok:false must blank the counts to "–"');
+});
+
+test('missing/garbage snapshot never throws and still yields all five', () => {
+  for (const snap of [undefined, null, {}, { updated: 1, ok: true, blocked: 'x' }]) {
+    const s = ticketStates(snap);
+    assert.equal(s.length, TICKET_STATES.length);
+  }
+});
diff --git a/ticket-states.js b/ticket-states.js
new file mode 100644
index 0000000..92c4984
--- /dev/null
+++ b/ticket-states.js
@@ -0,0 +1,29 @@
+'use strict';
+// The ticket-state row the bar ALWAYS renders (Steve 2026-09-25: "nav bar should be including all
+// from orig nav bar... blocked, parked, doing, idle etc."). The original Swift TicketBar showed its
+// OPEN/BLOCKED/DOING/PARKED tiles permanently ("…" placeholder when it had no data); the Electron
+// port hid zero states AND hid the whole row whenever the board was unreachable — which is how a
+// dead ticket-board after a reboot erased every ticket chip with no signal at all.
+//
+// Three states per chip, never two: measured-nonzero, measured-zero (dimmed), NOT-measured
+// (board unreachable / never fetched: count null, rendered as "–" + offline). A missing board
+// must never look like "0 blocked".
+const TICKET_STATES = [
+  { key: 'blocked', section: 'blocked', label: 'blocked', css: '#f87171' },
+  { key: 'open',    section: 'open',    label: 'open',    css: '#e5e7eb' },
+  { key: 'idle',    section: 'stopped', label: 'idle',    css: '#9ca3af' },
+  { key: 'doing',   section: 'doing',   label: 'doing',   css: '#60a5fa' },
+  { key: 'parked',  section: 'parked',  label: 'parked',  css: '#f9a8d4' },
+];
+
+// snap = the server's tickets snapshot ({updated, ok, blocked, open, ...}).
+function ticketStates(snap) {
+  const s = snap || {};
+  const measured = !!s.updated && s.ok !== false;
+  return TICKET_STATES.map(t => {
+    const n = measured && Number.isFinite(s[t.key]) ? s[t.key] : null;
+    return { ...t, n, measured: n !== null, zero: n === 0 };
+  });
+}
+
+module.exports = { TICKET_STATES, ticketStates };