@matt82198/aesop 0.1.0-beta.3 → 0.1.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -95
- package/CLAUDE-TEMPLATE.md +115 -0
- package/README.md +126 -245
- package/aesop.config.example.json +34 -26
- package/bin/CLAUDE.md +67 -0
- package/bin/cli.js +593 -36
- package/daemons/CLAUDE.md +36 -0
- package/daemons/backup-fleet.sh +92 -21
- package/daemons/run-watchdog.sh +95 -12
- package/dash/CLAUDE.md +32 -0
- package/dash/dash-extra.mjs +194 -21
- package/dash/watchdog-gui.sh +23 -6
- package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
- package/docs/CHECKPOINTING.md +100 -0
- package/docs/DISPATCH-MODEL.md +11 -7
- package/docs/FORENSICS.md +204 -0
- package/docs/GOVERNANCE.md +4 -2
- package/docs/HOOK-INSTALL.md +307 -0
- package/docs/HOW-THE-LOOP-WORKS.md +175 -0
- package/docs/MEMORY-TEMPLATE.md +61 -0
- package/docs/README.md +44 -0
- package/docs/RELEASING.md +159 -0
- package/docs/RELIABILITY.md +61 -0
- package/docs/RESTORE.md +397 -0
- package/docs/SCRIPTS-POLICY.md +97 -0
- package/docs/archive/README.md +3 -0
- package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +125 -0
- package/docs/archive/spikes/tiered-cognition/DESIGN.md +287 -0
- package/docs/archive/spikes/tiered-cognition/FINDINGS.md +113 -0
- package/docs/archive/spikes/tiered-cognition/README.md +27 -0
- package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
- package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
- package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
- package/docs/case-study-portfolio.md +61 -0
- package/docs/self-stats-data.json +11 -0
- package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
- package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
- package/docs/templates/PROPOSALS-LOG.example.md +64 -0
- package/hooks/CLAUDE.md +61 -0
- package/hooks/claude/force-model-policy.mjs +159 -0
- package/hooks/pre-push-policy.sh +753 -0
- package/mcp/CLAUDE.md +213 -0
- package/mcp/package.json +26 -0
- package/mcp/server.mjs +543 -0
- package/monitor/.signal-state.json +3 -0
- package/monitor/ACTIONS.log +1 -0
- package/monitor/BRIEF.md +24 -0
- package/monitor/CHARTER.md +75 -88
- package/monitor/CLAUDE.md +40 -0
- package/monitor/SIGNALS.json +54 -0
- package/monitor/collect-signals.mjs +613 -61
- package/package.json +15 -3
- package/scan/fleet-scan.example.mjs +292 -0
- package/skills/CLAUDE.md +3 -0
- package/skills/healthcheck/SKILL.md +44 -0
- package/skills/power/SKILL.md +161 -0
- package/state_store/CLAUDE.md +39 -0
- package/state_store/__init__.py +24 -0
- package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
- package/state_store/__pycache__/api.cpython-314.pyc +0 -0
- package/state_store/__pycache__/export.cpython-314.pyc +0 -0
- package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
- package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
- package/state_store/__pycache__/store.cpython-314.pyc +0 -0
- package/state_store/api.py +35 -0
- package/state_store/export.py +19 -0
- package/state_store/ingest.py +24 -0
- package/state_store/projections.py +52 -0
- package/state_store/store.py +102 -0
- package/tools/CLAUDE.md +72 -0
- package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
- package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
- package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
- package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
- package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
- package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
- package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
- package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
- package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
- package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
- package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
- package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
- package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
- package/tools/agent-forensics.sh +186 -0
- package/tools/alert_bridge.py +449 -0
- package/tools/buildlog.py +182 -0
- package/tools/ci_merge_wait.py +317 -0
- package/tools/ensure_state.py +94 -0
- package/tools/eod_sweep.py +239 -0
- package/tools/fleet_ledger.py +272 -0
- package/tools/fleet_prompt_extractor.py +134 -0
- package/tools/healthcheck.py +296 -0
- package/tools/heartbeat.py +131 -0
- package/tools/inbox_drain.py +249 -0
- package/tools/launch_tui.py +51 -3
- package/tools/lock.mjs +211 -0
- package/tools/metrics_gate.py +205 -0
- package/tools/orchestrator_status.py +99 -0
- package/tools/power_selftest.py +386 -0
- package/tools/prepublish_scan.py +84 -0
- package/tools/proposals.mjs +248 -0
- package/tools/reconstitute.sh +467 -0
- package/tools/rotate_logs.py +228 -0
- package/tools/scanner_selftest.py +303 -0
- package/tools/secret_scan.py +138 -31
- package/tools/self_stats.py +509 -0
- package/tools/session_usage_summary.py +198 -0
- package/tools/stall_check.py +172 -0
- package/tools/svg_to_png.mjs +50 -0
- package/tools/transcript_replay.py +236 -0
- package/tools/transcript_timeline.py +184 -0
- package/tools/verify_dash.py +513 -0
- package/tools/verify_submit_encoding.py +202 -0
- package/ui/CLAUDE.md +125 -0
- package/ui/__pycache__/agents.cpython-314.pyc +0 -0
- package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
- package/ui/__pycache__/config.cpython-314.pyc +0 -0
- package/ui/__pycache__/cost.cpython-314.pyc +0 -0
- package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
- package/ui/__pycache__/handler.cpython-314.pyc +0 -0
- package/ui/__pycache__/render.cpython-314.pyc +0 -0
- package/ui/__pycache__/serve.cpython-314.pyc +0 -0
- package/ui/__pycache__/sse.cpython-314.pyc +0 -0
- package/ui/agents.py +183 -0
- package/ui/api/__init__.py +84 -0
- package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
- package/ui/api/submit.py +97 -0
- package/ui/api/tracker.py +116 -0
- package/ui/collectors.py +735 -0
- package/ui/config.py +123 -0
- package/ui/cost.py +260 -0
- package/ui/csrf.py +144 -0
- package/ui/handler.py +676 -0
- package/ui/render.py +49 -0
- package/ui/serve.py +68 -796
- package/ui/sse.py +183 -0
- package/ui/web/.gitattributes +13 -0
- package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
- package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
- package/ui/web/dist/index.html +14 -0
- package/ui/web/index.html +13 -0
- package/ui/web/package-lock.json +2225 -0
- package/ui/web/package.json +26 -0
- package/ui/web/src/App.test.tsx +74 -0
- package/ui/web/src/App.tsx +142 -0
- package/ui/web/src/CONTRIBUTING-UI.md +49 -0
- package/ui/web/src/components/AgentRow.css +187 -0
- package/ui/web/src/components/AgentRow.test.tsx +209 -0
- package/ui/web/src/components/AgentRow.tsx +207 -0
- package/ui/web/src/components/AgentsPanel.css +108 -0
- package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
- package/ui/web/src/components/AgentsPanel.tsx +58 -0
- package/ui/web/src/components/AlertsPanel.css +88 -0
- package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
- package/ui/web/src/components/AlertsPanel.tsx +67 -0
- package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
- package/ui/web/src/components/BacklogPanel.tsx +122 -0
- package/ui/web/src/components/CostChart.css +110 -0
- package/ui/web/src/components/CostChart.test.tsx +144 -0
- package/ui/web/src/components/CostChart.tsx +152 -0
- package/ui/web/src/components/CostTable.css +93 -0
- package/ui/web/src/components/CostTable.test.tsx +165 -0
- package/ui/web/src/components/CostTable.tsx +94 -0
- package/ui/web/src/components/EventsFeed.css +68 -0
- package/ui/web/src/components/EventsFeed.test.tsx +36 -0
- package/ui/web/src/components/EventsFeed.tsx +31 -0
- package/ui/web/src/components/HealthHeader.css +137 -0
- package/ui/web/src/components/HealthHeader.test.tsx +278 -0
- package/ui/web/src/components/HealthHeader.tsx +281 -0
- package/ui/web/src/components/InboxForm.css +135 -0
- package/ui/web/src/components/InboxForm.test.tsx +208 -0
- package/ui/web/src/components/InboxForm.tsx +116 -0
- package/ui/web/src/components/MessagesTail.module.css +144 -0
- package/ui/web/src/components/MessagesTail.test.tsx +176 -0
- package/ui/web/src/components/MessagesTail.tsx +94 -0
- package/ui/web/src/components/ReposPanel.css +90 -0
- package/ui/web/src/components/ReposPanel.test.tsx +45 -0
- package/ui/web/src/components/ReposPanel.tsx +67 -0
- package/ui/web/src/components/Scorecard.css +106 -0
- package/ui/web/src/components/Scorecard.test.tsx +117 -0
- package/ui/web/src/components/Scorecard.tsx +85 -0
- package/ui/web/src/components/Timeline.module.css +151 -0
- package/ui/web/src/components/Timeline.test.tsx +215 -0
- package/ui/web/src/components/Timeline.tsx +99 -0
- package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
- package/ui/web/src/components/TrackerBoard.tsx +107 -0
- package/ui/web/src/components/TrackerCard.test.tsx +180 -0
- package/ui/web/src/components/TrackerCard.tsx +160 -0
- package/ui/web/src/components/TrackerForm.test.tsx +189 -0
- package/ui/web/src/components/TrackerForm.tsx +144 -0
- package/ui/web/src/lib/api.ts +218 -0
- package/ui/web/src/lib/format.test.ts +89 -0
- package/ui/web/src/lib/format.ts +103 -0
- package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
- package/ui/web/src/lib/sanitizeUrl.ts +38 -0
- package/ui/web/src/lib/types.ts +230 -0
- package/ui/web/src/lib/useHashRoute.test.ts +60 -0
- package/ui/web/src/lib/useHashRoute.ts +23 -0
- package/ui/web/src/lib/useSSE.ts +175 -0
- package/ui/web/src/main.tsx +10 -0
- package/ui/web/src/styles/global.css +179 -0
- package/ui/web/src/styles/theme.css +184 -0
- package/ui/web/src/styles/work.css +572 -0
- package/ui/web/src/test/fixtures.ts +385 -0
- package/ui/web/src/test/setup.ts +49 -0
- package/ui/web/src/views/Activity.module.css +43 -0
- package/ui/web/src/views/Activity.test.tsx +89 -0
- package/ui/web/src/views/Activity.tsx +31 -0
- package/ui/web/src/views/Cost.css +87 -0
- package/ui/web/src/views/Cost.test.tsx +142 -0
- package/ui/web/src/views/Cost.tsx +54 -0
- package/ui/web/src/views/Overview.css +51 -0
- package/ui/web/src/views/Overview.test.tsx +76 -0
- package/ui/web/src/views/Overview.tsx +46 -0
- package/ui/web/src/views/Work.test.tsx +82 -0
- package/ui/web/src/views/Work.tsx +79 -0
- package/ui/web/src/vite-env.d.ts +10 -0
- package/ui/web/tsconfig.json +22 -0
- package/ui/web/vite.config.ts +25 -0
- package/ui/web/vitest.config.ts +12 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeline styling — horizontal status-colored bars per agent.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
.container {
|
|
6
|
+
border: 1px solid var(--color-border);
|
|
7
|
+
border-radius: var(--radius-md);
|
|
8
|
+
background: var(--color-bg-subtle);
|
|
9
|
+
padding: var(--space-4);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.title {
|
|
13
|
+
margin: 0 0 var(--space-4) 0;
|
|
14
|
+
font-size: var(--font-size-md);
|
|
15
|
+
font-weight: var(--font-weight-medium);
|
|
16
|
+
color: var(--color-text);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.timelineContent {
|
|
20
|
+
display: flex;
|
|
21
|
+
flex-direction: column;
|
|
22
|
+
gap: var(--space-4);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.row {
|
|
26
|
+
display: grid;
|
|
27
|
+
grid-template-columns: 160px 1fr 80px;
|
|
28
|
+
gap: var(--space-3);
|
|
29
|
+
align-items: center;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.rowLabel {
|
|
33
|
+
display: flex;
|
|
34
|
+
flex-direction: column;
|
|
35
|
+
gap: var(--space-1);
|
|
36
|
+
font-size: var(--font-size-sm);
|
|
37
|
+
overflow: hidden;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.agentId {
|
|
41
|
+
font-family: var(--font-mono);
|
|
42
|
+
font-weight: var(--font-weight-bold);
|
|
43
|
+
color: var(--color-text);
|
|
44
|
+
word-break: break-all;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.agentStatus {
|
|
48
|
+
font-size: var(--font-size-xs);
|
|
49
|
+
color: var(--color-text-muted);
|
|
50
|
+
text-transform: uppercase;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.barContainer {
|
|
54
|
+
height: 24px;
|
|
55
|
+
background: var(--color-bg);
|
|
56
|
+
border: 1px solid var(--color-border-subtle);
|
|
57
|
+
border-radius: var(--radius-sm);
|
|
58
|
+
overflow: hidden;
|
|
59
|
+
position: relative;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.bar {
|
|
63
|
+
height: 100%;
|
|
64
|
+
border-radius: var(--radius-sm);
|
|
65
|
+
transition: width var(--transition-normal);
|
|
66
|
+
min-width: 5%;
|
|
67
|
+
max-width: 100%;
|
|
68
|
+
display: flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
justify-content: flex-end;
|
|
71
|
+
padding-right: var(--space-2);
|
|
72
|
+
font-size: var(--font-size-xs);
|
|
73
|
+
font-weight: var(--font-weight-medium);
|
|
74
|
+
color: var(--color-text-inverse);
|
|
75
|
+
white-space: nowrap;
|
|
76
|
+
overflow: hidden;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* Status color classes */
|
|
80
|
+
.status-ok {
|
|
81
|
+
background: var(--color-status-ok);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.status-info {
|
|
85
|
+
background: var(--color-status-info);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.status-warn {
|
|
89
|
+
background: var(--color-status-warn);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.status-error {
|
|
93
|
+
background: var(--color-status-error);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.status-neutral {
|
|
97
|
+
background: var(--color-status-neutral);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.rowDuration {
|
|
101
|
+
text-align: right;
|
|
102
|
+
font-size: var(--font-size-sm);
|
|
103
|
+
font-family: var(--font-mono);
|
|
104
|
+
color: var(--color-text-muted);
|
|
105
|
+
min-width: 60px;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.footer {
|
|
109
|
+
margin-top: var(--space-4);
|
|
110
|
+
padding-top: var(--space-4);
|
|
111
|
+
border-top: 1px solid var(--color-border-subtle);
|
|
112
|
+
font-size: var(--font-size-xs);
|
|
113
|
+
color: var(--color-text-muted);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.legend {
|
|
117
|
+
display: inline-block;
|
|
118
|
+
line-height: var(--line-height-normal);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* Responsive: stack on small screens */
|
|
122
|
+
@media (max-width: 768px) {
|
|
123
|
+
.row {
|
|
124
|
+
grid-template-columns: 1fr;
|
|
125
|
+
gap: var(--space-2);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.rowLabel {
|
|
129
|
+
display: flex;
|
|
130
|
+
flex-direction: row;
|
|
131
|
+
gap: var(--space-2);
|
|
132
|
+
align-items: baseline;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.barContainer {
|
|
136
|
+
order: 3;
|
|
137
|
+
grid-column: 1;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.rowDuration {
|
|
141
|
+
order: 2;
|
|
142
|
+
text-align: left;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* Respect reduced motion */
|
|
147
|
+
@media (prefers-reduced-motion: reduce) {
|
|
148
|
+
.bar {
|
|
149
|
+
transition: none;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeline — Horizontal per-agent bars from startedAt/lastActivity/runtimeSeconds.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { render, screen } from '@testing-library/react';
|
|
7
|
+
import Timeline from './Timeline';
|
|
8
|
+
import { fixtureAgents } from '../test/fixtures';
|
|
9
|
+
import { TESTIDS } from '../test/fixtures';
|
|
10
|
+
import type { Agent } from '../lib/types';
|
|
11
|
+
|
|
12
|
+
describe('Timeline', () => {
|
|
13
|
+
it('renders timeline container with testid', () => {
|
|
14
|
+
render(<Timeline agents={fixtureAgents} />);
|
|
15
|
+
expect(screen.getByTestId(TESTIDS.timeline)).toBeInTheDocument();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('renders one bar per agent', () => {
|
|
19
|
+
render(<Timeline agents={fixtureAgents} />);
|
|
20
|
+
const bars = screen.getAllByTestId(TESTIDS.timelineBar);
|
|
21
|
+
expect(bars.length).toBe(fixtureAgents.length);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('shows agent id in bar label', () => {
|
|
25
|
+
render(<Timeline agents={fixtureAgents} />);
|
|
26
|
+
fixtureAgents.forEach((agent) => {
|
|
27
|
+
expect(screen.getByText(agent.id)).toBeInTheDocument();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('shows agent status in bar label', () => {
|
|
32
|
+
render(<Timeline agents={fixtureAgents} />);
|
|
33
|
+
const statusElements = screen.getAllByText(/running/i);
|
|
34
|
+
expect(statusElements.length).toBeGreaterThan(0); // At least one "running"
|
|
35
|
+
// Check for idle and SUSPICIOUS in aria-labels instead
|
|
36
|
+
fixtureAgents.forEach((agent) => {
|
|
37
|
+
const bars = screen.getAllByTestId(TESTIDS.timelineBar);
|
|
38
|
+
const agentBar = bars.find((bar) => bar.getAttribute('aria-label')?.includes(agent.id));
|
|
39
|
+
expect(agentBar?.getAttribute('aria-label')).toContain(agent.status);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('shows runtime duration in bar label', () => {
|
|
44
|
+
render(<Timeline agents={fixtureAgents} />);
|
|
45
|
+
// Should format the runtimeSeconds as a human-readable duration
|
|
46
|
+
// First agent has runtimeSeconds: 1776, which is 29m 36s
|
|
47
|
+
const container = screen.getByTestId(TESTIDS.timeline);
|
|
48
|
+
const text = container.textContent || '';
|
|
49
|
+
expect(text).toContain('29m'); // Should contain formatted duration
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('applies status-based color to bars via theme tokens', () => {
|
|
53
|
+
render(<Timeline agents={fixtureAgents} />);
|
|
54
|
+
const bars = screen.getAllByTestId(TESTIDS.timelineBar);
|
|
55
|
+
expect(bars.length).toBeGreaterThan(0);
|
|
56
|
+
|
|
57
|
+
// Check that bars have role-based styling (via CSS class or style)
|
|
58
|
+
bars.forEach((bar) => {
|
|
59
|
+
const style = window.getComputedStyle(bar);
|
|
60
|
+
// Color should be set via theme tokens (will be computed CSS variable)
|
|
61
|
+
expect(style.backgroundColor || style.color).toBeDefined();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('handles missing startedAt gracefully (null)', () => {
|
|
66
|
+
const agents: Agent[] = [
|
|
67
|
+
{
|
|
68
|
+
...fixtureAgents[0],
|
|
69
|
+
startedAt: null,
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
render(<Timeline agents={agents} />);
|
|
73
|
+
const bars = screen.getAllByTestId(TESTIDS.timelineBar);
|
|
74
|
+
expect(bars.length).toBe(1);
|
|
75
|
+
expect(bars[0]).toBeInTheDocument();
|
|
76
|
+
// Should still show agent id and status
|
|
77
|
+
expect(screen.getByText(agents[0].id)).toBeInTheDocument();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('handles missing lastActivity gracefully (null)', () => {
|
|
81
|
+
const agents: Agent[] = [
|
|
82
|
+
{
|
|
83
|
+
...fixtureAgents[0],
|
|
84
|
+
lastActivity: null,
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
render(<Timeline agents={agents} />);
|
|
88
|
+
const bars = screen.getAllByTestId(TESTIDS.timelineBar);
|
|
89
|
+
expect(bars.length).toBe(1);
|
|
90
|
+
expect(bars[0]).toBeInTheDocument();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('handles garbage/invalid ISO timestamps (falls back to sensible default)', () => {
|
|
94
|
+
const agents: Agent[] = [
|
|
95
|
+
{
|
|
96
|
+
...fixtureAgents[0],
|
|
97
|
+
startedAt: 'not-a-date',
|
|
98
|
+
lastActivity: 'garbage',
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
render(<Timeline agents={agents} />);
|
|
102
|
+
const bars = screen.getAllByTestId(TESTIDS.timelineBar);
|
|
103
|
+
expect(bars.length).toBe(1);
|
|
104
|
+
expect(bars[0]).toBeInTheDocument();
|
|
105
|
+
// Should not throw, should render with fallback values
|
|
106
|
+
expect(screen.getByText(agents[0].id)).toBeInTheDocument();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('clamps timeline when runtimeSeconds is 0 or negative', () => {
|
|
110
|
+
const agents: Agent[] = [
|
|
111
|
+
{
|
|
112
|
+
...fixtureAgents[0],
|
|
113
|
+
runtimeSeconds: -5,
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
render(<Timeline agents={agents} />);
|
|
117
|
+
const bars = screen.getAllByTestId(TESTIDS.timelineBar);
|
|
118
|
+
expect(bars.length).toBe(1);
|
|
119
|
+
const bar = bars[0];
|
|
120
|
+
expect(bar).toBeInTheDocument();
|
|
121
|
+
// Should render without NaN or invalid dimensions
|
|
122
|
+
const style = window.getComputedStyle(bar);
|
|
123
|
+
expect(style.width).not.toContain('NaN');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('clamps timeline when runtimeSeconds is extremely large', () => {
|
|
127
|
+
const agents: Agent[] = [
|
|
128
|
+
{
|
|
129
|
+
...fixtureAgents[0],
|
|
130
|
+
runtimeSeconds: 999999999,
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
render(<Timeline agents={agents} />);
|
|
134
|
+
const bars = screen.getAllByTestId(TESTIDS.timelineBar);
|
|
135
|
+
expect(bars.length).toBe(1);
|
|
136
|
+
const bar = bars[0];
|
|
137
|
+
expect(bar).toBeInTheDocument();
|
|
138
|
+
// Should not overflow, should be clamped
|
|
139
|
+
const style = window.getComputedStyle(bar);
|
|
140
|
+
expect(style.width).not.toContain('NaN');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('computes bar width proportionally from startedAt to lastActivity', () => {
|
|
144
|
+
const now = new Date().toISOString();
|
|
145
|
+
const oneHourAgo = new Date(Date.now() - 3600000).toISOString();
|
|
146
|
+
const agents: Agent[] = [
|
|
147
|
+
{
|
|
148
|
+
...fixtureAgents[0],
|
|
149
|
+
startedAt: oneHourAgo,
|
|
150
|
+
lastActivity: now,
|
|
151
|
+
},
|
|
152
|
+
];
|
|
153
|
+
render(<Timeline agents={agents} />);
|
|
154
|
+
const bars = screen.getAllByTestId(TESTIDS.timelineBar);
|
|
155
|
+
expect(bars.length).toBe(1);
|
|
156
|
+
const bar = bars[0];
|
|
157
|
+
expect(bar).toBeInTheDocument();
|
|
158
|
+
// Bar should have a width reflecting the time span
|
|
159
|
+
const style = window.getComputedStyle(bar);
|
|
160
|
+
expect(style.width).toBeDefined();
|
|
161
|
+
expect(style.width).not.toBe('0px');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('shows empty state when no agents', () => {
|
|
165
|
+
render(<Timeline agents={[]} />);
|
|
166
|
+
const container = screen.getByTestId(TESTIDS.timeline);
|
|
167
|
+
expect(container.textContent).toContain('no agents');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('each bar is a labeled element for accessibility', () => {
|
|
171
|
+
render(<Timeline agents={fixtureAgents} />);
|
|
172
|
+
const bars = screen.getAllByTestId(TESTIDS.timelineBar);
|
|
173
|
+
bars.forEach((bar, i) => {
|
|
174
|
+
const agent = fixtureAgents[i];
|
|
175
|
+
// Should have aria-label or be a semantic element with accessible name
|
|
176
|
+
const label = bar.getAttribute('aria-label') || bar.textContent || '';
|
|
177
|
+
expect(label).toContain(agent.id);
|
|
178
|
+
expect(label).toContain(agent.status);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('handles all status colors: running, idle, SUSPICIOUS, HIGH, DRIFT, MED', () => {
|
|
183
|
+
const agentsWithStatuses: Agent[] = [
|
|
184
|
+
{ ...fixtureAgents[0], status: 'running' },
|
|
185
|
+
{ ...fixtureAgents[0], id: 'idle-agent', status: 'idle' },
|
|
186
|
+
{ ...fixtureAgents[0], id: 'sus-agent', status: 'SUSPICIOUS' },
|
|
187
|
+
{ ...fixtureAgents[0], id: 'high-agent', status: 'HIGH' },
|
|
188
|
+
{ ...fixtureAgents[0], id: 'drift-agent', status: 'DRIFT' },
|
|
189
|
+
{ ...fixtureAgents[0], id: 'med-agent', status: 'MED' },
|
|
190
|
+
];
|
|
191
|
+
render(<Timeline agents={agentsWithStatuses} />);
|
|
192
|
+
const bars = screen.getAllByTestId(TESTIDS.timelineBar);
|
|
193
|
+
expect(bars.length).toBe(6);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('respects prefers-reduced-motion for animations', () => {
|
|
197
|
+
// Mock matchMedia to return reduced motion preference
|
|
198
|
+
const mockMatchMedia = (query: string) => ({
|
|
199
|
+
matches: query === '(prefers-reduced-motion: reduce)',
|
|
200
|
+
media: query,
|
|
201
|
+
onchange: null,
|
|
202
|
+
addListener: () => {},
|
|
203
|
+
removeListener: () => {},
|
|
204
|
+
addEventListener: () => {},
|
|
205
|
+
removeEventListener: () => {},
|
|
206
|
+
dispatchEvent: () => true,
|
|
207
|
+
});
|
|
208
|
+
Object.defineProperty(window, 'matchMedia', { value: mockMatchMedia, writable: true });
|
|
209
|
+
|
|
210
|
+
render(<Timeline agents={fixtureAgents} />);
|
|
211
|
+
const bars = screen.getAllByTestId(TESTIDS.timelineBar);
|
|
212
|
+
expect(bars.length).toBeGreaterThan(0);
|
|
213
|
+
// Should render without issues even with reduced motion preference
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeline — Horizontal per-agent bars from startedAt/lastActivity/runtimeSeconds.
|
|
3
|
+
* Bars are status-colored via theme tokens. Includes sensible clamping for edge cases.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { formatAge } from '../lib/format';
|
|
7
|
+
import { TESTIDS } from '../test/fixtures';
|
|
8
|
+
import type { Agent } from '../lib/types';
|
|
9
|
+
import styles from './Timeline.module.css';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
agents: Agent[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Status to CSS class mapping
|
|
16
|
+
function getStatusClass(status: string): string {
|
|
17
|
+
if (status === 'running') return 'status-ok';
|
|
18
|
+
if (status === 'idle') return 'status-info';
|
|
19
|
+
if (status === 'SUSPICIOUS' || status === 'HIGH') return 'status-error';
|
|
20
|
+
if (status === 'DRIFT' || status === 'MED') return 'status-warn';
|
|
21
|
+
return 'status-neutral';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Compute bar width percentage based on time span
|
|
25
|
+
function computeBarWidth(startedAt: string | null, lastActivity: string | null, runtimeSeconds: number): number {
|
|
26
|
+
// If we have both timestamps, use them to compute the span
|
|
27
|
+
if (startedAt && lastActivity) {
|
|
28
|
+
try {
|
|
29
|
+
const start = new Date(startedAt).getTime();
|
|
30
|
+
const end = new Date(lastActivity).getTime();
|
|
31
|
+
const span = (end - start) / 1000; // convert to seconds
|
|
32
|
+
|
|
33
|
+
if (span > 0) {
|
|
34
|
+
// Progress from start to end relative to runtime
|
|
35
|
+
const progress = Math.min(span / Math.max(runtimeSeconds, 1), 1);
|
|
36
|
+
return Math.max(5, Math.min(100, progress * 100)); // Clamp between 5% and 100%
|
|
37
|
+
}
|
|
38
|
+
} catch (err) {
|
|
39
|
+
// Fall through to runtime-based calculation
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Fallback: use runtimeSeconds to estimate visibility
|
|
44
|
+
if (runtimeSeconds > 0) {
|
|
45
|
+
// Clamp very large values
|
|
46
|
+
const clamped = Math.min(runtimeSeconds, 86400); // Max 1 day
|
|
47
|
+
return Math.max(5, Math.min(100, (clamped / 3600) * 10)); // Normalize to 5-100%
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return 10; // Default minimum width
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default function Timeline({ agents }: Props) {
|
|
54
|
+
if (agents.length === 0) {
|
|
55
|
+
return (
|
|
56
|
+
<div data-testid={TESTIDS.timeline} className={styles.container}>
|
|
57
|
+
<h3 className={styles.title}>Agent Timeline</h3>
|
|
58
|
+
<div className={styles.emptyState}>(no agents)</div>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div data-testid={TESTIDS.timeline} className={styles.container}>
|
|
65
|
+
<h3 className={styles.title}>Agent Timeline</h3>
|
|
66
|
+
<div className={styles.timelineContent}>
|
|
67
|
+
{agents.map((agent) => {
|
|
68
|
+
const barWidth = computeBarWidth(agent.startedAt, agent.lastActivity, agent.runtimeSeconds ?? 0);
|
|
69
|
+
const statusClass = getStatusClass(agent.status);
|
|
70
|
+
const duration = formatAge(agent.runtimeSeconds ?? 0);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div key={agent.id} className={styles.row}>
|
|
74
|
+
<div className={styles.rowLabel}>
|
|
75
|
+
<span className={styles.agentId}>{agent.id}</span>
|
|
76
|
+
<span className={styles.agentStatus}>{agent.status}</span>
|
|
77
|
+
</div>
|
|
78
|
+
<div className={styles.barContainer}>
|
|
79
|
+
<div
|
|
80
|
+
data-testid={TESTIDS.timelineBar}
|
|
81
|
+
className={`${styles.bar} ${styles[statusClass]}`}
|
|
82
|
+
style={{ width: `${barWidth}%` }}
|
|
83
|
+
aria-label={`Agent ${agent.id} (${agent.status}, ${duration})`}
|
|
84
|
+
role="presentation"
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
<div className={styles.rowDuration}>{duration}</div>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
})}
|
|
91
|
+
</div>
|
|
92
|
+
<div className={styles.footer}>
|
|
93
|
+
<span className={styles.legend}>
|
|
94
|
+
Green = running · Blue = idle · Orange = warn/drift · Red = error/suspicious
|
|
95
|
+
</span>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { TrackerBoard } from './TrackerBoard';
|
|
4
|
+
import { fixtureTrackerItems } from '../test/fixtures';
|
|
5
|
+
import { TESTIDS } from '../test/fixtures';
|
|
6
|
+
|
|
7
|
+
describe('TrackerBoard', () => {
|
|
8
|
+
it('renders all 4 lanes', () => {
|
|
9
|
+
render(<TrackerBoard items={fixtureTrackerItems} />);
|
|
10
|
+
|
|
11
|
+
// Use role queries to be specific about lane headers
|
|
12
|
+
// Lane headers have format: "LaneName <span>Count</span>"
|
|
13
|
+
// So we need to check if the text starts with the lane name
|
|
14
|
+
const headers = screen.getAllByRole('heading', { level: 2 });
|
|
15
|
+
const laneNames = headers.map((h) => {
|
|
16
|
+
// Get the text before the count span
|
|
17
|
+
const firstNode = h.firstChild;
|
|
18
|
+
if (firstNode && firstNode.nodeType === Node.TEXT_NODE) {
|
|
19
|
+
return firstNode.textContent?.trim() || '';
|
|
20
|
+
}
|
|
21
|
+
return h.textContent || '';
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
expect(laneNames.some((name) => name.includes('Proposed'))).toBe(true);
|
|
25
|
+
expect(laneNames.some((name) => name.includes('Ranked'))).toBe(true);
|
|
26
|
+
expect(laneNames.some((name) => name.includes('In Progress'))).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('buckets items correctly by lane', () => {
|
|
30
|
+
render(<TrackerBoard items={fixtureTrackerItems} />);
|
|
31
|
+
|
|
32
|
+
// fixtureTrackerItems[0] has lane: 'in-progress'
|
|
33
|
+
// fixtureTrackerItems[1] has lane: 'done'
|
|
34
|
+
// fixtureTrackerItems[2] has lane: 'ranked'
|
|
35
|
+
// fixtureTrackerItems[3] has lane: 'proposed'
|
|
36
|
+
// fixtureTrackerItems[4] has lane: 'done' (archived status)
|
|
37
|
+
|
|
38
|
+
expect(screen.getByText('Dashboard rewrite: foundation scaffold')).toBeInTheDocument();
|
|
39
|
+
expect(screen.getByText('Cost collector parses OUTCOMES-LEDGER.md')).toBeInTheDocument();
|
|
40
|
+
expect(screen.getByText('Agent timeline read-only v1')).toBeInTheDocument();
|
|
41
|
+
expect(screen.getByText('Replay slider for agent timeline')).toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('displays lane counts with accessible labels', () => {
|
|
45
|
+
render(<TrackerBoard items={fixtureTrackerItems} />);
|
|
46
|
+
|
|
47
|
+
// Should have accessible labels for counts
|
|
48
|
+
const laneCountSpans = screen.getAllByLabelText(/items in/);
|
|
49
|
+
expect(laneCountSpans.length).toBeGreaterThan(0);
|
|
50
|
+
|
|
51
|
+
laneCountSpans.forEach((span) => {
|
|
52
|
+
expect(span).toHaveAttribute('aria-label');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('routes unknown lanes to proposed', () => {
|
|
57
|
+
const unknownLaneItem = {
|
|
58
|
+
...fixtureTrackerItems[0],
|
|
59
|
+
lane: 'undefined-lane' as any,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
render(<TrackerBoard items={[unknownLaneItem]} />);
|
|
63
|
+
|
|
64
|
+
// Should appear under Proposed
|
|
65
|
+
const proposedLane = screen.getByText('Proposed').closest('section');
|
|
66
|
+
expect(proposedLane).toBeInTheDocument();
|
|
67
|
+
expect(proposedLane?.textContent).toContain(unknownLaneItem.title);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('separates archived items from active lanes', () => {
|
|
71
|
+
const archivedItem = fixtureTrackerItems[4]; // status: archived
|
|
72
|
+
expect(archivedItem.status).toBe('archived');
|
|
73
|
+
|
|
74
|
+
render(<TrackerBoard items={[archivedItem]} />);
|
|
75
|
+
|
|
76
|
+
// Should appear in archived section, not in any lane
|
|
77
|
+
expect(screen.getByText('Archived (1)')).toBeInTheDocument();
|
|
78
|
+
expect(screen.getByText(archivedItem.title)).toBeInTheDocument();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('shows archived summary with item count', () => {
|
|
82
|
+
const archived1 = { ...fixtureTrackerItems[1], status: 'archived' as const };
|
|
83
|
+
const archived2 = { ...fixtureTrackerItems[4], status: 'archived' as const };
|
|
84
|
+
|
|
85
|
+
render(<TrackerBoard items={[archived1, archived2]} />);
|
|
86
|
+
|
|
87
|
+
expect(screen.getByText('Archived (2)')).toBeInTheDocument();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('lanes with no items show "No items" placeholder', () => {
|
|
91
|
+
const singleItem = fixtureTrackerItems.slice(0, 1); // only 1 item in in-progress
|
|
92
|
+
|
|
93
|
+
render(<TrackerBoard items={singleItem} />);
|
|
94
|
+
|
|
95
|
+
const emptyMessages = screen.getAllByText('No items');
|
|
96
|
+
expect(emptyMessages.length).toBeGreaterThan(0); // At least 3 empty lanes
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('renders tracker cards in each lane', () => {
|
|
100
|
+
render(<TrackerBoard items={fixtureTrackerItems} />);
|
|
101
|
+
|
|
102
|
+
const cards = screen.getAllByTestId(TESTIDS.trackerCard);
|
|
103
|
+
// Should have 4 active cards (5 total minus 1 archived)
|
|
104
|
+
expect(cards.length).toBeGreaterThanOrEqual(4);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('bucket test: unknown lane maps to proposed', () => {
|
|
108
|
+
const testItems = [
|
|
109
|
+
{
|
|
110
|
+
...fixtureTrackerItems[0],
|
|
111
|
+
lane: 'some-random-lane' as any,
|
|
112
|
+
title: 'Unknown lane item',
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
render(<TrackerBoard items={testItems} />);
|
|
117
|
+
|
|
118
|
+
const proposedSection = screen.getByText('Proposed').closest('section');
|
|
119
|
+
expect(proposedSection?.textContent).toContain('Unknown lane item');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TrackerBoard — kanban-style layout with 4 lanes + archived summary.
|
|
3
|
+
* Lane bucketing: proposed/ranked/in-progress/done.
|
|
4
|
+
* Unknown lanes -> proposed. Empty lanes compact.
|
|
5
|
+
* Lane counts with accessible labels and health badges.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { TESTIDS } from '../test/fixtures';
|
|
9
|
+
import type { TrackerItem } from '../lib/types';
|
|
10
|
+
import { TrackerCard } from './TrackerCard';
|
|
11
|
+
|
|
12
|
+
interface TrackerBoardProps {
|
|
13
|
+
items: TrackerItem[];
|
|
14
|
+
onUpdate?: (item: TrackerItem) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const LANE_ORDER = ['proposed', 'ranked', 'in-progress', 'done'] as const;
|
|
18
|
+
type Lane = (typeof LANE_ORDER)[number];
|
|
19
|
+
|
|
20
|
+
function getLaneLabel(lane: Lane): string {
|
|
21
|
+
return {
|
|
22
|
+
proposed: 'Proposed',
|
|
23
|
+
ranked: 'Ranked',
|
|
24
|
+
'in-progress': 'In Progress',
|
|
25
|
+
done: 'Done',
|
|
26
|
+
}[lane];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeLane(lane: string | undefined): Lane {
|
|
30
|
+
const normalized = lane?.toLowerCase();
|
|
31
|
+
if (LANE_ORDER.includes(normalized as Lane)) {
|
|
32
|
+
return normalized as Lane;
|
|
33
|
+
}
|
|
34
|
+
return 'proposed'; // unknown lane -> proposed
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function TrackerBoard({ items, onUpdate }: TrackerBoardProps) {
|
|
38
|
+
// Separate active items from archived
|
|
39
|
+
const activeItems = items.filter((item) => item.status !== 'archived');
|
|
40
|
+
const archivedItems = items.filter((item) => item.status === 'archived');
|
|
41
|
+
|
|
42
|
+
// Bucket active items by lane
|
|
43
|
+
const laneMap = new Map<Lane, TrackerItem[]>();
|
|
44
|
+
LANE_ORDER.forEach((lane) => laneMap.set(lane, []));
|
|
45
|
+
|
|
46
|
+
activeItems.forEach((item) => {
|
|
47
|
+
const lane = normalizeLane(item.lane);
|
|
48
|
+
laneMap.get(lane)?.push(item);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Render lanes
|
|
52
|
+
const renderedLanes = LANE_ORDER.map((lane) => {
|
|
53
|
+
const items = laneMap.get(lane) || [];
|
|
54
|
+
|
|
55
|
+
// Calculate health badge info (done/total where derivable)
|
|
56
|
+
let badgeText = `${items.length} items`;
|
|
57
|
+
if (lane === 'done') {
|
|
58
|
+
badgeText = `${items.length}/${items.length} done`;
|
|
59
|
+
} else if (items.length > 0) {
|
|
60
|
+
const doneCount = items.filter((item) => item.status === 'done').length;
|
|
61
|
+
if (doneCount > 0) {
|
|
62
|
+
badgeText = `${doneCount}/${items.length} done`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<section key={lane} className="tracker-lane" data-testid={TESTIDS.trackerLane}>
|
|
68
|
+
<h2 className="lane-header">
|
|
69
|
+
{getLaneLabel(lane)}
|
|
70
|
+
<span
|
|
71
|
+
className={`lane-badge ${items.length === 0 ? 'lane-badge--empty' : 'lane-badge--active'}`}
|
|
72
|
+
aria-label={`${badgeText} in ${getLaneLabel(lane)}`}
|
|
73
|
+
>
|
|
74
|
+
{badgeText}
|
|
75
|
+
</span>
|
|
76
|
+
</h2>
|
|
77
|
+
<div className="lane-content">
|
|
78
|
+
{items.length === 0 ? (
|
|
79
|
+
<p className="lane-empty">No items</p>
|
|
80
|
+
) : (
|
|
81
|
+
items.map((item) => <TrackerCard key={item.id} item={item} onUpdate={onUpdate} />)
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
</section>
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className="tracker-board" data-testid={TESTIDS.trackerBoard}>
|
|
90
|
+
<div className="lanes-container">{renderedLanes}</div>
|
|
91
|
+
|
|
92
|
+
{archivedItems.length > 0 && (
|
|
93
|
+
<section className="archived-summary">
|
|
94
|
+
<h3>Archived ({archivedItems.length})</h3>
|
|
95
|
+
<ul className="archived-list">
|
|
96
|
+
{archivedItems.map((item) => (
|
|
97
|
+
<li key={item.id} className="archived-item">
|
|
98
|
+
<span className="archived-title">{item.title}</span>
|
|
99
|
+
<span className="archived-priority">{item.priority}</span>
|
|
100
|
+
</li>
|
|
101
|
+
))}
|
|
102
|
+
</ul>
|
|
103
|
+
</section>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|