Skip to content

Commit 47e9f26

Browse files
authored
feat: SPA dashboard with static HTML fallback, cold-start fix, recent activity (#497)
* fix: resolve SPA dashboard cold-start timeout The dashboard server was blocking on a GitHub API fetch before binding its port, causing the 5-second startup poll to always timeout. Users always got the static HTML fallback instead of the interactive SPA. Now the server starts immediately with cached state.json data (which the daily check just wrote), then refreshes from GitHub in the background after the port is bound. * feat: remove static HTML dashboard, consolidate to SPA-only Remove the static HTML dashboard (~4,000 lines) and make the Preact SPA the sole dashboard. The SPA already supports all interactive features (shelve, unshelve, snooze, filter, search, refresh, charts). - Delete 7 static HTML modules: dashboard-templates, dashboard-components, dashboard-scripts, dashboard-styles, dashboard-formatters, and their tests - Move buildDashboardStats to dashboard-data.ts (shared with SPA server) - Remove getDashboardPath, writeDashboardFromState, generateDashboardHtml - Remove bare `dashboard` CLI command (keep `dashboard serve`) - Remove dashboardPath from StartupOutput, remove static HTML fallback - Add recently merged/closed PRs and auto-unshelved PRs to SPA API - Add RecentActivity component to SPA showing last 7 days of activity - Update ARCHITECTURE.md, CLAUDE.md, README.md, oss.md, reference.md * fix: address review findings — stale comments, rate-limit propagation, dedup openInBrowser Round 1 review fixes: - Fix stale 'dashboard --open' message in daily-logic.ts - Fix stale 'dashboard --json' JSDoc in dashboard-server.ts - Fix stale 'template generation' and 'HTML output' comments in dashboard-data.ts - Fix stale 'dashboard' in reference.md local-only commands - Fix stale 'dashboard' in getDataDir JSDoc - Add rate-limit/auth error propagation to dashboard-data.ts (mirrors daily.ts) - Log re-fetch errors in use-dashboard.ts (was empty catch block) - Add assertions for 3 new API fields in dashboard-server.test.ts - Remove dead cachedDigest truthiness guard - Extract shared openInBrowser utility, remove duplication from dashboard-server.ts - Assert SPA-unavailable error message in startup.test.ts * fix: restore static HTML dashboard as safety net fallback Keep the static HTML dashboard as a fallback when the SPA cannot launch (e.g., assets not built, server fails to start). The SPA remains the primary dashboard — static HTML is the safety net. Changes: - Restore 5 template files from main (components, formatters, scripts, styles, templates) with updated imports pointing to dashboard-data.ts - Add writeDashboardFromState() back to dashboard.ts - Add getDashboardPath() back to utils.ts + index.ts re-export - Add dashboardPath field to StartupOutput in json.ts - Add tryStaticHtmlFallback() helper in startup.ts for tiered degradation: SPA → static HTML → graceful failure - Fix broken DashboardStats import in dashboard-scripts.ts (was pointing to dashboard-formatters.ts after the type moved to dashboard-data.ts) - Fix TypeScript narrowing issue with cachedDigest in dashboard-server.ts - Update stale docs in README.md and ARCHITECTURE.md - Add 2 new tests: SPA-null+HTML-throws, SPA-throws+HTML-throws - Add briefSummary and error message assertions to fallback tests * fix: resolve ESLint unused-import error in dashboard-templates Remove buildDashboardStats from the import statement — it's only re-exported via a separate `export { } from` declaration, which ESLint correctly flags as an unused import. * style: fix Prettier formatting in dashboard-server and startup tests
1 parent 819288b commit 47e9f26

30 files changed

+558
-2742
lines changed

ARCHITECTURE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ Debug and warning output goes to stderr via the logger, so it never contaminates
104104
| `init` | `init.ts` | Initialize with GitHub username and import open PRs |
105105
| `setup` / `checkSetup` | `setup.ts` | First-run setup and setup verification |
106106
| `vet` | `vet.ts` | Vet a single issue for claimability |
107-
| `dashboard` | `dashboard.ts` | Generate HTML dashboard (with `dashboard-data.ts` and `dashboard-templates.ts`) |
107+
| `dashboard serve` | `dashboard.ts` | Launch interactive SPA dashboard (with `dashboard-data.ts`, `dashboard-templates.ts`, `dashboard-server.ts`) |
108108
| `shelve` / `unshelve` | `shelve.ts` | Temporarily hide PRs from daily digest |
109109
| `snooze` / `unsnooze` | `snooze.ts` | Temporarily suppress PR notifications |
110110
| `dismiss` / `undismiss` | `dismiss.ts` | Dismiss issue reply notifications (auto-resurfaces on new activity) |
@@ -212,7 +212,7 @@ CLI Layer (startup.ts)
212212
│ ├── StateManager.load() → ~/.oss-autopilot/state.json
213213
│ └── computeActionMenu() → Pre-computed menu items (daily-logic.ts)
214214
215-
generateDashboardHtml() → ~/.oss-autopilot/dashboard.html
215+
launchDashboardServer() → http://localhost:3000 (SPA dashboard)
216216
217217
│ Returns JsonOutput<StartupOutput> to stdout
218218
@@ -272,7 +272,7 @@ PRs are **not** stored in state. On every `daily` run, all open PRs are fetched
272272
├── state.json # AgentState (config, issues, scores, events)
273273
├── backups/ # Auto-backups before each state write
274274
├── cache/ # ETag-based HTTP response cache
275-
└── dashboard.html # Generated HTML status dashboard
275+
└── dashboard-server.pid # Running dashboard server PID + port
276276
```
277277

278278
## Security Model

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ Repo root (also the Claude Code plugin directory):
8686
│ │ ├── dist/cli.bundle.cjs # Built bundle (gitignored, auto-generated)
8787
│ │ ├── package.json # Published to npm, has bin + exports
8888
│ │ └── tsconfig.json
89-
│ └── dashboard/ # @oss-autopilot/dashboard (placeholder)
89+
│ └── dashboard/ # @oss-autopilot/dashboard (interactive SPA)
9090
│ └── package.json
9191
├── pnpm-workspace.yaml # Workspace definition
9292
├── package.json # Workspace root (private, not published)
@@ -95,7 +95,7 @@ Repo root (also the Claude Code plugin directory):
9595
~/.oss-autopilot/ # User data (separate from plugin code)
9696
├── state.json # PR tracking state (AgentState)
9797
├── backups/ # Auto-backups of state before writes
98-
└── dashboard.html # Generated HTML dashboard
98+
└── dashboard-server.pid # PID file for interactive SPA dashboard server
9999
```
100100

101101
## Development Commands

README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ What would you like to do?
8787

8888
Then Claude walks you through each issue: drafting responses, diagnosing CI failures, resolving conflicts, until everything is handled.
8989

90-
An HTML dashboard also opens in your browser with charts showing your contribution timeline, merge rate, and PR health at a glance.
90+
An interactive dashboard also opens in your browser at `http://localhost:3000` with charts showing your contribution timeline, merge rate, and PR health at a glance.
9191

9292
### When You Search for Issues
9393

@@ -194,14 +194,16 @@ Issues from spam repos (label farming, templated mass issues) and inactive proje
194194

195195
### Dashboard
196196

197-
The dashboard (`~/.oss-autopilot/dashboard.html`) auto-opens each time you run `/oss`. It includes:
197+
An interactive dashboard auto-opens at `http://localhost:3000` each time you run `/oss`. It includes:
198198

199199
![OSS Autopilot Dashboard](docs/images/dashboard.png)
200200

201201
- **Status Overview** - Doughnut chart of PR states (active, merged, closed, dormant)
202202
- **Repository Breakdown** - Top 10 repos by total PRs with stacked bars
203203
- **Contribution Timeline** - Monthly view of PRs opened, merged, and closed
204204

205+
The dashboard is a Preact SPA served locally. You can also launch it directly with `npx @oss-autopilot/core dashboard serve`.
206+
205207
### Curated Issue Lists
206208

207209
Maintain a markdown file of issues you're interested in. `/oss` detects it and offers "Pick from issue list" as an action. Completed issues get marked done automatically.
@@ -387,7 +389,7 @@ OSS Autopilot is a **pnpm monorepo** with three packages, plus a plugin layer:
387389
|---------|-----|-------------|
388390
| `@oss-autopilot/core` | [![npm](https://img.shields.io/npm/v/@oss-autopilot/core)](https://www.npmjs.com/package/@oss-autopilot/core) | Core library + CLI. PR monitoring, issue discovery, state management, GitHub API. |
389391
| `@oss-autopilot/mcp` | [![npm](https://img.shields.io/npm/v/@oss-autopilot/mcp)](https://www.npmjs.com/package/@oss-autopilot/mcp) | MCP server for Cursor, Claude Desktop, Codex, Windsurf, and any MCP client. |
390-
| `@oss-autopilot/dashboard` || Interactive HTML dashboard with charts and PR health view. |
392+
| `@oss-autopilot/dashboard` || Interactive Preact SPA dashboard with charts and PR health view. |
391393

392394
### CLI
393395

@@ -401,7 +403,7 @@ The CLI supports `--json` on every command for structured output:
401403
GITHUB_TOKEN=$(gh auth token) npx @oss-autopilot/core daily --json
402404
GITHUB_TOKEN=$(gh auth token) npx @oss-autopilot/core search 10 --json
403405
npx @oss-autopilot/core status --json
404-
npx @oss-autopilot/core dashboard
406+
npx @oss-autopilot/core dashboard serve
405407
```
406408

407409
All commands return `{ success, data, error, timestamp }`, useful for building your own tooling on top.
@@ -501,7 +503,7 @@ npm run bundle
501503
<details>
502504
<summary>Dashboard doesn't open</summary>
503505

504-
The dashboard is at `~/.oss-autopilot/dashboard.html`. If it doesn't open automatically, open it manually in your browser.
506+
The interactive dashboard runs at `http://localhost:3000`. If it doesn't open automatically, try launching it manually with `npx @oss-autopilot/core dashboard serve`, then open `http://localhost:3000` in your browser.
505507
</details>
506508

507509
<details>
@@ -520,7 +522,7 @@ The dashboard is at `~/.oss-autopilot/dashboard.html`. If it doesn't open automa
520522
No. Claude drafts responses and suggests actions. Nothing is posted to GitHub without your explicit approval.
521523

522524
**Where is my data stored?**
523-
Config in `.claude/oss-autopilot/config.md`. State and dashboard in `~/.oss-autopilot/`. Nothing is sent to external servers beyond GitHub API calls to fetch your PR data.
525+
Config in `.claude/oss-autopilot/config.md`. State in `~/.oss-autopilot/`. The dashboard runs locally at `http://localhost:3000`. Nothing is sent to external servers beyond GitHub API calls to fetch your PR data.
524526

525527
**Does it work with private repos?**
526528
Yes, as long as your GitHub CLI (`gh`) has access.

commands/oss.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ After the bash call completes, jump straight to displaying the brief summary and
8484

8585
## Combined Bash Script
8686

87-
Run **everything** in a single bash call. The CLI's `startup` command handles auth, setup, daily fetch, dashboard generation, version detection, and issue list detection internally. The output is a single JSON envelope.
87+
Run **everything** in a single bash call. The CLI's `startup` command handles auth, setup, daily fetch, interactive dashboard launch, version detection, and issue list detection internally. The output is a single JSON envelope.
8888

8989
```bash
9090
# Rebuild CLI if needed
@@ -116,8 +116,7 @@ The output is a single JSON object with the standard envelope: `{ success: boole
116116
| `data.setupComplete` | Whether setup is done | If `false`, prompt setup |
117117
| `data.authError` | Set when no GitHub token | If present, show auth instructions |
118118
| `data.daily` | DailyOutput (same shape as before) | Extract `briefSummary`, `actionableIssues`, `actionMenu`, etc. |
119-
| `data.dashboardPath` | Path to generated dashboard HTML (static fallback) | Always present on success |
120-
| `data.dashboardUrl` | URL of interactive dashboard SPA (e.g., `http://localhost:3000`) | If present, show `Dashboard: <url>` so user can re-open it |
119+
| `data.dashboardUrl` | URL of interactive dashboard SPA (e.g., `http://localhost:3000`) | Show `Dashboard: <url>` so user can re-open it |
121120
| `data.issueList` | Issue list info (if detected) | `hasIssueList` = present; extract `path`, `source`, `availableCount`, `completedCount` |
122121

123122
**Routing based on parsed data:**
@@ -209,7 +208,7 @@ The CLI returns structured data with new fields for the action-first flow:
209208
...
210209
}
211210
},
212-
"dashboardPath": "/Users/.../.oss-autopilot/dashboard.html",
211+
"dashboardUrl": "http://localhost:3000",
213212
"issueList": { "path": "open-source/potential-issue-list.md", "source": "auto-detected", "availableCount": 5, "completedCount": 3 }
214213
}
215214
}

packages/core/src/cli.test.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,6 @@ describe('LOCAL_ONLY_COMMANDS', () => {
110110
expect(LOCAL_ONLY_COMMANDS).toContain('checkSetup');
111111
});
112112

113-
it('should contain "dashboard"', () => {
114-
expect(LOCAL_ONLY_COMMANDS).toContain('dashboard');
115-
});
116-
117113
it('should contain "parse-issue-list"', () => {
118114
expect(LOCAL_ONLY_COMMANDS).toContain('parse-issue-list');
119115
});
@@ -168,9 +164,9 @@ describe('LOCAL_ONLY_COMMANDS', () => {
168164
});
169165

170166
it('should have exactly the expected number of entries (no accidental additions/deletions)', () => {
171-
// 20 entries: 18 active + 'help' and 'version' which Commander intercepts before
167+
// 19 entries: 17 active + 'help' and 'version' which Commander intercepts before
172168
// preAction fires, so they are defensive/dead entries but intentionally kept.
173-
expect(LOCAL_ONLY_COMMANDS).toHaveLength(20);
169+
expect(LOCAL_ONLY_COMMANDS).toHaveLength(19);
174170
});
175171
});
176172

