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:
co-authored by
Claude Fable 5
parent
1163357967
commit
c9d810c8e5
@@ -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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user