diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..e36321d --- /dev/null +++ b/server/index.ts @@ -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 { + 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(); diff --git a/server/routes.ts b/server/routes.ts new file mode 100644 index 0000000..ce1dcfb --- /dev/null +++ b/server/routes.ts @@ -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; +} + +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; +} diff --git a/server/sse.ts b/server/sse.ts new file mode 100644 index 0000000..3b61780 --- /dev/null +++ b/server/sse.ts @@ -0,0 +1,30 @@ +import type { Response } from 'express'; +import type { SyncEventPayload } from '../shared/types.js'; + +const clients = new Set(); + +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(); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..e006550 --- /dev/null +++ b/web/index.html @@ -0,0 +1,25 @@ + + + + + + PR Monitor + + + + + + +
+ + + diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..a14461c --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,55 @@ +import { useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; +import { fetchState } from './api'; +import { useSSE } from './useSSE'; +import Header from './components/Header'; +import PrList from './components/PrList'; +import Timeline from './components/Timeline'; + +type MobileView = 'prs' | 'timeline'; + +export default function App() { + const sse = useSSE(); + const stateQuery = useQuery({ queryKey: ['state'], queryFn: fetchState }); + const [mobileView, setMobileView] = useState('prs'); + + const state = stateQuery.data; + const lastSyncAt = sse.lastSync?.lastSyncAt ?? state?.lastSyncAt ?? null; + const error = sse.lastSync ? sse.lastSync.error : (state?.lastSyncError ?? null); + const unreadTotal = sse.lastSync?.unreadCount ?? state?.unreadTotal ?? 0; + + return ( +
+
+
+
+ {state ? ( + + ) : ( +
+ {stateQuery.isError ? 'Failed to load — is the server running?' : 'Loading…'} +
+ )} +
+
+ +
+
+ +
+ ); +} diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..c5e9342 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,33 @@ +import type { StatePayload, TimelinePage } from '../../shared/types'; + +async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(path, init); + if (!res.ok) throw new Error(`${path}: ${res.status}`); + return res.json() as Promise; +} + +export const fetchState = (): Promise => request('/api/state'); + +export const fetchTimeline = (opts: { + cursor?: string; + unreadOnly: boolean; +}): Promise => { + const params = new URLSearchParams({ limit: '50' }); + if (opts.cursor) params.set('before', opts.cursor); + if (opts.unreadOnly) params.set('unread', '1'); + return request(`/api/timeline?${params}`); +}; + +export const markRead = (id: string, read: boolean): Promise => + request(`/api/timeline/${encodeURIComponent(id)}/${read ? 'read' : 'unread'}`, { + method: 'POST', + }); + +export const markAllRead = (): Promise => + request('/api/timeline/read-all', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }); + +export const triggerSync = (): Promise => request('/api/sync', { method: 'POST' }); diff --git a/web/src/components/CategoryChips.tsx b/web/src/components/CategoryChips.tsx new file mode 100644 index 0000000..059b411 --- /dev/null +++ b/web/src/components/CategoryChips.tsx @@ -0,0 +1,104 @@ +import { useEffect, useRef, useState } from 'react'; +import type { CategoryStatus, CategoryState } from '../../../shared/types'; + +const ICONS: Record = { + pass: '✓', + fail: '✗', + running: '◐', + pending: '○', + skipped: '−', + none: '', +}; + +const LABELS: Record = { + lint: 'lint', + specs: 'specs', + e2e: 'e2e', + other: 'other', +}; + +function Chip({ status }: { status: CategoryStatus }) { + const [open, setOpen] = useState(false); + const [alignRight, setAlignRight] = useState(false); + const ref = useRef(null); + + const updateAlignment = () => { + const rect = ref.current?.getBoundingClientRect(); + if (rect) setAlignRight(rect.left + 300 > window.innerWidth); + }; + + useEffect(() => { + if (!open) return; + const close = (e: PointerEvent) => { + if (!ref.current?.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('pointerdown', close); + return () => document.removeEventListener('pointerdown', close); + }, [open]); + + const showCount = status.total > 0 && status.passed < status.total; + + return ( +
+ +
+ {status.checks.map((check) => + check.url ? ( + + {ICONS[check.state]} + + {check.name} + + + ) : ( +
+ {ICONS[check.state]} + + {check.name} + +
+ ), + )} +
+
+ ); +} + +export default function CategoryChips({ categories }: { categories: CategoryStatus[] }) { + return ( + <> + {categories + .filter((c) => c.checks.length > 0) + .map((c) => ( + + ))} + + ); +} diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx new file mode 100644 index 0000000..58a02ae --- /dev/null +++ b/web/src/components/Header.tsx @@ -0,0 +1,57 @@ +import { useEffect, useState } from 'react'; +import { getTheme, nextTheme, setTheme, type ThemeChoice } from '../theme'; +import { relativeTime } from '../format'; +import { triggerSync } from '../api'; + +const THEME_ICON: Record = { light: '☀', dark: '☾', system: '◑' }; + +export default function Header({ + lastSyncAt, + error, + connected, +}: { + lastSyncAt: string | null; + error: string | null; + connected: boolean; +}) { + const [theme, setThemeState] = useState(getTheme); + // Re-render every 30s so relative times stay fresh. + const [, tick] = useState(0); + useEffect(() => { + const t = setInterval(() => tick((n) => n + 1), 30_000); + return () => clearInterval(t); + }, []); + + const stale = lastSyncAt !== null && Date.now() - Date.parse(lastSyncAt) > 3 * 60_000; + const dotClass = error ? 'error' : stale || !connected ? 'stale' : ''; + + return ( + <> +
+

PR Monitor

+
+ + +
+ {error &&
Sync error: {error}
} + + ); +} diff --git a/web/src/components/PrCard.tsx b/web/src/components/PrCard.tsx new file mode 100644 index 0000000..29147f8 --- /dev/null +++ b/web/src/components/PrCard.tsx @@ -0,0 +1,30 @@ +import type { PrSnapshot } from '../../../shared/types'; +import CategoryChips from './CategoryChips'; +import ReviewBadge from './ReviewBadge'; +import { relativeTime } from '../format'; + +export default function PrCard({ pr, unread }: { pr: PrSnapshot; unread: number }) { + return ( +
+
+ #{pr.number} + + {pr.title} + + {unread > 0 && {unread}} +
+
+ {pr.isDraft && draft} + {pr.author} + + {pr.branch} + + {relativeTime(pr.updatedAt)} +
+
+ + +
+
+ ); +} diff --git a/web/src/components/PrList.tsx b/web/src/components/PrList.tsx new file mode 100644 index 0000000..3785636 --- /dev/null +++ b/web/src/components/PrList.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react'; +import type { StatePayload } from '../../../shared/types'; +import PrCard from './PrCard'; +import { shortRepo } from '../format'; + +type Filter = 'all' | 'unread' | string; + +export default function PrList({ state }: { state: StatePayload }) { + const [filter, setFilter] = useState('all'); + + const repos = [...new Set(state.prs.map((p) => p.repo))]; + const filtered = state.prs.filter((pr) => { + if (filter === 'all') return true; + if (filter === 'unread') return (state.unreadCountsByPr[pr.id] ?? 0) > 0; + return pr.repo === filter; + }); + + const byRepo = new Map(); + for (const pr of filtered) { + if (!byRepo.has(pr.repo)) byRepo.set(pr.repo, []); + byRepo.get(pr.repo)!.push(pr); + } + + return ( + <> +
+ + + {repos.map((repo) => ( + + ))} +
+
+ {filtered.length === 0 &&
No PRs match.
} + {[...byRepo.entries()].map(([repo, prs]) => ( +
+
{repo}
+ {prs.map((pr) => ( +
+ +
+ ))} +
+ ))} +
+ + ); +} diff --git a/web/src/components/ReviewBadge.tsx b/web/src/components/ReviewBadge.tsx new file mode 100644 index 0000000..011c349 --- /dev/null +++ b/web/src/components/ReviewBadge.tsx @@ -0,0 +1,36 @@ +import type { PrSnapshot } from '../../../shared/types'; + +const STATE_LABEL = { + no_review: { icon: '◌', text: 'no review', cls: '' }, + outstanding: { icon: '●', text: 'review outstanding', cls: 'outstanding' }, + addressed: { icon: '✓', text: 'review addressed', cls: 'addressed' }, +} as const; + +export default function ReviewBadge({ pr }: { pr: PrSnapshot }) { + const s = STATE_LABEL[pr.reviewState]; + return ( + <> + + {s.icon} + {s.text} + {pr.reviewState === 'outstanding' && pr.unresolvedThreads > 0 && ( + + · {pr.unresolvedThreads} thread{pr.unresolvedThreads > 1 ? 's' : ''} + + )} + + {pr.verdicts.map((v) => ( + + {v.state === 'APPROVED' ? '✓' : '±'} {v.reviewer} + + ))} + + ); +} diff --git a/web/src/components/Timeline.tsx b/web/src/components/Timeline.tsx new file mode 100644 index 0000000..9554411 --- /dev/null +++ b/web/src/components/Timeline.tsx @@ -0,0 +1,169 @@ +import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import type { TimelineEvent } from '../../../shared/types'; +import { fetchTimeline, markAllRead, markRead } from '../api'; +import { relativeTime, shortRepo } from '../format'; + +const GROUP_WINDOW_MS = 10 * 60 * 1000; + +interface Group { + key: string; + actor: string; + repo: string; + prNumber: number; + prTitle: string; + events: TimelineEvent[]; +} + +function groupEvents(events: TimelineEvent[]): Group[] { + const groups: Group[] = []; + for (const e of events) { + const last = groups[groups.length - 1]; + const lastEvent = last?.events[last.events.length - 1]; + if ( + last && + lastEvent && + last.actor === e.actor && + last.repo === e.repo && + last.prNumber === e.prNumber && + Date.parse(lastEvent.createdAt) - Date.parse(e.createdAt) < GROUP_WINDOW_MS + ) { + last.events.push(e); + } else { + groups.push({ + key: e.id, + actor: e.actor, + repo: e.repo, + prNumber: e.prNumber, + prTitle: e.prTitle, + events: [e], + }); + } + } + return groups; +} + +function KindLabel({ event }: { event: TimelineEvent }) { + if (event.kind === 'review') { + const cls = + event.reviewState === 'APPROVED' + ? 'approved' + : event.reviewState === 'CHANGES_REQUESTED' + ? 'changes' + : ''; + const label = + event.reviewState === 'APPROVED' + ? 'approved' + : event.reviewState === 'CHANGES_REQUESTED' + ? 'requested changes' + : 'reviewed'; + return {label}; + } + if (event.kind === 'review_comment') { + return ( + + commented{event.path ? ' on ' : ''} + {event.path && {event.path.split('/').pop()}} + + ); + } + return commented; +} + +export default function Timeline() { + const [unreadOnly, setUnreadOnly] = useState(false); + const queryClient = useQueryClient(); + + const query = useInfiniteQuery({ + queryKey: ['timeline', unreadOnly], + queryFn: ({ pageParam }) => fetchTimeline({ cursor: pageParam, unreadOnly }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (page) => page.nextCursor ?? undefined, + }); + + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: ['timeline'] }); + void queryClient.invalidateQueries({ queryKey: ['state'] }); + }; + + const readMutation = useMutation({ + mutationFn: ({ id, read }: { id: string; read: boolean }) => markRead(id, read), + onSettled: invalidate, + }); + + const readAllMutation = useMutation({ mutationFn: markAllRead, onSettled: invalidate }); + + const events = query.data?.pages.flatMap((p) => p.events) ?? []; + const groups = groupEvents(events); + + const openEvent = (e: TimelineEvent) => { + if (!e.read) readMutation.mutate({ id: e.id, read: true }); + window.open(e.url, '_blank', 'noopener'); + }; + + return ( + <> +
+

Activity

+ + +
+
+ {query.isLoading &&
Loading…
} + {!query.isLoading && groups.length === 0 && ( +
{unreadOnly ? 'Nothing unread.' : 'No activity yet.'}
+ )} + {groups.map((group) => ( +
+
+ {group.actor} + + {shortRepo(group.repo)}#{group.prNumber} · {group.prTitle} + + {relativeTime(group.events[0]!.createdAt)} +
+ {group.events.map((e) => ( +
openEvent(e)} + > +
+ ))} +
+ ))} + {query.hasNextPage && ( + + )} +
+ + ); +} diff --git a/web/src/format.ts b/web/src/format.ts new file mode 100644 index 0000000..1614133 --- /dev/null +++ b/web/src/format.ts @@ -0,0 +1,16 @@ +export function relativeTime(iso: string | null): string { + if (!iso) return 'never'; + const seconds = Math.round((Date.now() - Date.parse(iso)) / 1000); + if (seconds < 45) return 'just now'; + if (seconds < 90) return '1 min ago'; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes} min ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.round(hours / 24); + return `${days}d ago`; +} + +export function shortRepo(repo: string): string { + return repo.split('/')[1] ?? repo; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..4b8be92 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,20 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import App from './App'; +import './styles/tokens.css'; +import './styles/app.css'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { staleTime: 30_000, refetchOnWindowFocus: true }, + }, +}); + +createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/web/src/styles/app.css b/web/src/styles/app.css new file mode 100644 index 0000000..5c3a22a --- /dev/null +++ b/web/src/styles/app.css @@ -0,0 +1,629 @@ +* { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + height: 100%; +} + +body { + font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; + background: var(--page); + color: var(--ink); + font-size: 14px; + line-height: 1.45; +} + +a { + color: inherit; +} + +button { + font: inherit; + color: inherit; + background: none; + border: none; + padding: 0; + cursor: pointer; +} + +.app { + display: flex; + flex-direction: column; + height: 100%; +} + +/* ---------- header ---------- */ + +.header { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + border-bottom: 1px solid var(--hairline); + background: var(--surface); + position: sticky; + top: 0; + z-index: 20; +} + +.header h1 { + font-size: 15px; + font-weight: 650; + margin: 0; +} + +.header .spacer { + flex: 1; +} + +.sync-status { + display: flex; + align-items: center; + gap: 6px; + color: var(--ink-secondary); + font-size: 12px; + white-space: nowrap; +} + +.sync-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--status-pass); +} + +.sync-dot.stale { + background: var(--status-running); +} + +.sync-dot.error { + background: var(--status-fail); +} + +.error-banner { + background: var(--chip-fail-bg); + color: var(--status-fail-text); + padding: 6px 16px; + font-size: 12px; + border-bottom: 1px solid var(--hairline); + overflow-wrap: anywhere; +} + +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 7px; + border: 1px solid var(--border); + background: var(--surface-raised); + font-size: 14px; +} + +.icon-btn:hover { + border-color: var(--ink-muted); +} + +/* ---------- layout ---------- */ + +.columns { + display: flex; + flex: 1; + min-height: 0; +} + +.pane { + overflow-y: auto; + overflow-x: hidden; + min-width: 0; + -webkit-overflow-scrolling: touch; +} + +.pane-prs { + flex: 3; + border-right: 1px solid var(--hairline); +} + +.pane-timeline { + flex: 2; + background: var(--surface); +} + +.mobile-tabs { + display: none; +} + +@media (max-width: 899px) { + .columns .pane { + display: none; + flex: 1; + } + + .columns .pane.active { + display: block; + } + + .pane-prs { + border-right: none; + } + + .mobile-tabs { + display: flex; + border-top: 1px solid var(--hairline); + background: var(--surface); + padding-bottom: env(safe-area-inset-bottom); + } + + .mobile-tabs button { + flex: 1; + padding: 10px 0 8px; + font-size: 13px; + color: var(--ink-muted); + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + } + + .mobile-tabs button.active { + color: var(--accent); + font-weight: 600; + } + + .tab-badge { + position: absolute; + transform: translate(14px, -2px); + background: var(--unread); + color: #fff; + font-size: 10px; + font-weight: 600; + border-radius: 8px; + min-width: 16px; + height: 16px; + line-height: 16px; + padding: 0 4px; + } +} + +/* ---------- filter tabs ---------- */ + +.filter-tabs { + display: flex; + gap: 6px; + padding: 10px 16px 4px; + flex-wrap: wrap; +} + +.filter-tabs button { + padding: 4px 10px; + border-radius: 14px; + border: 1px solid var(--border); + color: var(--ink-secondary); + font-size: 12px; + background: var(--surface); +} + +.filter-tabs button.active { + background: var(--ink); + color: var(--page); + border-color: var(--ink); +} + +/* ---------- PR cards ---------- */ + +.pr-list { + padding: 8px 16px 24px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.repo-heading { + margin: 10px 0 2px; + font-size: 11px; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ink-muted); +} + +.pr-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 12px; +} + +.pr-card .title-row { + display: flex; + align-items: baseline; + gap: 8px; +} + +.pr-card .pr-number { + color: var(--ink-muted); + font-size: 12px; + white-space: nowrap; +} + +.pr-card .pr-title { + font-weight: 600; + text-decoration: none; + overflow-wrap: anywhere; +} + +.pr-card .pr-title:hover { + text-decoration: underline; +} + +.pr-card .meta-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 4px; + color: var(--ink-muted); + font-size: 12px; + flex-wrap: wrap; +} + +.draft-tag { + border: 1px solid var(--border); + border-radius: 4px; + padding: 0 5px; + font-size: 11px; + color: var(--ink-secondary); +} + +.branch { + font-family: ui-monospace, monospace; + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + max-width: 220px; + white-space: nowrap; +} + +.unread-pill { + background: var(--unread); + color: #fff; + border-radius: 9px; + font-size: 11px; + font-weight: 600; + padding: 0 6px; + line-height: 17px; +} + +.chips-row { + display: flex; + align-items: center; + gap: 6px; + margin-top: 8px; + flex-wrap: wrap; +} + +/* ---------- category chips + dropdown ---------- */ + +.chip-wrap { + position: relative; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 9px; + border-radius: 13px; + font-size: 12px; + font-weight: 600; + border: 1px solid transparent; +} + +.chip .count { + font-weight: 500; + opacity: 0.85; +} + +.chip-pass { + background: var(--chip-pass-bg); + color: var(--status-pass-text); +} + +.chip-fail { + background: var(--chip-fail-bg); + color: var(--status-fail-text); +} + +.chip-running { + background: var(--chip-running-bg); + color: var(--status-running-text); +} + +.chip-running .chip-icon { + animation: pulse 1.4s ease-in-out infinite; +} + +.chip-pending { + background: var(--chip-pending-bg); + color: var(--ink-secondary); +} + +.chip-skipped { + background: var(--chip-skipped-bg); + color: var(--ink-muted); +} + +@keyframes pulse { + 50% { + opacity: 0.35; + } +} + +.check-dropdown { + display: none; + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 30; + min-width: 260px; + max-width: min(340px, 90vw); + max-height: 300px; + overflow-y: auto; + background: var(--surface-raised); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: var(--shadow); + padding: 6px; +} + +.chip-wrap.align-right .check-dropdown { + left: auto; + right: 0; +} + +.chip-wrap.open .check-dropdown { + display: block; +} + +@media (hover: hover) { + .chip-wrap:hover .check-dropdown { + display: block; + } +} + +.check-row { + display: flex; + align-items: center; + gap: 7px; + padding: 4px 6px; + border-radius: 6px; + font-size: 12px; + color: var(--ink); + text-decoration: none; +} + +a.check-row:hover { + background: var(--chip-pending-bg); +} + +.check-row .check-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.check-icon { + font-size: 11px; + width: 14px; + text-align: center; + flex: none; +} + +.check-icon.pass { + color: var(--status-pass-text); +} + +.check-icon.fail { + color: var(--status-fail-text); +} + +.check-icon.running { + color: var(--status-running-text); + animation: pulse 1.4s ease-in-out infinite; +} + +.check-icon.pending, +.check-icon.skipped { + color: var(--ink-muted); +} + +/* ---------- review badge ---------- */ + +.review-badge { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12px; + padding: 3px 9px; + border-radius: 13px; + border: 1px solid var(--border); + color: var(--ink-secondary); +} + +.review-badge.outstanding { + border-color: var(--status-running); + color: var(--status-running-text); +} + +.review-badge.addressed { + border-color: var(--status-pass); + color: var(--status-pass-text); +} + +.verdict { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 11px; + padding: 2px 7px; + border-radius: 11px; +} + +.verdict.approved { + background: var(--chip-pass-bg); + color: var(--status-pass-text); +} + +.verdict.changes { + background: var(--chip-fail-bg); + color: var(--status-fail-text); +} + +/* ---------- timeline ---------- */ + +.timeline-head { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px 6px; + position: sticky; + top: 0; + background: var(--surface); + z-index: 10; +} + +.timeline-head h2 { + font-size: 13px; + font-weight: 650; + margin: 0; + flex: 1; +} + +.timeline-head button { + font-size: 12px; + color: var(--ink-secondary); + border: 1px solid var(--border); + border-radius: 12px; + padding: 3px 9px; +} + +.timeline-head button.active { + background: var(--ink); + color: var(--page); + border-color: var(--ink); +} + +.timeline-list { + padding: 0 8px 24px; +} + +.tl-group { + margin: 10px 6px 0; +} + +.tl-group-head { + display: flex; + align-items: baseline; + gap: 6px; + font-size: 12px; + color: var(--ink-secondary); + padding: 0 6px 2px; + flex-wrap: wrap; +} + +.tl-group-head .actor { + font-weight: 650; + color: var(--ink); +} + +.tl-group-head a { + color: var(--ink-secondary); + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} + +.tl-group-head a:hover { + text-decoration: underline; +} + +.tl-item { + display: flex; + gap: 8px; + padding: 6px 6px; + border-radius: 8px; + cursor: pointer; + align-items: flex-start; +} + +.tl-item:hover { + background: var(--chip-pending-bg); +} + +.tl-item .dot { + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; + margin-top: 6px; + background: transparent; + border: 1px solid transparent; +} + +.tl-item.unread .dot { + background: var(--unread); +} + +.tl-item .body { + color: var(--ink-secondary); + font-size: 13px; + overflow-wrap: anywhere; +} + +.tl-item.unread .body { + color: var(--ink); +} + +.tl-kind { + font-size: 11px; + color: var(--ink-muted); +} + +.tl-kind .path { + font-family: ui-monospace, monospace; +} + +.tl-review-state { + font-weight: 600; +} + +.tl-review-state.approved { + color: var(--status-pass-text); +} + +.tl-review-state.changes { + color: var(--status-fail-text); +} + +.load-more { + display: block; + margin: 12px auto; + padding: 5px 14px; + border: 1px solid var(--border); + border-radius: 14px; + color: var(--ink-secondary); + font-size: 12px; +} + +.empty-note { + color: var(--ink-muted); + text-align: center; + padding: 32px 16px; + font-size: 13px; +} diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css new file mode 100644 index 0000000..46bd24a --- /dev/null +++ b/web/src/styles/tokens.css @@ -0,0 +1,87 @@ +:root { + color-scheme: light; + --page: #f9f9f7; + --surface: #fcfcfb; + --surface-raised: #ffffff; + --ink: #0b0b0b; + --ink-secondary: #52514e; + --ink-muted: #898781; + --hairline: #e1e0d9; + --border: rgba(11, 11, 11, 0.1); + --accent: #2a78d6; + + /* Status palette (mode-invariant hues; always paired with icon + label). */ + --status-pass: #0ca30c; + --status-pass-text: #006300; + --status-fail: #d03b3b; + --status-fail-text: #b02f2f; + --status-running: #fab219; + --status-running-text: #8a5f00; + --status-pending: #898781; + --status-skipped: #b8b6ae; + + --chip-pass-bg: rgba(12, 163, 12, 0.1); + --chip-fail-bg: rgba(208, 59, 59, 0.12); + --chip-running-bg: rgba(250, 178, 25, 0.16); + --chip-pending-bg: rgba(137, 135, 129, 0.12); + --chip-skipped-bg: rgba(137, 135, 129, 0.07); + + --unread: #2a78d6; + --shadow: 0 4px 16px rgba(11, 11, 11, 0.12); +} + +@media (prefers-color-scheme: dark) { + :root:where(:not([data-theme='light'])) { + color-scheme: dark; + --page: #0d0d0d; + --surface: #1a1a19; + --surface-raised: #242423; + --ink: #ffffff; + --ink-secondary: #c3c2b7; + --ink-muted: #898781; + --hairline: #2c2c2a; + --border: rgba(255, 255, 255, 0.1); + --accent: #3987e5; + + --status-pass-text: #4cc24c; + --status-fail-text: #e66767; + --status-running-text: #fab219; + --status-skipped: #55544f; + + --chip-pass-bg: rgba(12, 163, 12, 0.16); + --chip-fail-bg: rgba(208, 59, 59, 0.2); + --chip-running-bg: rgba(250, 178, 25, 0.16); + --chip-pending-bg: rgba(137, 135, 129, 0.18); + --chip-skipped-bg: rgba(137, 135, 129, 0.1); + + --unread: #3987e5; + --shadow: 0 4px 16px rgba(0, 0, 0, 0.5); + } +} + +:root[data-theme='dark'] { + color-scheme: dark; + --page: #0d0d0d; + --surface: #1a1a19; + --surface-raised: #242423; + --ink: #ffffff; + --ink-secondary: #c3c2b7; + --ink-muted: #898781; + --hairline: #2c2c2a; + --border: rgba(255, 255, 255, 0.1); + --accent: #3987e5; + + --status-pass-text: #4cc24c; + --status-fail-text: #e66767; + --status-running-text: #fab219; + --status-skipped: #55544f; + + --chip-pass-bg: rgba(12, 163, 12, 0.16); + --chip-fail-bg: rgba(208, 59, 59, 0.2); + --chip-running-bg: rgba(250, 178, 25, 0.16); + --chip-pending-bg: rgba(137, 135, 129, 0.18); + --chip-skipped-bg: rgba(137, 135, 129, 0.1); + + --unread: #3987e5; + --shadow: 0 4px 16px rgba(0, 0, 0, 0.5); +} diff --git a/web/src/theme.ts b/web/src/theme.ts new file mode 100644 index 0000000..f3da688 --- /dev/null +++ b/web/src/theme.ts @@ -0,0 +1,20 @@ +export type ThemeChoice = 'light' | 'dark' | 'system'; + +export function getTheme(): ThemeChoice { + const stored = localStorage.getItem('theme'); + return stored === 'light' || stored === 'dark' ? stored : 'system'; +} + +export function setTheme(choice: ThemeChoice): void { + if (choice === 'system') { + localStorage.removeItem('theme'); + delete document.documentElement.dataset.theme; + } else { + localStorage.setItem('theme', choice); + document.documentElement.dataset.theme = choice; + } +} + +export function nextTheme(current: ThemeChoice): ThemeChoice { + return current === 'system' ? 'light' : current === 'light' ? 'dark' : 'system'; +} diff --git a/web/src/useSSE.ts b/web/src/useSSE.ts new file mode 100644 index 0000000..1cf6b68 --- /dev/null +++ b/web/src/useSSE.ts @@ -0,0 +1,30 @@ +import { useEffect, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import type { SyncEventPayload } from '../../shared/types'; + +export interface ConnectionState { + connected: boolean; + lastSync: SyncEventPayload | null; +} + +export function useSSE(): ConnectionState { + const queryClient = useQueryClient(); + const [state, setState] = useState({ connected: false, lastSync: null }); + + useEffect(() => { + const source = new EventSource('/api/events'); + source.onopen = () => setState((s) => ({ ...s, connected: true })); + source.onerror = () => setState((s) => ({ ...s, connected: false })); + source.addEventListener('sync', (e) => { + const payload = JSON.parse((e as MessageEvent).data) as SyncEventPayload; + setState({ connected: true, lastSync: payload }); + if (payload.changedPrIds.length > 0 || payload.newEventCount > 0) { + void queryClient.invalidateQueries({ queryKey: ['state'] }); + void queryClient.invalidateQueries({ queryKey: ['timeline'] }); + } + }); + return () => source.close(); + }, [queryClient]); + + return state; +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..6cf6318 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src", "../shared"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..335b2c9 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': { target: 'http://localhost:4000' }, + }, + }, +});