packages/core/src/cli.ts

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ const LOCAL_ONLY_COMMANDS = [
4949
'version',
5050
'setup',
5151
'checkSetup',
52-
'dashboard',
5352
'serve',
5453
'parse-issue-list',
5554
'check-integration',
@@ -530,20 +529,6 @@ dashboardCmd
530529
}
531530
});
532531

533-
// Keep bare `dashboard` (no subcommand) for backward compat — generates static HTML
534-
dashboardCmd
535-
.option('--open', 'Open in browser')
536-
.option('--json', 'Output as JSON')
537-
.option('--offline', 'Use cached data only (no GitHub API calls)')
538-
.action(async (options) => {
539-
try {
540-
const { runDashboard } = await import('./commands/dashboard.js');
541-
await runDashboard({ open: options.open, json: options.json, offline: options.offline });
542-
} catch (err) {
543-
handleCommandError(err, options.json);
544-
}
545-
});
546-
547532
// Parse issue list command (#82)
548533
program
549534
.command('parse-issue-list <path>')
@@ -657,7 +642,6 @@ program
657642
} else {
658643
console.log(`OSS Autopilot v${data.version}`);
659644
console.log(data.daily?.briefSummary ?? '');
660-
if (data.dashboardPath) console.log(`Dashboard: ${data.dashboardPath}`);
661645
}
662646
}
663647
} catch (err) {

packages/core/src/commands/dashboard-data.test.ts

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
*/
44

55
import { describe, it, expect } from 'vitest';
6-
import { computePRsByRepo, computeTopRepos, getMonthlyData } from './dashboard-data.js';
7-
import type { DailyDigest, AgentState } from '../core/types.js';
6+
import { buildDashboardStats, computePRsByRepo, computeTopRepos, getMonthlyData } from './dashboard-data.js';
7+
import type { DailyDigest, AgentState, ShelvedPRRef } from '../core/types.js';
88

99
function makeDigest(overrides: Partial<DailyDigest> = {}): DailyDigest {
1010
return {
@@ -50,6 +50,108 @@ function makeState(overrides: Partial<AgentState> = {}): AgentState {
5050
} as AgentState;
5151
}
5252

53+
// ---------------------------------------------------------------------------
54+
// buildDashboardStats
55+
// ---------------------------------------------------------------------------
56+
57+
describe('buildDashboardStats', () => {
58+
it('returns zeros when digest has no summary', () => {
59+
const digest = makeDigest();
60+
// Remove summary to trigger the default fallback
61+
(digest as any).summary = undefined;
62+
const stats = buildDashboardStats(digest, makeState());
63+
expect(stats).toEqual({
64+
activePRs: 0,
65+
shelvedPRs: 0,
66+
mergedPRs: 0,
67+
closedPRs: 0,
68+
mergeRate: '0.0%',
69+
});
70+
});
71+
72+
it('pulls activePRs from summary.totalActivePRs', () => {
73+
const digest = makeDigest({
74+
summary: { totalActivePRs: 5, totalNeedingAttention: 2, totalMergedAllTime: 10, mergeRate: 80 },
75+
});
76+
const stats = buildDashboardStats(digest, makeState());
77+
expect(stats.activePRs).toBe(5);
78+
});
79+
80+
it('counts shelvedPRs from digest.shelvedPRs array length', () => {
81+
const shelvedPRs: ShelvedPRRef[] = [
82+
{ number: 1, url: 'u1', title: 't1', repo: 'r/1', daysSinceActivity: 5, status: 'healthy' },
83+
{ number: 2, url: 'u2', title: 't2', repo: 'r/2', daysSinceActivity: 10, status: 'dormant' },
84+
];
85+
const digest = makeDigest({ shelvedPRs });
86+
const stats = buildDashboardStats(digest, makeState());
87+
expect(stats.shelvedPRs).toBe(2);
88+
});
89+
90+
it('pulls mergedPRs from summary.totalMergedAllTime', () => {
91+
const digest = makeDigest({
92+
summary: { totalActivePRs: 0, totalNeedingAttention: 0, totalMergedAllTime: 42, mergeRate: 85 },
93+
});
94+
const stats = buildDashboardStats(digest, makeState());
95+
expect(stats.mergedPRs).toBe(42);
96+
});
97+
98+
it('sums closedWithoutMergeCount across all repoScores', () => {
99+
const state = makeState({
100+
repoScores: {
101+
'a/b': {
102+
repo: 'a/b',
103+
score: 5,
104+
mergedPRCount: 1,
105+
closedWithoutMergeCount: 3,
106+
avgResponseDays: null,
107+
lastEvaluatedAt: '2025-06-01T00:00:00Z',
108+
signals: { hasActiveMaintainers: true, isResponsive: true, hasHostileComments: false },
109+
},
110+
'c/d': {
111+
repo: 'c/d',
112+
score: 7,
113+
mergedPRCount: 2,
114+
closedWithoutMergeCount: 1,
115+
avgResponseDays: null,
116+
lastEvaluatedAt: '2025-06-01T00:00:00Z',
117+
signals: { hasActiveMaintainers: true, isResponsive: true, hasHostileComments: false },
118+
},
119+
},
120+
});
121+
const stats = buildDashboardStats(makeDigest(), state);
122+
expect(stats.closedPRs).toBe(4); // 3 + 1
123+
});
124+
125+
it('formats mergeRate as a percentage string', () => {
126+
const digest = makeDigest({
127+
summary: { totalActivePRs: 0, totalNeedingAttention: 0, totalMergedAllTime: 0, mergeRate: 72.3456 },
128+
});
129+
const stats = buildDashboardStats(digest, makeState());
130+
expect(stats.mergeRate).toBe('72.3%');
131+
});
132+
133+
it('handles null/undefined mergeRate gracefully', () => {
134+
const digest = makeDigest();
135+
(digest.summary as any).mergeRate = null;
136+
const stats = buildDashboardStats(digest, makeState());
137+
expect(stats.mergeRate).toBe('0.0%');
138+
});
139+
140+
it('handles missing repoScores gracefully', () => {
141+
const state = makeState();
142+
(state as any).repoScores = undefined;
143+
const stats = buildDashboardStats(makeDigest(), state);
144+
expect(stats.closedPRs).toBe(0);
145+
});
146+
147+
it('handles missing shelvedPRs array', () => {
148+
const digest = makeDigest();
149+
(digest as any).shelvedPRs = undefined;
150+
const stats = buildDashboardStats(digest, makeState());
151+
expect(stats.shelvedPRs).toBe(0);
152+
});
153+
});
154+
53155
describe('computePRsByRepo', () => {
54156
it('should group active PRs by repo', () => {
55157
const digest = makeDigest({

0 commit comments

Comments
 (0)