Author filter chips composable with repo/unread filters; 'mine' pinned first

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-29 09:38:17 +00:00
parent 2015d20aa5
commit 5aa6f3367d
5 changed files with 75 additions and 16 deletions

View File

@ -40,7 +40,7 @@ function scheduleNext(): void {
}, intervalMs); }, intervalMs);
} }
const app = createApp({ db, triggerSync: poll }); const app = createApp({ db, user: config.user, triggerSync: poll });
app.listen(config.port, config.host, () => { app.listen(config.port, config.host, () => {
console.log(`pr-monitor listening on http://${config.host}:${config.port}`); console.log(`pr-monitor listening on http://${config.host}:${config.port}`);
}); });

View File

@ -9,6 +9,7 @@ import { buildFailureReport } from './failures.js';
export interface RouteDeps { export interface RouteDeps {
db: Db; db: Db;
user: string;
triggerSync: () => Promise<void>; triggerSync: () => Promise<void>;
} }
@ -22,13 +23,14 @@ export function buildSyncPayload(db: Db, changedPrIds: string[] = [], newEventCo
}; };
} }
export function createApp({ db, triggerSync }: RouteDeps): Express { export function createApp({ db, user, triggerSync }: RouteDeps): Express {
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.get('/api/state', (_req, res) => { app.get('/api/state', (_req, res) => {
const { byPr, total } = db.unreadCounts(); const { byPr, total } = db.unreadCounts();
const payload: StatePayload = { const payload: StatePayload = {
user,
prs: db.getOpenSnapshots(), prs: db.getOpenSnapshots(),
lastSyncAt: db.getMeta('last_sync_at'), lastSyncAt: db.getMeta('last_sync_at'),
lastSyncError: db.getMeta('last_sync_error'), lastSyncError: db.getMeta('last_sync_error'),

View File

@ -88,6 +88,8 @@ export interface FailureReport extends FailureReportSummary {
} }
export interface StatePayload { export interface StatePayload {
/** The configured GitHub login this dashboard belongs to. */
user: string;
prs: PrSnapshot[]; prs: PrSnapshot[];
lastSyncAt: string | null; lastSyncAt: string | null;
lastSyncError: string | null; lastSyncError: string | null;

View File

@ -3,8 +3,6 @@ import type { StatePayload } from '../../../shared/types';
import PrCard from './PrCard'; import PrCard from './PrCard';
import { shortRepo } from '../format'; import { shortRepo } from '../format';
type Filter = 'all' | 'unread' | string;
export default function PrList({ export default function PrList({
state, state,
selectedPrId, selectedPrId,
@ -14,15 +12,30 @@ export default function PrList({
selectedPrId: string | null; selectedPrId: string | null;
onSelect: (id: string) => void; onSelect: (id: string) => void;
}) { }) {
const [filter, setFilter] = useState<Filter>('all'); const [unreadOnly, setUnreadOnly] = useState(false);
const [repoFilter, setRepoFilter] = useState<string | null>(null);
const [authorFilter, setAuthorFilter] = useState<string | null>(null);
const repos = [...new Set(state.prs.map((p) => p.repo))]; const repos = [...new Set(state.prs.map((p) => p.repo))];
const filtered = state.prs.filter((pr) => {
if (filter === 'all') return true; const authorCounts = new Map<string, number>();
if (filter === 'unread') return (state.unreadCountsByPr[pr.id] ?? 0) > 0; for (const pr of state.prs) {
return pr.repo === filter; authorCounts.set(pr.author, (authorCounts.get(pr.author) ?? 0) + 1);
}
// "mine" first, then by open-PR count.
const authors = [...authorCounts.entries()].sort((a, b) => {
if (a[0] === state.user) return -1;
if (b[0] === state.user) return 1;
return b[1] - a[1] || a[0].localeCompare(b[0]);
}); });
const filtered = state.prs.filter(
(pr) =>
(!unreadOnly || (state.unreadCountsByPr[pr.id] ?? 0) > 0) &&
(!repoFilter || pr.repo === repoFilter) &&
(!authorFilter || pr.author === authorFilter),
);
const byRepo = new Map<string, typeof filtered>(); const byRepo = new Map<string, typeof filtered>();
for (const pr of filtered) { for (const pr of filtered) {
if (!byRepo.has(pr.repo)) byRepo.set(pr.repo, []); if (!byRepo.has(pr.repo)) byRepo.set(pr.repo, []);
@ -32,19 +45,42 @@ export default function PrList({
return ( return (
<> <>
<div className="filter-tabs"> <div className="filter-tabs">
<button className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}> <button
className={!unreadOnly && !repoFilter && !authorFilter ? 'active' : ''}
onClick={() => {
setUnreadOnly(false);
setRepoFilter(null);
setAuthorFilter(null);
}}
>
All ({state.prs.length}) All ({state.prs.length})
</button> </button>
<button className={filter === 'unread' ? 'active' : ''} onClick={() => setFilter('unread')}> <button
className={unreadOnly ? 'active' : ''}
onClick={() => setUnreadOnly((v) => !v)}
>
Unread{state.unreadTotal > 0 ? ` (${state.unreadTotal})` : ''} Unread{state.unreadTotal > 0 ? ` (${state.unreadTotal})` : ''}
</button> </button>
{repos.map((repo) => ( {repos.length > 1 && <span className="filter-sep" />}
{repos.length > 1 &&
repos.map((repo) => (
<button
key={repo}
className={repoFilter === repo ? 'active' : ''}
onClick={() => setRepoFilter((cur) => (cur === repo ? null : repo))}
>
{shortRepo(repo)}
</button>
))}
</div>
<div className="filter-tabs filter-authors">
{authors.map(([author, count]) => (
<button <button
key={repo} key={author}
className={filter === repo ? 'active' : ''} className={authorFilter === author ? 'active' : ''}
onClick={() => setFilter(repo)} onClick={() => setAuthorFilter((cur) => (cur === author ? null : author))}
> >
{shortRepo(repo)} {author === state.user ? 'mine' : author} ({count})
</button> </button>
))} ))}
</div> </div>

View File

@ -214,6 +214,25 @@ button {
border-color: var(--ink); border-color: var(--ink);
} }
.filter-sep {
width: 1px;
align-self: stretch;
background: var(--hairline);
margin: 2px 2px;
}
.filter-authors {
padding-top: 0;
}
.filter-authors button {
border-style: dashed;
}
.filter-authors button.active {
border-style: solid;
}
/* ---------- PR cards ---------- */ /* ---------- PR cards ---------- */
.pr-list { .pr-list {