React frontend: PR dashboard, per-check dropdowns, timeline, themes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgBEW6qAwgn2fcQbA2f4ZQ
This commit is contained in:
Joshua Coles
2026-07-28 14:28:59 +00:00
co-authored by Claude Fable 5
parent 1163357967
commit c9d810c8e5
20 changed files with 1560 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
import { loadConfig } from './config.js';
import { Db } from './db.js';
import { runPoll } from './poller.js';
import { getLastRateLimit } from './github/client.js';
import { createApp, buildSyncPayload } from './routes.js';
import { broadcast } from './sse.js';
const config = loadConfig();
const db = new Db();
let polling = false;
async function poll(): Promise<void> {
if (polling) return;
polling = true;
try {
const result = await runPoll(config, db);
console.log(
`[poll] ${result.prCount} PRs, ${result.changedPrIds.length} changed, ${result.newEventCount} new events`,
);
broadcast(buildSyncPayload(db, result.changedPrIds, result.newEventCount));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`[poll] failed: ${message}`);
db.setMeta('last_sync_error', message);
broadcast(buildSyncPayload(db));
} finally {
polling = false;
}
}
function scheduleNext(): void {
// Back off to 5 minutes when the rate-limit budget runs low.
const remaining = getLastRateLimit()?.remaining;
const intervalMs =
remaining !== undefined && remaining < 500 ? 5 * 60_000 : config.pollIntervalSeconds * 1000;
setTimeout(async () => {
await poll();
scheduleNext();
}, intervalMs);
}
const app = createApp({ db, triggerSync: poll });
app.listen(config.port, () => {
console.log(`pr-monitor listening on http://localhost:${config.port}`);
});
await poll();
scheduleNext();
+86
View File
@@ -0,0 +1,86 @@
import express, { type Express } from 'express';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import type { StatePayload, SyncEventPayload } from '../shared/types.js';
import type { Db } from './db.js';
import { rootDir } from './config.js';
import { addClient } from './sse.js';
export interface RouteDeps {
db: Db;
triggerSync: () => Promise<void>;
}
export function buildSyncPayload(db: Db, changedPrIds: string[] = [], newEventCount = 0): SyncEventPayload {
return {
lastSyncAt: db.getMeta('last_sync_at'),
error: db.getMeta('last_sync_error'),
changedPrIds,
newEventCount,
unreadCount: db.unreadCounts().total,
};
}
export function createApp({ db, triggerSync }: RouteDeps): Express {
const app = express();
app.use(express.json());
app.get('/api/state', (_req, res) => {
const { byPr, total } = db.unreadCounts();
const payload: StatePayload = {
prs: db.getOpenSnapshots(),
lastSyncAt: db.getMeta('last_sync_at'),
lastSyncError: db.getMeta('last_sync_error'),
unreadCountsByPr: byPr,
unreadTotal: total,
};
res.json(payload);
});
app.get('/api/timeline', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 50, 200);
const before = typeof req.query.before === 'string' ? req.query.before : undefined;
const unreadOnly = req.query.unread === '1';
res.json(db.timeline({ limit, before, unreadOnly }));
});
app.post('/api/timeline/:id/read', (req, res) => {
db.markRead(req.params.id, true, new Date().toISOString());
res.json({ ok: true });
});
app.post('/api/timeline/:id/unread', (req, res) => {
db.markRead(req.params.id, false, new Date().toISOString());
res.json({ ok: true });
});
app.post('/api/timeline/read-all', (req, res) => {
const before = typeof req.body?.before === 'string' ? req.body.before : undefined;
const changed = db.markAllRead(new Date().toISOString(), before);
res.json({ ok: true, changed });
});
app.post('/api/prs/:id/read-all', (req, res) => {
const changed = db.markAllReadForPr(req.params.id, new Date().toISOString());
res.json({ ok: true, changed });
});
app.post('/api/sync', (_req, res) => {
void triggerSync();
res.json({ ok: true });
});
app.get('/api/events', (_req, res) => {
addClient(res, buildSyncPayload(db));
});
const webDist = join(rootDir, 'web', 'dist');
if (existsSync(webDist)) {
app.use(express.static(webDist));
app.get(/^\/(?!api\/).*/, (_req, res) => {
res.sendFile(join(webDist, 'index.html'));
});
}
return app;
}
+30
View File
@@ -0,0 +1,30 @@
import type { Response } from 'express';
import type { SyncEventPayload } from '../shared/types.js';
const clients = new Set<Response>();
export function addClient(res: Response, initial: SyncEventPayload): void {
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive',
'x-accel-buffering': 'no',
});
res.write('retry: 3000\n\n');
res.write(frame(initial));
clients.add(res);
res.on('close', () => clients.delete(res));
}
export function broadcast(payload: SyncEventPayload): void {
const data = frame(payload);
for (const res of clients) res.write(data);
}
function frame(payload: SyncEventPayload): string {
return `event: sync\ndata: ${JSON.stringify(payload)}\n\n`;
}
setInterval(() => {
for (const res of clients) res.write(': ping\n\n');
}, 25_000).unref();