← back to Ticket System
board: per-ticket DW / Non-DW segment field (editable DW? column, /api/segment, group-by, overrides project-name rule)
1316a6534052661191f838a29be5d4614ab75e70 · 2026-09-25 10:43:20 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoE3f4bpXix6QSPTkT7WFv
Files touched
M board.htmlM lib.jsM lib.test.jsM server.js
Diff
commit 1316a6534052661191f838a29be5d4614ab75e70
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Sep 25 10:43:20 2026 -0700
board: per-ticket DW / Non-DW segment field (editable DW? column, /api/segment, group-by, overrides project-name rule)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoE3f4bpXix6QSPTkT7WFv
---
board.html | 20 ++++++++++++++++----
lib.js | 5 ++++-
lib.test.js | 14 ++++++++++++++
server.js | 18 +++++++++++++++++-
4 files changed, 51 insertions(+), 6 deletions(-)
diff --git a/board.html b/board.html
index 9163e472..69c86585 100644
--- a/board.html
+++ b/board.html
@@ -69,6 +69,7 @@
/* per-row status reassign dropdown (TK-12076) */
.stsel{font:inherit;font-size:11px;padding:1px 6px;border-radius:20px;border:1px solid var(--line);background:var(--card);cursor:pointer;max-width:118px;text-decoration:none}
.stsel:hover{border-color:var(--acc)} .stsel:disabled{opacity:.5;cursor:progress}
+ .segsel{font:inherit;background:transparent;border:1px solid var(--line);border-radius:6px;padding:1px 4px;cursor:pointer}.segsel.seg-auto{opacity:.6;font-style:italic}.segsel.seg-set{font-weight:600}.segsel.seg-dw{color:var(--gold)}.segsel.seg-non{color:var(--mut)}
.stsel.s-open{color:var(--open)}.stsel.s-doing{color:var(--doing)}.stsel.s-blocked{color:var(--blocked)}.stsel.s-done{color:var(--done)}.stsel.s-stopped{color:var(--stopped);text-decoration:none}.stsel.s-parked{color:#e879b9}
.stsel option{color:var(--ink);background:var(--card)}
.vb{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:1px 7px;border-radius:20px;border:1px solid var(--line)}
@@ -134,7 +135,7 @@
<span id="pills"></span>
<input class="search" id="q" placeholder="search all fields (space = AND)…">
<label style="font-size:11px;color:var(--mut)">Group
- <select class="gsel" id="grp"><option value="none">none</option><option value="status">status</option><option value="blocker">blocker lane</option><option value="project">project</option><option value="assignee">owner</option></select>
+ <select class="gsel" id="grp"><option value="none">none</option><option value="status">status</option><option value="blocker">blocker lane</option><option value="project">project</option><option value="assignee">owner</option><option value="segment">DW / Non-DW</option></select>
</label>
<span id="dtdchip" title="last DTD run-now sweep">DTD: —</span>
<span class="grow"></span>
@@ -207,6 +208,7 @@ const COLS=[
{k:'ratings',l:'Ratings',g:'Priority',w:150, def:1, cell:t=>barsCell(t), raw:t=>t.priority||0},
{k:'id', l:'Ticket', g:'Core', w:150, def:1, cell:t=>ticketCell(t), raw:t=>t.id},
{k:'about', l:'About · 5 words',g:'Core',w:230,def:1,cell:t=>`<span title="${esc(t.title)}">${esc(fiveWords(t.title))}</span>`,raw:t=>fiveWords(t.title)},
+ {k:'segment',l:'DW?',g:'Core',w:118,def:1,cell:t=>segCell(t),raw:t=>isDW(t)?'DW':'Non-DW'},
{k:'status',l:'Status', g:'Core', w:120, def:1, cell:t=>{const cur=t.parked?'parked':t.status;const o=['open','doing','blocked','done','stopped','parked'].map(s=>`<option value="${s}"${s===cur?' selected':''}>${s==='stopped'?'TicketStopped':s==='parked'?'⏸ parked':s}</option>`).join('');return `<select class="stsel s-${cur}" data-id="${t.id}" data-cur="${cur}" title="Reassign status" onpointerdown="event.stopPropagation()" onmousedown="event.stopPropagation()" onclick="event.stopPropagation()" onchange="reassignStatus(event)">${o}</select>`;}, raw:t=>t.parked?'parked':t.status},
{k:'blocker',l:'Blocker lane',g:'Blocker',w:150,def:1,cell:t=>blockerCell(t),raw:t=>t.blocker?.type||''},
{k:'next_action',l:'Next unblock',g:'Blocker',w:320,def:1,cell:t=>esc(t.blocker?.next_action||'—'),raw:t=>t.blocker?.next_action||''},
@@ -238,7 +240,7 @@ let sortKey=LS('tkSortKey','created_at'), sortDir=LS('tkSortDir',-1); if(!CK.inc
let VIEW=LS('tkView','task');
let VIS=LS('tkCols',null)||Object.fromEntries(COLS.map(c=>[c.k,!!c.def]));
for(const c of COLS)if(!(c.k in VIS))VIS[c.k]=!!c.def; // new cols (runnow) default ON even with an old stored set
-let ORDER=(Array.isArray(LS('tkOrder',null))?LS('tkOrder',null):[]).filter(k=>CK.includes(k)); for(const k of CK)if(!ORDER.includes(k))ORDER.push(k);
+let ORDER=(Array.isArray(LS('tkOrder',null))?LS('tkOrder',null):[]).filter(k=>CK.includes(k)); for(const k of CK)if(!ORDER.includes(k)){const prev=CK[CK.indexOf(k)-1],at=ORDER.indexOf(prev);at<0?ORDER.push(k):ORDER.splice(at+1,0,k);} // new cols land beside their COLS neighbour, not at the far right
let WID=LS('tkWid',{}), DENS=LS('tkDens',7);
let GROUP=LS('tkGroup','none');
let SEL=new Set(); // selected ticket ids — survives re-render/auto-refresh
@@ -296,7 +298,8 @@ const DW_RE=/designerwallcoverings|designer-wallcoverings|dw_unified|(^|[^a-z])d
const DW_KW=/wallcover|wallpaper/i; // DW wallcovering sister-sites (architectural/contract/hospitality/goldleaf)
const DW_VENDOR=/(^|[^a-z])(sanderson|stroheim|muralsource|kravet|greenland|malibu|gracie|astek|fabricut|groundworks|rebel-walls|thibaut|schumacher|koroseal|scalamandre|maya-?romanoff|winfield|phillip-?jeffries|brewster|wallquest|carnegie|designtex|momentum|versa|elitis|fromental|fentucci|hollywood|novasuede|philipperomano|mdc)($|[^a-z])/i;
const DW_EXTRA=new Set(['norma','greenland-onboard','sample-followup','sample-followup-sweep','interiordesignershowroom','marketing-command-center','all-designerwallcoverings','consulting-designerwallcoverings-com','mfr-review-viewer','mfr-review-viewer-corruption','designersalesreps','vendor-command-center','discontinued-agent']);
-function isDW(t){const p=String(t.project||'').toLowerCase().trim();if(!p||DW_DENY.has(p))return false;return DW_EXTRA.has(p)||DW_RE.test(p)||DW_VENDOR.test(p)||DW_KW.test(p);}
+function isDW(t){if(t.segment==='dw')return true;if(t.segment==='nondw')return false;return autoDW(t);}
+function autoDW(t){const p=String(t.project||'').toLowerCase().trim();if(!p||DW_DENY.has(p))return false;return DW_EXTRA.has(p)||DW_RE.test(p)||DW_VENDOR.test(p)||DW_KW.test(p);}
function match(t){if(VIEW!=='all'&&(t.kind||'task')!==VIEW)return false;if(['open','blocked','doing'].includes(SECTION)&&t.status!==SECTION)return false;if(['open','blocked','doing'].includes(SECTION)&&t.parked)return false;/*TK-11946: parked held out of the active queue (mirrors /kanban parked lane)*/if(SECTION==='parked'&&!(t.parked&&['open','doing','blocked'].includes(t.status)))return false;if(SECTION==='nondw'&&isDW(t))return false;if(!q)return true;
// search the FULL thread — every comment (note/win/challenge/cody incl.) + every action — not just lastText
const thread=[...(t.comments||[]).map(c=>c.text),...(t.actions||[]).map(a=>a.text)].join(' ');
@@ -333,7 +336,7 @@ function render(){
for(const t of rows)rowTr(t);
}else{
const groups=new Map();
- for(const t of rows){const gv=GROUP==='status'?((t.parked&&['open','doing','blocked'].includes(t.status))?'parked':t.status)/*TK-11946*/:GROUP==='blocker'?(t.blocker?.type||''):GROUP==='project'?(t.project||''):(t.assignee||'');(groups.get(gv)||groups.set(gv,[]).get(gv)).push(t);}
+ for(const t of rows){const gv=GROUP==='status'?((t.parked&&['open','doing','blocked'].includes(t.status))?'parked':t.status)/*TK-11946*/:GROUP==='blocker'?(t.blocker?.type||''):GROUP==='project'?(t.project||''):GROUP==='segment'?(isDW(t)?'DW':'Non-DW'):(t.assignee||'');(groups.get(gv)||groups.set(gv,[]).get(gv)).push(t);}
// order groups: for status use canonical order, else alpha
const order=GROUP==='status'?['open','doing','blocked','done','stopped','parked']:[...groups.keys()].sort((a,b)=>String(a).localeCompare(String(b)));
for(const gv of order){const list=groups.get(gv);if(!list)continue;
@@ -406,6 +409,15 @@ $('#reopenBtn').onclick=async()=>{const ids=selIds();if(!ids.length)return;try{a
// Per-row status reassign (TK-12076). 'parked' isn't a real status — it's the durable PARKED
// registry — so route it to /api/park; a real status routes to /api/status (unparking first
// if the ticket was parked). On failure, restore the select so the row never lies.
+// DW / Non-DW segment (per-ticket). "auto" = the project-name rule above; picking DW or Non-DW
+// writes a 'segment' event that overrides it. Bold = set by hand, faint = auto-guessed.
+function segCell(t){const auto=autoDW(t)?'DW':'Non-DW',cur=t.segment||'';const o=[['',`auto · ${auto}`],['dw','DW'],['nondw','Non-DW']].map(([v,l])=>`<option value="${v}"${v===cur?' selected':''}>${l}</option>`).join('');return `<select class="segsel ${cur?'seg-set':'seg-auto'} ${isDW(t)?'seg-dw':'seg-non'}" data-id="${t.id}" data-cur="${cur}" title="${cur?'Set by hand':'Auto-guessed from project — pick to assign'}" onpointerdown="event.stopPropagation()" onmousedown="event.stopPropagation()" onclick="event.stopPropagation()" onchange="reassignSegment(event)">${o}</select>`;}
+async function reassignSegment(e){e.stopPropagation();const sel=e.target,id=sel.dataset.id,to=sel.value,cur=sel.dataset.cur;if(to===cur)return;
+ const ids=(SEL.has(id)&&SEL.size>1)?[...SEL]:[id];sel.disabled=true;setRunMsg(`${ids.length} ticket(s) → ${to==='dw'?'DW':to==='nondw'?'Non-DW':'auto'}…`);
+ try{const r=await post('/api/segment',{ids,segment:to});if(r&&r.error)throw new Error(r.error);setRunMsg(`${ids.length} ticket(s) → ${to==='dw'?'DW':to==='nondw'?'Non-DW':'auto'}`);}
+ catch(err){setRunMsg('segment change failed: '+err.message);sel.value=cur;sel.disabled=false;return;}
+ load();
+}
async function reassignStatus(e){e.stopPropagation();const sel=e.target,id=sel.dataset.id,to=sel.value,cur=sel.dataset.cur;if(to===cur)return;
// 'done' bypasses the tk-CLI evidence guard + clears the blocker — confirm it like Stop Forever (reversible via Reopen).
if(to==='done'&&!confirm(`Mark ${id} DONE from the board? This skips the tk evidence guard. Reversible via Reopen.`)){sel.value=cur;return;}
diff --git a/lib.js b/lib.js
index 777a1611..06bf8c56 100644
--- a/lib.js
+++ b/lib.js
@@ -39,6 +39,7 @@ function append(ev) { ensure(); fs.appendFileSync(EVENTS, JSON.stringify(ev) + '
// just an appended status event, so `tk status <id> open` un-stops it. Added
// last so legacy 4-status consumers that index by position are unaffected.
const STATUSES = ['open', 'doing', 'blocked', 'done', 'stopped'];
+const SEGMENTS = ['dw', 'nondw', ''];
const KINDS = ['task', 'continuous_loop', 'scheduled_job'];
const BLOCKER_TYPES = ['steve_action', 'external_wait', 'technical_dependency', 'intentional_guardrail', 'resolved_candidate'];
function cleanBlocker(value) {
@@ -87,6 +88,8 @@ function tickets() {
}
else if (ev.type === 'assign') t.assignee = ev.agent || '';
else if (ev.type === 'blocker') t.blocker = cleanBlocker(ev.blocker);
+ // DW / Non-DW business segment, set from the board. '' = back to auto (project-name rule).
+ else if (ev.type === 'segment' && SEGMENTS.includes(ev.segment)) { if (ev.segment) t.segment = ev.segment; else delete t.segment; }
else if (ev.type === 'designation' && KINDS.includes(ev.kind)) {
t.kind = ev.kind;
t.schedule = cleanSchedule(ev.schedule);
@@ -418,7 +421,7 @@ function reapExpiredLeases(agent = 'reaper') {
});
}
-module.exports = { withLock, append, tickets, nextId, resolveId, idNum, slugify, STATUSES, KINDS, BLOCKER_TYPES, cleanBlocker, cleanSchedule, EVENTS,
+module.exports = { withLock, append, tickets, nextId, resolveId, idNum, slugify, STATUSES, SEGMENTS, KINDS, BLOCKER_TYPES, cleanBlocker, cleanSchedule, EVENTS,
IDRE, REFRE, resolveList,
nextMid, messages, inbox, thread, resolveMid, threadParticipants, parseMentions, isBroadcast, knownAgents,
correlationId, createTicket, ticketComment, ticketAction, ticketAssign, ticketStatus, ticketBlocker, ticketDesignate, sendDm, markMessageRead,
diff --git a/lib.test.js b/lib.test.js
index 15d41c37..cee6f025 100644
--- a/lib.test.js
+++ b/lib.test.js
@@ -417,3 +417,17 @@ test('CLI/process: concurrent claim() calls across real OS processes never doubl
assert.ok(finalLease);
assert.equal(finalLease.lease_id, winners[0].result.lease_id);
});
+
+test('segment event sets DW / Non-DW override; empty segment clears back to auto; junk ignored', () => {
+ const id = 'TK-90001';
+ append({ ts: iso(), type: 'create', id, title: 'seg test', project: 'misc' });
+ assert.equal(tickets().get(id).segment, undefined);
+ append({ ts: iso(1), type: 'segment', id, segment: 'dw' });
+ assert.equal(tickets().get(id).segment, 'dw');
+ append({ ts: iso(2), type: 'segment', id, segment: 'bogus' });
+ assert.equal(tickets().get(id).segment, 'dw', 'unknown segment value must not change state');
+ append({ ts: iso(3), type: 'segment', id, segment: 'nondw' });
+ assert.equal(tickets().get(id).segment, 'nondw');
+ append({ ts: iso(4), type: 'segment', id, segment: '' });
+ assert.equal(tickets().get(id).segment, undefined, "'' returns the ticket to auto");
+});
diff --git a/server.js b/server.js
index 8a36017d..68af53da 100644
--- a/server.js
+++ b/server.js
@@ -5,7 +5,7 @@ const path = require('path');
const os = require('os');
const { exec, execFile, execFileSync, spawn } = require('child_process');
const zlib = require('zlib');
-const { tickets, STATUSES, messages, resolveId, append, withLock, IDRE, REFRE, resolveList, EVENTS,
+const { tickets, STATUSES, SEGMENTS, messages, resolveId, append, withLock, IDRE, REFRE, resolveList, EVENTS,
claim: claimTicket, renew: renewLease, release: releaseLease } = require('./lib.js');
// ── ticket-run + DTD wiring (TK-10527) ──
@@ -600,6 +600,22 @@ http.createServer((req, res) => {
json(res, 200, { launched, skipped, profile });
});
}
+ // DW / Non-DW segment — per-ticket override of the board's project-name DW rule.
+ // segment: 'dw' | 'nondw' | '' (clear → auto). Append-only like every other board write.
+ if (req.method === 'POST' && req.url === '/api/segment') {
+ return readJson(req, body => {
+ if (!body) return json(res, 400, { error: 'bad json' });
+ const segment = String(body.segment ?? '').toLowerCase();
+ if (!SEGMENTS.includes(segment)) return json(res, 400, { error: 'invalid segment: ' + segment });
+ const map = cachedTickets(); const ids = resolveList(body.ids, map);
+ withLock(() => { for (const id of ids) {
+ append({ ts: new Date().toISOString(), type: 'segment', id, segment, agent: 'board' });
+ append({ ts: new Date().toISOString(), type: 'action', id, agent: 'board', text: `🏷 segment → ${segment === 'dw' ? 'DW' : segment === 'nondw' ? 'Non-DW' : 'auto'} from the board` });
+ } });
+ invalidateViews();
+ json(res, 200, { updated: ids, segment });
+ });
+ }
// Stop Forever — mark selected tickets TicketStopped (status 'stopped'); reversible via /api/reopen.
if (req.method === 'POST' && req.url === '/api/stop') {
return readJson(req, body => {
← b4c361a8 auto-data-snapshot: 2026-09-25T10:22:25 (2 data files) — dat
·
back to Ticket System
·
board: add DW section (mirror of Non-DW) so the dotbar's dw 2a97ecd7 →