From 844db1fd2b2c16eebcf839fd49c2a179ea69d0c0 Mon Sep 17 00:00:00 2001
From: chahinebrini
Date: Mon, 29 Jun 2026 05:05:02 +0200
Subject: [PATCH] =?UTF-8?q?feat(board):=20dashboard=20=E2=80=94=20KPI=20ca?=
=?UTF-8?q?rds=20+=20half-donut=20+=20activity=20+=20/decisions=20+=20/arc?=
=?UTF-8?q?hive=20(TSK-0032)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
codex built the dashboard redesign (TSK-0032): KPI metric cards, half-donut status
distribution, recent-activity feed, /decisions page, /archive page, task-detail
handoffs. Architect follow-up: the KPI cards were oversized (stretched to the side
panel height) — reworked to a compact full-width top row (auto-fit, fixed ~84px
height, label over value, small status-colored icon) per the user's reference.
Committed together to lock TSK-0032 in before TSK-0030 (realtime) touches the same files.
Co-Authored-By: Claude Opus 4.8
---
src/server/archive.ts | 91 ++++++++
src/server/board.ts | 455 ++++++++++++++++++++++++++++++++++++---
src/server/decisions.ts | 106 +++++++++
src/server/routes.ts | 23 +-
src/server/taskDetail.ts | 135 ++++++++++++
5 files changed, 784 insertions(+), 26 deletions(-)
create mode 100644 src/server/archive.ts
create mode 100644 src/server/decisions.ts
create mode 100644 src/server/taskDetail.ts
diff --git a/src/server/archive.ts b/src/server/archive.ts
new file mode 100644
index 0000000..ed90600
--- /dev/null
+++ b/src/server/archive.ts
@@ -0,0 +1,91 @@
+import { loadConfig } from '../core/config.js';
+import { listTasks } from '../core/services/taskService.js';
+import { agentAvatar, designTokensCss, escapeHtml } from './ui-shared.js';
+import type { IndexEntry } from '../core/index.js';
+
+function compactDuration(ms: number): string {
+ const s = Math.max(0, Math.floor(ms / 1000));
+ if (s < 60) return `${s}s`;
+ const m = Math.floor(s / 60);
+ if (m < 60) return `${m}m`;
+ const h = Math.floor(m / 60);
+ if (h < 48) return `${h}h`;
+ return `${Math.floor(h / 24)}d`;
+}
+
+function totalTime(t: IndexEntry): string {
+ const start = Date.parse(t.createdAt);
+ const end = Date.parse(t.updatedAt);
+ if (Number.isNaN(start) || Number.isNaN(end)) return '';
+ return compactDuration(end - start);
+}
+
+export function renderArchiveHtml(cwd: string): string {
+ const config = loadConfig(cwd);
+ const done = listTasks(cwd)
+ .filter((t) => t.status === 'done')
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
+
+ const rows = done.length
+ ? done
+ .map(
+ (t) => `
+ ${escapeHtml(t.id)}
+ ${escapeHtml(t.title)}
+ ${escapeHtml(totalTime(t))}
+ ${agentAvatar(t.assignedTo, { size: 24 })}
+`,
+ )
+ .join('')
+ : 'No done tasks yet.
';
+
+ return `
+
+
+
+
+
+ AgentHub Done Archive
+
+
+
+
+
+
+
Done Archive
+ ${done.length} done tasks
+
+ ${rows}
+
+
+`;
+}
diff --git a/src/server/board.ts b/src/server/board.ts
index bc00951..3d46be8 100644
--- a/src/server/board.ts
+++ b/src/server/board.ts
@@ -22,8 +22,11 @@ export const BOARD_COLUMNS: BoardColumn[] = [
{ key: 'cancelled', label: 'Cancelled' },
];
+const ACTIVE_COLUMNS = BOARD_COLUMNS.filter((c) => ['open', 'in_progress', 'review'].includes(c.key));
+const DONE_PREVIEW_LIMIT = 8;
+
function columnSkeleton(): string {
- return BOARD_COLUMNS.map(
+ return ACTIVE_COLUMNS.map(
(c) => `
${c.label}
@@ -68,6 +71,11 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
--done: #22C55E;
--cancelled: #6E7681;
--danger: #F85149;
+ --status-open: var(--open);
+ --status-in_progress: var(--in_progress);
+ --status-review: var(--review);
+ --status-done: var(--done);
+ --status-cancelled: var(--cancelled);
--mono: ui-monospace, "JetBrains Mono", SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--sans: system-ui, "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
@@ -181,11 +189,181 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
.sse-status.stale .conn-dot { background: var(--review); box-shadow: 0 0 0 3px rgba(210, 153, 34, .14); }
.sse-status.down .conn-dot { background: var(--danger); box-shadow: 0 0 0 3px rgba(248, 81, 73, .14); }
- .board {
+ .dashboard {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ margin-bottom: 14px;
+ }
+ .metrics {
display: grid;
- grid-template-columns: repeat(5, minmax(0, 1fr));
+ grid-template-columns: repeat(auto-fit, minmax(158px, 1fr));
+ gap: 12px;
+ min-width: 0;
+ }
+ .metric-card,
+ .donut-card,
+ .activity-card,
+ .done-panel {
+ min-width: 0;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ }
+ .metric-card {
+ position: relative;
+ padding: 14px 16px;
+ min-height: 84px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 6px;
+ }
+ .metric-label {
+ color: var(--muted);
+ font-size: 12.5px;
+ overflow-wrap: anywhere;
+ }
+ .metric-value {
+ font: 700 28px/1 var(--mono);
+ letter-spacing: 0;
+ }
+ .metric-icon {
+ position: absolute;
+ top: 50%;
+ right: 16px;
+ transform: translateY(-50%);
+ width: 34px;
+ height: 34px;
+ border-radius: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: color-mix(in srgb, currentColor 16%, transparent);
+ }
+ .metric-icon::after {
+ content: '';
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: currentColor;
+ }
+ .dashboard-side {
+ display: grid;
+ grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
+ gap: 12px;
+ min-width: 0;
+ }
+ .donut-card,
+ .activity-card,
+ .done-panel {
+ padding: 12px 14px;
+ }
+ .section-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ margin-bottom: 10px;
+ }
+ .section-title {
+ margin: 0;
+ font-size: 13px;
+ font-weight: 700;
+ }
+ .section-link {
+ color: var(--accent);
+ font: 12px/1.4 var(--mono);
+ white-space: nowrap;
+ }
+ .section-link:hover { text-decoration: underline; }
+ .donut-wrap {
+ display: grid;
+ grid-template-columns: 170px minmax(0, 1fr);
+ gap: 12px;
+ align-items: end;
+ }
+ .donut-svg {
+ width: 170px;
+ height: 100px;
+ overflow: visible;
+ }
+ .donut-total {
+ font: 700 24px/1 var(--mono);
+ fill: var(--text);
+ text-anchor: middle;
+ }
+ .donut-label {
+ font: 11px/1 var(--mono);
+ fill: var(--muted);
+ text-anchor: middle;
+ }
+ .legend {
+ display: grid;
+ gap: 6px;
+ min-width: 0;
+ }
+ .legend-row {
+ display: grid;
+ grid-template-columns: 10px minmax(0, 1fr) auto;
+ gap: 7px;
+ align-items: center;
+ color: var(--muted);
+ font: 12px/1.3 var(--mono);
+ }
+ .legend-swatch {
+ width: 10px;
+ height: 10px;
+ border-radius: 3px;
+ }
+ .activity-list,
+ .done-list {
+ display: grid;
+ gap: 7px;
+ min-width: 0;
+ }
+ .activity-row {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ gap: 8px;
+ align-items: start;
+ padding-top: 7px;
+ border-top: 1px solid rgba(48, 54, 61, .65);
+ }
+ .activity-row:first-child { border-top: 0; padding-top: 0; }
+ .activity-kind {
+ font: 10px/1.4 var(--mono);
+ border-radius: 999px;
+ padding: 1px 6px;
+ border: 1px solid var(--border);
+ color: var(--muted);
+ white-space: nowrap;
+ }
+ .activity-kind-message { color: var(--accent); border-color: rgba(88, 166, 255, .32); background: rgba(88, 166, 255, .08); }
+ .activity-kind-task { color: var(--review); border-color: rgba(210, 153, 34, .32); background: rgba(210, 153, 34, .08); }
+ .activity-main { min-width: 0; overflow-wrap: anywhere; font-size: 12px; }
+ .read-state { color: var(--muted); font: 11px/1.4 var(--mono); white-space: nowrap; }
+ .unread-dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--accent);
+ margin-right: 4px;
+ }
+ .work-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 300px;
gap: 12px;
align-items: start;
+ margin-top: 12px;
+ }
+ .board {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+ align-items: start;
+ min-width: 0;
}
.column {
min-width: 0;
@@ -222,6 +400,32 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
.column[data-column="review"] .col-label { color: var(--review); }
.column[data-column="done"] .col-label { color: var(--done); }
.column[data-column="cancelled"] .col-label { color: var(--cancelled); }
+ .done-summary {
+ color: var(--muted);
+ font: 12px/1.4 var(--mono);
+ white-space: nowrap;
+ }
+ .done-card {
+ display: grid;
+ gap: 4px;
+ padding: 8px 0;
+ border-top: 1px solid rgba(48, 54, 61, .65);
+ }
+ .done-card:first-child { border-top: 0; padding-top: 0; }
+ .done-title {
+ color: var(--text);
+ font-size: 12px;
+ font-weight: 650;
+ overflow-wrap: anywhere;
+ }
+ .done-meta {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+ color: var(--muted);
+ font: 11px/1.3 var(--mono);
+ }
.cards { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.card {
@@ -388,10 +592,14 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
.row .who { display: flex; align-items: center; gap: 4px; color: var(--accent); font-size: 11px; white-space: nowrap; }
.row .when { color: var(--muted); font: 11px/1.4 var(--mono); white-space: nowrap; }
- @media (max-width: 1180px) { .board { grid-template-columns: repeat(3, 1fr); } }
+ @media (max-width: 1180px) {
+ .dashboard { grid-template-columns: 1fr; }
+ .work-grid { grid-template-columns: 1fr; }
+ }
@media (max-width: 860px) {
body { padding-left: 14px; padding-right: 14px; }
.app-header { margin-left: -14px; margin-right: -14px; padding-left: 14px; padding-right: 14px; }
+ .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.board { grid-template-columns: repeat(2, 1fr); }
.panels { grid-template-columns: 1fr; }
.project-name { max-width: 50vw; }
@@ -402,6 +610,8 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
.brand { width: 100%; }
.brand-copy { flex-direction: column; gap: 0; }
.project-name { max-width: calc(100vw - 96px); }
+ .metrics { grid-template-columns: 1fr; }
+ .donut-wrap { grid-template-columns: 1fr; }
.board { grid-template-columns: 1fr; }
.nav { flex: 1; }
.nav-link { flex: 1; justify-content: center; }
@@ -435,6 +645,7 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
@@ -442,26 +653,65 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
-
-${columnSkeleton()}
-
+
+
+
+
+
+
+
Status Distribution
+ 0 total
+
+
+
+
+
+
Recent Activity
+ 0 events
+
+
+
+
+
-
-
- Handoffs
-
+
+
+${columnSkeleton()}
+
+
-
-
+
+
+
+ Decision Log
+ ${rows}
+
+
diff --git a/src/server/decisions.ts b/src/server/decisions.ts
new file mode 100644
index 0000000..47f6906
--- /dev/null
+++ b/src/server/decisions.ts
@@ -0,0 +1,106 @@
+import { loadConfig } from '../core/config.js';
+import { getDecision, listDecisions } from '../core/services/decisionService.js';
+import { designTokensCss, escapeHtml } from './ui-shared.js';
+import type { Decision } from '../core/schema.js';
+
+function ago(iso: string): string {
+ const t = Date.parse(iso);
+ if (Number.isNaN(t)) return '';
+ const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
+ if (s < 60) return `${s}s ago`;
+ const m = Math.floor(s / 60);
+ if (m < 60) return `${m}m ago`;
+ const h = Math.floor(m / 60);
+ if (h < 24) return `${h}h ago`;
+ return `${Math.floor(h / 24)}d ago`;
+}
+
+export function renderDecisionsHtml(cwd: string): string {
+ const config = loadConfig(cwd);
+ const decisions: Decision[] = listDecisions(cwd).map((entry) => {
+ try {
+ return getDecision(cwd, entry.id).decision;
+ } catch {
+ return {
+ id: entry.id,
+ title: entry.title,
+ status: 'accepted',
+ context: '',
+ decision: entry.content,
+ consequences: [],
+ alternatives: [],
+ relatedDecisions: [],
+ createdAt: entry.createdAt,
+ updatedAt: entry.updatedAt,
+ };
+ }
+ });
+ const rows = decisions.length
+ ? decisions
+ .map(
+ (d) => `
+
+ ${escapeHtml(d.id)}
+ ${escapeHtml(d.status)}
+ ${escapeHtml(ago(d.updatedAt || d.createdAt))}
+
+ ${escapeHtml(d.title)}
+ ${escapeHtml(d.decision)}
+ ${
+ d.consequences?.length
+ ? `Consequences${d.consequences.map((c) => `${escapeHtml(c)}`).join('')}
`
+ : ''
+ }
+`,
+ )
+ .join('')
+ : '
No decisions yet.