From eb62a1307b89f20c17327530692290cb6077a401 Mon Sep 17 00:00:00 2001 From: Helmi Date: Sun, 1 Mar 2026 22:45:50 +0100 Subject: [PATCH 1/4] feat(backend): priority-first sort and deferred filter for board view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change listIssues() ORDER BY to P0→P3 priority then updated_at DESC. Add hideDeferred option that filters defer_until > now. getBoard() now passes hideDeferred:true. Covers td-628268 backend half. 5 new tests: priority sort order, deferred hide/show, board variants. --- src/services/tdReader.test.ts | 95 +++++++++++++++++++++++++++++++++++ src/services/tdReader.ts | 9 +++- 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/src/services/tdReader.test.ts b/src/services/tdReader.test.ts index 12f5631..db9df0c 100644 --- a/src/services/tdReader.test.ts +++ b/src/services/tdReader.test.ts @@ -191,6 +191,59 @@ describe('TdReader', () => { expect(children).toHaveLength(2); }); + + it('should sort by priority first, then updated_at', () => { + const db = new Database(TEST_DB_PATH); + db.prepare( + `INSERT INTO issues (id, title, status, type, priority, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, + ).run('td-p0', 'Critical bug', 'open', 'task', 'P0', '2024-01-01'); + db.prepare( + `INSERT INTO issues (id, title, status, type, priority, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, + ).run('td-p3', 'Nice to have', 'open', 'task', 'P3', '2024-12-31'); + db.close(); + + const reader = new TdReader(TEST_DB_PATH); + const issues = reader.listIssues(); + reader.close(); + + const ids = issues.map(i => i.id); + // P0 should come first despite older updated_at + expect(ids.indexOf('td-p0')).toBeLessThan(ids.indexOf('td-p3')); + // P1 issues (td-001, td-002) should come before P2 (td-003) + expect(ids.indexOf('td-001')).toBeLessThan(ids.indexOf('td-003')); + }); + + it('should hide deferred issues when hideDeferred is true', () => { + const db = new Database(TEST_DB_PATH); + db.prepare( + `INSERT INTO issues (id, title, status, type, priority, defer_until) VALUES (?, ?, ?, ?, ?, ?)`, + ).run('td-deferred', 'Deferred task', 'open', 'task', 'P2', '2099-01-01'); + db.prepare( + `INSERT INTO issues (id, title, status, type, priority, defer_until) VALUES (?, ?, ?, ?, ?, ?)`, + ).run( + 'td-past-defer', + 'Past deferred', + 'open', + 'task', + 'P2', + '2020-01-01', + ); + db.close(); + + const reader = new TdReader(TEST_DB_PATH); + + // Without hideDeferred, all should appear + const all = reader.listIssues(); + expect(all.find(i => i.id === 'td-deferred')).toBeDefined(); + expect(all.find(i => i.id === 'td-past-defer')).toBeDefined(); + + // With hideDeferred, future-deferred should be hidden + const filtered = reader.listIssues({hideDeferred: true}); + expect(filtered.find(i => i.id === 'td-deferred')).toBeUndefined(); + expect(filtered.find(i => i.id === 'td-past-defer')).toBeDefined(); + + reader.close(); + }); }); describe('getIssue', () => { @@ -248,6 +301,48 @@ describe('TdReader', () => { expect(board['open']).toHaveLength(1); expect(board['done']).toHaveLength(1); }); + + it('should hide deferred issues', () => { + const db = new Database(TEST_DB_PATH); + db.prepare( + `INSERT INTO issues (id, title, status, type, priority, defer_until) VALUES (?, ?, ?, ?, ?, ?)`, + ).run( + 'td-deferred-board', + 'Deferred board task', + 'open', + 'task', + 'P2', + '2099-01-01', + ); + db.close(); + + const reader = new TdReader(TEST_DB_PATH); + const board = reader.getBoard(); + reader.close(); + + const openIds = (board['open'] || []).map(i => i.id); + expect(openIds).not.toContain('td-deferred-board'); + }); + + it('should sort issues by priority within each status column', () => { + const db = new Database(TEST_DB_PATH); + db.prepare( + `INSERT INTO issues (id, title, status, type, priority) VALUES (?, ?, ?, ?, ?)`, + ).run('td-open-p0', 'Urgent open', 'open', 'task', 'P0'); + db.prepare( + `INSERT INTO issues (id, title, status, type, priority) VALUES (?, ?, ?, ?, ?)`, + ).run('td-open-p3', 'Low open', 'open', 'task', 'P3'); + db.close(); + + const reader = new TdReader(TEST_DB_PATH); + const board = reader.getBoard(); + reader.close(); + + const openIds = (board['open'] || []).map(i => i.id); + expect(openIds.indexOf('td-open-p0')).toBeLessThan( + openIds.indexOf('td-open-p3'), + ); + }); }); describe('searchIssues', () => { diff --git a/src/services/tdReader.ts b/src/services/tdReader.ts index 05face0..2c70800 100644 --- a/src/services/tdReader.ts +++ b/src/services/tdReader.ts @@ -155,6 +155,7 @@ export class TdReader { status?: string; type?: string; parentId?: string; + hideDeferred?: boolean; }): TdIssue[] { try { const db = this.open(); @@ -182,8 +183,12 @@ export class TdReader { sql += ' AND parent_id = ?'; params.push(options.parentId); } + if (options?.hideDeferred) { + sql += " AND (defer_until IS NULL OR defer_until <= datetime('now'))"; + } - sql += ' ORDER BY updated_at DESC'; + sql += + " ORDER BY CASE priority WHEN 'P0' THEN 0 WHEN 'P1' THEN 1 WHEN 'P2' THEN 2 WHEN 'P3' THEN 3 ELSE 4 END ASC, updated_at DESC"; return db.prepare(sql).all(...params) as TdIssue[]; } catch (error) { @@ -242,7 +247,7 @@ export class TdReader { * Get issues by status for board view (grouped by status). */ getBoard(): Record { - const issues = this.listIssues(); + const issues = this.listIssues({hideDeferred: true}); const board: Record = {}; for (const issue of issues) { From b33898d480d44fac812bd7276f8b352339a6d4c8 Mon Sep 17 00:00:00 2001 From: Helmi Date: Sun, 1 Mar 2026 22:45:58 +0100 Subject: [PATCH 2/4] =?UTF-8?q?feat(ui):=20board=20UX=20overhaul=20?= =?UTF-8?q?=E2=80=94=20flat=20sort,=20epic=20badges,=20card=20redesign,=20?= =?UTF-8?q?column=20collapse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit td-628268: StatusColumn renders flat (no groupIssuesWithChildren), issues arrive priority-sorted from backend. td-b21b0f: Epic cards show open child count badge (global count from tdIssues). No inline child expansion; clicking epic opens detail modal. td-bbc52a: IssueCard redesigned — full-width title, colored priority pill bottom-left (P0=red, P1=orange, P2=muted, P3=dim), muted ID bottom-right. Left border and tags removed. groupIssuesWithChildren kept for list view. td-ff29f1: Empty columns collapse to clickable vertical strip. Closed column shows only last-3-days by default with 'Show N older' reveal button. Min/max-width removed; flex distributes naturally. --- client/src/components/TaskBoard.tsx | 182 +++++++++++++++------------- 1 file changed, 101 insertions(+), 81 deletions(-) diff --git a/client/src/components/TaskBoard.tsx b/client/src/components/TaskBoard.tsx index 897b36f..72b2178 100644 --- a/client/src/components/TaskBoard.tsx +++ b/client/src/components/TaskBoard.tsx @@ -18,12 +18,10 @@ import { PauseCircle, AlertCircle, Layers, - Tag, FolderGit2, - ChevronDown, - ChevronRight, RefreshCw, XCircle, + ChevronRight, } from 'lucide-react' // Status column configuration @@ -35,13 +33,6 @@ const STATUS_COLUMNS = [ { key: 'closed', label: 'Closed', icon: CheckCircle2, color: 'text-green-500', bg: 'bg-green-500/10' }, ] -const priorityColors: Record = { - P0: 'border-l-red-500', - P1: 'border-l-orange-500', - P2: 'border-l-border', - P3: 'border-l-border/50', -} - type ViewMode = 'board' | 'list' function normalizeBranchName(branch?: string): string { @@ -49,46 +40,51 @@ function normalizeBranchName(branch?: string): string { return branch.replace(/^refs\/heads\//, '').trim() } -function IssueCard({ issue, compact, indent, onSelect }: { issue: TdIssue; compact?: boolean; indent?: boolean; onSelect: (id: string) => void }) { - const labels = issue.labels ? issue.labels.split(',').filter(Boolean) : [] +function priorityBadgeClass(priority: string): string { + if (priority === 'P0') return 'bg-red-500/15 text-red-400' + if (priority === 'P1') return 'bg-orange-500/15 text-orange-400' + if (priority === 'P2') return 'bg-muted text-muted-foreground' + return 'bg-muted/50 text-muted-foreground/50' +} +function IssueCard({ issue, compact, indent, onSelect, childCount }: { issue: TdIssue; compact?: boolean; indent?: boolean; onSelect: (id: string) => void; childCount?: number }) { return ( ) @@ -126,70 +122,82 @@ function groupIssuesWithChildren(issues: TdIssue[]): Array<{ issue: TdIssue; chi return result } -function StatusColumn({ status, issues, onSelect }: { +function StatusColumn({ status, issues, onSelect, childCountByEpicId }: { status: typeof STATUS_COLUMNS[0] issues: TdIssue[] onSelect: (id: string) => void + childCountByEpicId: Map }) { const StatusIcon = status.icon - const grouped = useMemo(() => groupIssuesWithChildren(issues), [issues]) - const [collapsedEpics, setCollapsedEpics] = useState>(new Set()) - - const toggleEpic = (epicId: string) => { - setCollapsedEpics(prev => { - const next = new Set(prev) - if (next.has(epicId)) next.delete(epicId) - else next.add(epicId) - return next - }) + const [isCollapsed, setIsCollapsed] = useState(issues.length === 0) + const [showAllClosed, setShowAllClosed] = useState(false) + + useEffect(() => { + if (issues.length === 0) setIsCollapsed(true) + }, [issues.length]) + + const threeDaysAgo = new Date(Date.now() - 3 * 86_400_000).toISOString() + const visibleIssues = (status.key === 'closed' && !showAllClosed) + ? issues.filter(i => (i.closed_at ?? '') >= threeDaysAgo) + : issues + + if (isCollapsed) { + return ( +
setIsCollapsed(false)} + title={`${status.label} (${issues.length})`} + > + + + {status.label} + + {issues.length} +
+ ) } return ( -
+
{status.label} {issues.length} +
- {issues.length === 0 ? ( + {visibleIssues.length === 0 ? (

No issues

) : ( - grouped.map(({ issue, children }) => ( -
- {issue.type === 'epic' && children.length > 0 ? ( -
- - - {!collapsedEpics.has(issue.id) && children.map(child => ( -
- -
- ))} -
- ) : ( - - )} -
+ visibleIssues.map(issue => ( + )) )} + {status.key === 'closed' && !showAllClosed && issues.length > visibleIssues.length && ( + + )}
@@ -212,6 +220,16 @@ export function TaskBoard() { const [searchQuery, setSearchQuery] = useState('') const [selectedIssueId, setSelectedIssueId] = useState(null) + const childCountByEpicId = useMemo(() => { + const counts = new Map() + tdIssues.forEach(issue => { + if (issue.parent_id && issue.status !== 'closed') { + counts.set(issue.parent_id, (counts.get(issue.parent_id) ?? 0) + 1) + } + }) + return counts + }, [tdIssues]) + const projectWorktrees = useMemo(() => { if (!currentProject) return [] const projectName = currentProject.path.split('/').pop() || '' @@ -374,6 +392,7 @@ export function TaskBoard() { status={status} issues={(filteredBoard[status.key] || []).filter(i => !i.deleted_at)} onSelect={setSelectedIssueId} + childCountByEpicId={childCountByEpicId} /> ))}
@@ -393,6 +412,7 @@ export function TaskBoard() { issue={issue} compact onSelect={setSelectedIssueId} + childCount={issue.type === 'epic' ? childCountByEpicId.get(issue.id) : undefined} /> {children.map(child => (
From b91667c07cc96fc71ffbea05fcd2d2435a9ea74b Mon Sep 17 00:00:00 2001 From: Helmi Date: Sun, 1 Mar 2026 22:46:02 +0100 Subject: [PATCH 3/4] feat(ui): add task board button to session title bar (td-c6ca47) LayoutGrid icon button in TerminalSession title bar, before the Info button. Visible when td is enabled for the project. One-click board access from any session view without hunting through the sidebar. --- client/src/components/TerminalSession.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/client/src/components/TerminalSession.tsx b/client/src/components/TerminalSession.tsx index d272ee6..e8952d5 100644 --- a/client/src/components/TerminalSession.tsx +++ b/client/src/components/TerminalSession.tsx @@ -25,6 +25,7 @@ import { ArrowDown, Pencil, RotateCcw, + LayoutGrid, } from 'lucide-react'; import {cn} from '@/lib/utils'; import type {Session} from '@/lib/types'; @@ -130,6 +131,8 @@ export const TerminalSession = memo(function TerminalSession({ selectedSessions, worktrees, agents, + openTaskBoard, + tdStatus, } = useAppStore(); const isMobile = useIsMobile(); const hasMultipleSessions = selectedSessions.length > 1; @@ -730,6 +733,19 @@ export const TerminalSession = memo(function TerminalSession({
+ {/* Task board button */} + {tdStatus?.projectState?.enabled && ( + + )} + {/* Info button */} )}
diff --git a/client/src/components/TerminalSession.tsx b/client/src/components/TerminalSession.tsx index e8952d5..daebece 100644 --- a/client/src/components/TerminalSession.tsx +++ b/client/src/components/TerminalSession.tsx @@ -132,7 +132,6 @@ export const TerminalSession = memo(function TerminalSession({ worktrees, agents, openTaskBoard, - tdStatus, } = useAppStore(); const isMobile = useIsMobile(); const hasMultipleSessions = selectedSessions.length > 1; @@ -734,17 +733,15 @@ export const TerminalSession = memo(function TerminalSession({
{/* Task board button */} - {tdStatus?.projectState?.enabled && ( - - )} + {/* Info button */}