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;
|
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 clauses = ["p.state != ''"];
|
||||||
const params: (string | number)[] = [];
|
const params: (string | number)[] = [];
|
||||||
|
if (opts.prId) {
|
||||||
|
clauses.push('e.pr_id = ?');
|
||||||
|
params.push(opts.prId);
|
||||||
|
}
|
||||||
if (opts.before) {
|
if (opts.before) {
|
||||||
clauses.push('(e.created_at < ? OR (e.created_at = ? AND e.id < ?))');
|
clauses.push('(e.created_at < ? OR (e.created_at = ? AND e.id < ?))');
|
||||||
const [createdAt, id] = splitCursor(opts.before);
|
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 limit = Math.min(Number(req.query.limit) || 50, 200);
|
||||||
const before = typeof req.query.before === 'string' ? req.query.before : undefined;
|
const before = typeof req.query.before === 'string' ? req.query.before : undefined;
|
||||||
const unreadOnly = req.query.unread === '1';
|
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) => {
|
app.post('/api/timeline/:id/read', (req, res) => {
|
||||||
|
|||||||
@ -12,8 +12,15 @@ export default function App() {
|
|||||||
const sse = useSSE();
|
const sse = useSSE();
|
||||||
const stateQuery = useQuery({ queryKey: ['state'], queryFn: fetchState });
|
const stateQuery = useQuery({ queryKey: ['state'], queryFn: fetchState });
|
||||||
const [mobileView, setMobileView] = useState<MobileView>('prs');
|
const [mobileView, setMobileView] = useState<MobileView>('prs');
|
||||||
|
const [selectedPrId, setSelectedPrId] = useState<string | null>(null);
|
||||||
|
|
||||||
const state = stateQuery.data;
|
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 lastSyncAt = sse.lastSync?.lastSyncAt ?? state?.lastSyncAt ?? null;
|
||||||
const error = sse.lastSync ? sse.lastSync.error : (state?.lastSyncError ?? null);
|
const error = sse.lastSync ? sse.lastSync.error : (state?.lastSyncError ?? null);
|
||||||
const unreadTotal = sse.lastSync?.unreadCount ?? state?.unreadTotal ?? 0;
|
const unreadTotal = sse.lastSync?.unreadCount ?? state?.unreadTotal ?? 0;
|
||||||
@ -24,7 +31,7 @@ export default function App() {
|
|||||||
<div className="columns">
|
<div className="columns">
|
||||||
<div className={`pane pane-prs${mobileView === 'prs' ? ' active' : ''}`}>
|
<div className={`pane pane-prs${mobileView === 'prs' ? ' active' : ''}`}>
|
||||||
{state ? (
|
{state ? (
|
||||||
<PrList state={state} />
|
<PrList state={state} selectedPrId={selectedPrId} onSelect={selectPr} />
|
||||||
) : (
|
) : (
|
||||||
<div className="empty-note">
|
<div className="empty-note">
|
||||||
{stateQuery.isError ? 'Failed to load — is the server running?' : 'Loading…'}
|
{stateQuery.isError ? 'Failed to load — is the server running?' : 'Loading…'}
|
||||||
@ -32,7 +39,7 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={`pane pane-timeline${mobileView === 'timeline' ? ' active' : ''}`}>
|
<div className={`pane pane-timeline${mobileView === 'timeline' ? ' active' : ''}`}>
|
||||||
<Timeline />
|
<Timeline selectedPr={selectedPr} onClearSelection={() => setSelectedPrId(null)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="mobile-tabs">
|
<nav className="mobile-tabs">
|
||||||
|
|||||||
@ -11,13 +11,18 @@ export const fetchState = (): Promise<StatePayload> => request('/api/state');
|
|||||||
export const fetchTimeline = (opts: {
|
export const fetchTimeline = (opts: {
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
unreadOnly: boolean;
|
unreadOnly: boolean;
|
||||||
|
prId?: string;
|
||||||
}): Promise<TimelinePage> => {
|
}): Promise<TimelinePage> => {
|
||||||
const params = new URLSearchParams({ limit: '50' });
|
const params = new URLSearchParams({ limit: '50' });
|
||||||
if (opts.cursor) params.set('before', opts.cursor);
|
if (opts.cursor) params.set('before', opts.cursor);
|
||||||
if (opts.unreadOnly) params.set('unread', '1');
|
if (opts.unreadOnly) params.set('unread', '1');
|
||||||
|
if (opts.prId) params.set('pr', opts.prId);
|
||||||
return request(`/api/timeline?${params}`);
|
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> =>
|
export const markRead = (id: string, read: boolean): Promise<unknown> =>
|
||||||
request(`/api/timeline/${encodeURIComponent(id)}/${read ? 'read' : 'unread'}`, {
|
request(`/api/timeline/${encodeURIComponent(id)}/${read ? 'read' : 'unread'}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@ -3,9 +3,26 @@ import CategoryChips from './CategoryChips';
|
|||||||
import ReviewBadge from './ReviewBadge';
|
import ReviewBadge from './ReviewBadge';
|
||||||
import { relativeTime } from '../format';
|
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 (
|
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">
|
<div className="title-row">
|
||||||
<span className="pr-number">#{pr.number}</span>
|
<span className="pr-number">#{pr.number}</span>
|
||||||
<a className="pr-title" href={pr.url} target="_blank" rel="noreferrer">
|
<a className="pr-title" href={pr.url} target="_blank" rel="noreferrer">
|
||||||
|
|||||||
@ -5,7 +5,15 @@ import { shortRepo } from '../format';
|
|||||||
|
|
||||||
type Filter = 'all' | 'unread' | string;
|
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 [filter, setFilter] = useState<Filter>('all');
|
||||||
|
|
||||||
const repos = [...new Set(state.prs.map((p) => p.repo))];
|
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>
|
<div className="repo-heading">{repo}</div>
|
||||||
{prs.map((pr) => (
|
{prs.map((pr) => (
|
||||||
<div key={pr.id} style={{ marginBottom: 8 }}>
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { TimelineEvent } from '../../../shared/types';
|
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';
|
import { relativeTime, shortRepo } from '../format';
|
||||||
|
|
||||||
const GROUP_WINDOW_MS = 10 * 60 * 1000;
|
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 [unreadOnly, setUnreadOnly] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const prId = selectedPr?.id;
|
||||||
|
|
||||||
const query = useInfiniteQuery({
|
const query = useInfiniteQuery({
|
||||||
queryKey: ['timeline', unreadOnly],
|
queryKey: ['timeline', unreadOnly, prId ?? null],
|
||||||
queryFn: ({ pageParam }) => fetchTimeline({ cursor: pageParam, unreadOnly }),
|
queryFn: ({ pageParam }) => fetchTimeline({ cursor: pageParam, unreadOnly, prId }),
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
getNextPageParam: (page) => page.nextCursor ?? undefined,
|
getNextPageParam: (page) => page.nextCursor ?? undefined,
|
||||||
});
|
});
|
||||||
@ -121,6 +129,7 @@ export default function Timeline() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const readAllMutation = useMutation({ mutationFn: markAllRead, onSettled: invalidate });
|
const readAllMutation = useMutation({ mutationFn: markAllRead, onSettled: invalidate });
|
||||||
|
const prReadMutation = useMutation({ mutationFn: markPrRead, onSettled: invalidate });
|
||||||
|
|
||||||
const events = query.data?.pages.flatMap((p) => p.events) ?? [];
|
const events = query.data?.pages.flatMap((p) => p.events) ?? [];
|
||||||
const groups = groupEvents(events);
|
const groups = groupEvents(events);
|
||||||
@ -137,8 +146,22 @@ export default function Timeline() {
|
|||||||
<button className={unreadOnly ? 'active' : ''} onClick={() => setUnreadOnly((v) => !v)}>
|
<button className={unreadOnly ? 'active' : ''} onClick={() => setUnreadOnly((v) => !v)}>
|
||||||
Unread only
|
Unread only
|
||||||
</button>
|
</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>
|
</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">
|
<div className="timeline-list">
|
||||||
{query.isLoading && <div className="empty-note">Loading…</div>}
|
{query.isLoading && <div className="empty-note">Loading…</div>}
|
||||||
{!query.isLoading && groups.length === 0 && (
|
{!query.isLoading && groups.length === 0 && (
|
||||||
@ -148,14 +171,16 @@ export default function Timeline() {
|
|||||||
<div className="tl-group" key={group.key}>
|
<div className="tl-group" key={group.key}>
|
||||||
<div className="tl-group-head">
|
<div className="tl-group-head">
|
||||||
<span className="actor">{group.actor}</span>
|
<span className="actor">{group.actor}</span>
|
||||||
<a
|
{!selectedPr && (
|
||||||
href={`https://github.com/${group.repo}/pull/${group.prNumber}`}
|
<a
|
||||||
target="_blank"
|
href={`https://github.com/${group.repo}/pull/${group.prNumber}`}
|
||||||
rel="noreferrer"
|
target="_blank"
|
||||||
title={group.prTitle}
|
rel="noreferrer"
|
||||||
>
|
title={group.prTitle}
|
||||||
{shortRepo(group.repo)}#{group.prNumber} · {group.prTitle}
|
>
|
||||||
</a>
|
{shortRepo(group.repo)}#{group.prNumber} · {group.prTitle}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
<span>{relativeTime(group.events[0]!.createdAt)}</span>
|
<span>{relativeTime(group.events[0]!.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
{group.events.map((e) => (
|
{group.events.map((e) => (
|
||||||
|
|||||||
@ -237,6 +237,16 @@ button {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 10px 12px;
|
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 {
|
.pr-card .title-row {
|
||||||
@ -530,6 +540,32 @@ a.check-row:hover {
|
|||||||
border-color: var(--ink);
|
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 {
|
.timeline-list {
|
||||||
padding: 0 8px 24px;
|
padding: 0 8px 24px;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user