Per-PR timeline focus: click a card to filter activity, mark-PR-read, clear chip
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgBEW6qAwgn2fcQbA2f4ZQ
This commit is contained in:
parent
d14b6ebed3
commit
3b7850fe1a
11
server/db.ts
11
server/db.ts
@ -195,9 +195,18 @@ export class Db {
|
||||
return this.db.prepare('SELECT 1 FROM events WHERE id = ?').get(id) !== undefined;
|
||||
}
|
||||
|
||||
timeline(opts: { limit: number; before?: string; unreadOnly?: boolean }): TimelinePage {
|
||||
timeline(opts: {
|
||||
limit: number;
|
||||
before?: string;
|
||||
unreadOnly?: boolean;
|
||||
prId?: string;
|
||||
}): TimelinePage {
|
||||
const clauses = ["p.state != ''"];
|
||||
const params: (string | number)[] = [];
|
||||
if (opts.prId) {
|
||||
clauses.push('e.pr_id = ?');
|
||||
params.push(opts.prId);
|
||||
}
|
||||
if (opts.before) {
|
||||
clauses.push('(e.created_at < ? OR (e.created_at = ? AND e.id < ?))');
|
||||
const [createdAt, id] = splitCursor(opts.before);
|
||||
|
||||
@ -41,7 +41,8 @@ export function createApp({ db, triggerSync }: RouteDeps): Express {
|
||||
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 }));
|
||||
const prId = typeof req.query.pr === 'string' ? req.query.pr : undefined;
|
||||
res.json(db.timeline({ limit, before, unreadOnly, prId }));
|
||||
});
|
||||
|
||||
app.post('/api/timeline/:id/read', (req, res) => {
|
||||
|
||||
@ -12,8 +12,15 @@ export default function App() {
|
||||
const sse = useSSE();
|
||||
const stateQuery = useQuery({ queryKey: ['state'], queryFn: fetchState });
|
||||
const [mobileView, setMobileView] = useState<MobileView>('prs');
|
||||
const [selectedPrId, setSelectedPrId] = useState<string | null>(null);
|
||||
|
||||
const state = stateQuery.data;
|
||||
const selectedPr = state?.prs.find((p) => p.id === selectedPrId) ?? null;
|
||||
|
||||
const selectPr = (id: string) => {
|
||||
setSelectedPrId((cur) => (cur === id ? null : id));
|
||||
setMobileView('timeline');
|
||||
};
|
||||
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;
|
||||
@ -24,7 +31,7 @@ export default function App() {
|
||||
<div className="columns">
|
||||
<div className={`pane pane-prs${mobileView === 'prs' ? ' active' : ''}`}>
|
||||
{state ? (
|
||||
<PrList state={state} />
|
||||
<PrList state={state} selectedPrId={selectedPrId} onSelect={selectPr} />
|
||||
) : (
|
||||
<div className="empty-note">
|
||||
{stateQuery.isError ? 'Failed to load — is the server running?' : 'Loading…'}
|
||||
@ -32,7 +39,7 @@ export default function App() {
|
||||
)}
|
||||
</div>
|
||||
<div className={`pane pane-timeline${mobileView === 'timeline' ? ' active' : ''}`}>
|
||||
<Timeline />
|
||||
<Timeline selectedPr={selectedPr} onClearSelection={() => setSelectedPrId(null)} />
|
||||
</div>
|
||||
</div>
|
||||
<nav className="mobile-tabs">
|
||||
|
||||
@ -11,13 +11,18 @@ export const fetchState = (): Promise<StatePayload> => request('/api/state');
|
||||
export const fetchTimeline = (opts: {
|
||||
cursor?: string;
|
||||
unreadOnly: boolean;
|
||||
prId?: string;
|
||||
}): Promise<TimelinePage> => {
|
||||
const params = new URLSearchParams({ limit: '50' });
|
||||
if (opts.cursor) params.set('before', opts.cursor);
|
||||
if (opts.unreadOnly) params.set('unread', '1');
|
||||
if (opts.prId) params.set('pr', opts.prId);
|
||||
return request(`/api/timeline?${params}`);
|
||||
};
|
||||
|
||||
export const markPrRead = (prId: string): Promise<unknown> =>
|
||||
request(`/api/prs/${encodeURIComponent(prId)}/read-all`, { method: 'POST' });
|
||||
|
||||
export const markRead = (id: string, read: boolean): Promise<unknown> =>
|
||||
request(`/api/timeline/${encodeURIComponent(id)}/${read ? 'read' : 'unread'}`, {
|
||||
method: 'POST',
|
||||
|
||||
@ -3,9 +3,26 @@ import CategoryChips from './CategoryChips';
|
||||
import ReviewBadge from './ReviewBadge';
|
||||
import { relativeTime } from '../format';
|
||||
|
||||
export default function PrCard({ pr, unread }: { pr: PrSnapshot; unread: number }) {
|
||||
export default function PrCard({
|
||||
pr,
|
||||
unread,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
pr: PrSnapshot;
|
||||
unread: number;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="pr-card">
|
||||
<div
|
||||
className={`pr-card${selected ? ' selected' : ''}`}
|
||||
onClick={(e) => {
|
||||
// Links, chips, and dropdown contents keep their own behavior.
|
||||
if ((e.target as HTMLElement).closest('a, button, .check-dropdown')) return;
|
||||
onSelect();
|
||||
}}
|
||||
>
|
||||
<div className="title-row">
|
||||
<span className="pr-number">#{pr.number}</span>
|
||||
<a className="pr-title" href={pr.url} target="_blank" rel="noreferrer">
|
||||
|
||||
@ -5,7 +5,15 @@ import { shortRepo } from '../format';
|
||||
|
||||
type Filter = 'all' | 'unread' | string;
|
||||
|
||||
export default function PrList({ state }: { state: StatePayload }) {
|
||||
export default function PrList({
|
||||
state,
|
||||
selectedPrId,
|
||||
onSelect,
|
||||
}: {
|
||||
state: StatePayload;
|
||||
selectedPrId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
|
||||
const repos = [...new Set(state.prs.map((p) => p.repo))];
|
||||
@ -47,7 +55,12 @@ export default function PrList({ state }: { state: StatePayload }) {
|
||||
<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} />
|
||||
<PrCard
|
||||
pr={pr}
|
||||
unread={state.unreadCountsByPr[pr.id] ?? 0}
|
||||
selected={pr.id === selectedPrId}
|
||||
onSelect={() => onSelect(pr.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
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 type { PrSnapshot } from '../../../shared/types';
|
||||
import { fetchTimeline, markAllRead, markPrRead, markRead } from '../api';
|
||||
import { relativeTime, shortRepo } from '../format';
|
||||
|
||||
const GROUP_WINDOW_MS = 10 * 60 * 1000;
|
||||
@ -99,13 +100,20 @@ function DiffHunk({ hunk }: { hunk: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function Timeline() {
|
||||
export default function Timeline({
|
||||
selectedPr,
|
||||
onClearSelection,
|
||||
}: {
|
||||
selectedPr: PrSnapshot | null;
|
||||
onClearSelection: () => void;
|
||||
}) {
|
||||
const [unreadOnly, setUnreadOnly] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const prId = selectedPr?.id;
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ['timeline', unreadOnly],
|
||||
queryFn: ({ pageParam }) => fetchTimeline({ cursor: pageParam, unreadOnly }),
|
||||
queryKey: ['timeline', unreadOnly, prId ?? null],
|
||||
queryFn: ({ pageParam }) => fetchTimeline({ cursor: pageParam, unreadOnly, prId }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (page) => page.nextCursor ?? undefined,
|
||||
});
|
||||
@ -121,6 +129,7 @@ export default function Timeline() {
|
||||
});
|
||||
|
||||
const readAllMutation = useMutation({ mutationFn: markAllRead, onSettled: invalidate });
|
||||
const prReadMutation = useMutation({ mutationFn: markPrRead, onSettled: invalidate });
|
||||
|
||||
const events = query.data?.pages.flatMap((p) => p.events) ?? [];
|
||||
const groups = groupEvents(events);
|
||||
@ -137,8 +146,22 @@ export default function Timeline() {
|
||||
<button className={unreadOnly ? 'active' : ''} onClick={() => setUnreadOnly((v) => !v)}>
|
||||
Unread only
|
||||
</button>
|
||||
<button onClick={() => readAllMutation.mutate()}>Mark all read</button>
|
||||
{selectedPr ? (
|
||||
<button onClick={() => prReadMutation.mutate(selectedPr.id)}>Mark PR read</button>
|
||||
) : (
|
||||
<button onClick={() => readAllMutation.mutate()}>Mark all read</button>
|
||||
)}
|
||||
</div>
|
||||
{selectedPr && (
|
||||
<div className="pr-filter-bar">
|
||||
<span className="pr-filter-label" title={selectedPr.title}>
|
||||
{shortRepo(selectedPr.repo)}#{selectedPr.number} · {selectedPr.title}
|
||||
</span>
|
||||
<button onClick={onClearSelection} aria-label="Show all activity">
|
||||
✕ all activity
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="timeline-list">
|
||||
{query.isLoading && <div className="empty-note">Loading…</div>}
|
||||
{!query.isLoading && groups.length === 0 && (
|
||||
@ -148,14 +171,16 @@ export default function Timeline() {
|
||||
<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>
|
||||
{!selectedPr && (
|
||||
<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) => (
|
||||
|
||||
@ -237,6 +237,16 @@ button {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pr-card:hover {
|
||||
border-color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.pr-card.selected {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
.pr-card .title-row {
|
||||
@ -530,6 +540,32 @@ a.check-row:hover {
|
||||
border-color: var(--ink);
|
||||
}
|
||||
|
||||
.pr-filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 14px 4px;
|
||||
padding: 5px 10px;
|
||||
background: var(--chip-pending-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pr-filter-bar .pr-filter-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pr-filter-bar button {
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeline-list {
|
||||
padding: 0 8px 24px;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user