← 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 };