pr-monitor/web/src/components/Timeline.tsx
Joshua Coles c9d810c8e5 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
2026-07-28 14:28:59 +00:00

170 lines
5.3 KiB
TypeScript

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>
</>
);
}