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
+104
View File
@@ -0,0 +1,104 @@
import { useEffect, useRef, useState } from 'react';
import type { CategoryStatus, CategoryState } from '../../../shared/types';
const ICONS: Record<CategoryState, string> = {
pass: '✓',
fail: '✗',
running: '◐',
pending: '○',
skipped: '−',
none: '',
};
const LABELS: Record<string, string> = {
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<HTMLDivElement>(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 (
<div
className={`chip-wrap${open ? ' open' : ''}${alignRight ? ' align-right' : ''}`}
ref={ref}
onMouseEnter={updateAlignment}
>
<button
className={`chip chip-${status.state}`}
onClick={() => {
updateAlignment();
setOpen((o) => !o);
}}
aria-expanded={open}
aria-label={`${LABELS[status.category]}: ${status.state}, ${status.passed} of ${status.total} passed`}
>
<span className="chip-icon">{ICONS[status.state]}</span>
{LABELS[status.category]}
{showCount && (
<span className="count">
{status.passed}/{status.total}
</span>
)}
</button>
<div className="check-dropdown" role="list">
{status.checks.map((check) =>
check.url ? (
<a
key={check.name}
className="check-row"
href={check.url}
target="_blank"
rel="noreferrer"
role="listitem"
>
<span className={`check-icon ${check.state}`}>{ICONS[check.state]}</span>
<span className="check-name" title={check.name}>
{check.name}
</span>
</a>
) : (
<div key={check.name} className="check-row" role="listitem">
<span className={`check-icon ${check.state}`}>{ICONS[check.state]}</span>
<span className="check-name" title={check.name}>
{check.name}
</span>
</div>
),
)}
</div>
</div>
);
}
export default function CategoryChips({ categories }: { categories: CategoryStatus[] }) {
return (
<>
{categories
.filter((c) => c.checks.length > 0)
.map((c) => (
<Chip key={c.category} status={c} />
))}
</>
);
}
+57
View File
@@ -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<ThemeChoice, string> = { light: '☀', dark: '☾', system: '◑' };
export default function Header({
lastSyncAt,
error,
connected,
}: {
lastSyncAt: string | null;
error: string | null;
connected: boolean;
}) {
const [theme, setThemeState] = useState<ThemeChoice>(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 (
<>
<header className="header">
<h1>PR Monitor</h1>
<div className="spacer" />
<button
className="sync-status"
onClick={() => void triggerSync()}
title="Trigger sync now"
>
<span className={`sync-dot ${dotClass}`} />
{connected ? `synced ${relativeTime(lastSyncAt)}` : 'reconnecting…'}
</button>
<button
className="icon-btn"
onClick={() => {
const next = nextTheme(theme);
setTheme(next);
setThemeState(next);
}}
title={`Theme: ${theme}`}
aria-label={`Theme: ${theme}. Click to change.`}
>
{THEME_ICON[theme]}
</button>
</header>
{error && <div className="error-banner">Sync error: {error}</div>}
</>
);
}
+30
View File
@@ -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 (
<div className="pr-card">
<div className="title-row">
<span className="pr-number">#{pr.number}</span>
<a className="pr-title" href={pr.url} target="_blank" rel="noreferrer">
{pr.title}
</a>
{unread > 0 && <span className="unread-pill">{unread}</span>}
</div>
<div className="meta-row">
{pr.isDraft && <span className="draft-tag">draft</span>}
<span>{pr.author}</span>
<span className="branch" title={pr.branch}>
{pr.branch}
</span>
<span>{relativeTime(pr.updatedAt)}</span>
</div>
<div className="chips-row">
<ReviewBadge pr={pr} />
<CategoryChips categories={pr.categories} />
</div>
</div>
);
}
+58
View File
@@ -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<Filter>('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<string, typeof filtered>();
for (const pr of filtered) {
if (!byRepo.has(pr.repo)) byRepo.set(pr.repo, []);
byRepo.get(pr.repo)!.push(pr);
}
return (
<>
<div className="filter-tabs">
<button className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>
All ({state.prs.length})
</button>
<button className={filter === 'unread' ? 'active' : ''} onClick={() => setFilter('unread')}>
Unread{state.unreadTotal > 0 ? ` (${state.unreadTotal})` : ''}
</button>
{repos.map((repo) => (
<button
key={repo}
className={filter === repo ? 'active' : ''}
onClick={() => setFilter(repo)}
>
{shortRepo(repo)}
</button>
))}
</div>
<div className="pr-list">
{filtered.length === 0 && <div className="empty-note">No PRs match.</div>}
{[...byRepo.entries()].map(([repo, prs]) => (
<div key={repo}>
<div className="repo-heading">{repo}</div>
{prs.map((pr) => (
<div key={pr.id} style={{ marginBottom: 8 }}>
<PrCard pr={pr} unread={state.unreadCountsByPr[pr.id] ?? 0} />
</div>
))}
</div>
))}
</div>
</>
);
}
+36
View File
@@ -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 (
<>
<span className={`review-badge ${s.cls}`}>
<span aria-hidden>{s.icon}</span>
{s.text}
{pr.reviewState === 'outstanding' && pr.unresolvedThreads > 0 && (
<span>
· {pr.unresolvedThreads} thread{pr.unresolvedThreads > 1 ? 's' : ''}
</span>
)}
</span>
{pr.verdicts.map((v) => (
<a
key={v.reviewer}
className={`verdict ${v.state === 'APPROVED' ? 'approved' : 'changes'}`}
href={v.url}
target="_blank"
rel="noreferrer"
title={`${v.reviewer}: ${v.state === 'APPROVED' ? 'approved' : 'requested changes'}`}
>
{v.state === 'APPROVED' ? '✓' : '±'} {v.reviewer}
</a>
))}
</>
);
}
+169
View File
@@ -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 <span className={`tl-review-state ${cls}`}>{label}</span>;
}
if (event.kind === 'review_comment') {
return (
<span>
commented{event.path ? ' on ' : ''}
{event.path && <span className="path">{event.path.split('/').pop()}</span>}
</span>
);
}
return <span>commented</span>;
}
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 (
<>
<div className="timeline-head">
<h2>Activity</h2>
<button className={unreadOnly ? 'active' : ''} onClick={() => setUnreadOnly((v) => !v)}>
Unread only
</button>
<button onClick={() => readAllMutation.mutate()}>Mark all read</button>
</div>
<div className="timeline-list">
{query.isLoading && <div className="empty-note">Loading…</div>}
{!query.isLoading && groups.length === 0 && (
<div className="empty-note">{unreadOnly ? 'Nothing unread.' : 'No activity yet.'}</div>
)}
{groups.map((group) => (
<div className="tl-group" key={group.key}>
<div className="tl-group-head">
<span className="actor">{group.actor}</span>
<a
href={`https://github.com/${group.repo}/pull/${group.prNumber}`}
target="_blank"
rel="noreferrer"
title={group.prTitle}
>
{shortRepo(group.repo)}#{group.prNumber} · {group.prTitle}
</a>
<span>{relativeTime(group.events[0]!.createdAt)}</span>
</div>
{group.events.map((e) => (
<div
key={e.id}
className={`tl-item${e.read ? '' : ' unread'}`}
onClick={() => openEvent(e)}
>
<button
className="dot"
aria-label={e.read ? 'mark unread' : 'mark read'}
title={e.read ? 'Mark unread' : 'Mark read'}
onClick={(ev) => {
ev.stopPropagation();
readMutation.mutate({ id: e.id, read: !e.read });
}}
/>
<div>
<div className="tl-kind">
<KindLabel event={e} />
</div>
{e.bodyExcerpt && <div className="body">{e.bodyExcerpt}</div>}
</div>
</div>
))}
</div>
))}
{query.hasNextPage && (
<button
className="load-more"
onClick={() => query.fetchNextPage()}
disabled={query.isFetchingNextPage}
>
{query.isFetchingNextPage ? 'Loading…' : 'Load older'}
</button>
)}
</div>
</>
);
}