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:
parent
2015d20aa5
commit
5aa6f3367d
@ -40,7 +40,7 @@ function scheduleNext(): void {
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
const app = createApp({ db, triggerSync: poll });
|
||||
const app = createApp({ db, user: config.user, triggerSync: poll });
|
||||
app.listen(config.port, config.host, () => {
|
||||
console.log(`pr-monitor listening on http://${config.host}:${config.port}`);
|
||||
});
|
||||
|
||||
@ -9,6 +9,7 @@ import { buildFailureReport } from './failures.js';
|
||||
|
||||
export interface RouteDeps {
|
||||
db: Db;
|
||||
user: string;
|
||||
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();
|
||||
app.use(express.json());
|
||||
|
||||
app.get('/api/state', (_req, res) => {
|
||||
const { byPr, total } = db.unreadCounts();
|
||||
const payload: StatePayload = {
|
||||
user,
|
||||
prs: db.getOpenSnapshots(),
|
||||
lastSyncAt: db.getMeta('last_sync_at'),
|
||||
lastSyncError: db.getMeta('last_sync_error'),
|
||||
|
||||
@ -88,6 +88,8 @@ export interface FailureReport extends FailureReportSummary {
|
||||
}
|
||||
|
||||
export interface StatePayload {
|
||||
/** The configured GitHub login this dashboard belongs to. */
|
||||
user: string;
|
||||
prs: PrSnapshot[];
|
||||
lastSyncAt: string | null;
|
||||
lastSyncError: string | null;
|
||||
|
||||
@ -3,8 +3,6 @@ import type { StatePayload } from '../../../shared/types';
|
||||
import PrCard from './PrCard';
|
||||
import { shortRepo } from '../format';
|
||||
|
||||
type Filter = 'all' | 'unread' | string;
|
||||
|
||||
export default function PrList({
|
||||
state,
|
||||
selectedPrId,
|
||||
@ -14,15 +12,30 @@ export default function PrList({
|
||||
selectedPrId: string | null;
|
||||
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 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 authorCounts = new Map<string, number>();
|
||||
for (const pr of state.prs) {
|
||||
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>();
|
||||
for (const pr of filtered) {
|
||||
if (!byRepo.has(pr.repo)) byRepo.set(pr.repo, []);
|
||||
@ -32,19 +45,42 @@ export default function PrList({
|
||||
return (
|
||||
<>
|
||||
<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})
|
||||
</button>
|
||||
<button className={filter === 'unread' ? 'active' : ''} onClick={() => setFilter('unread')}>
|
||||
<button
|
||||
className={unreadOnly ? 'active' : ''}
|
||||
onClick={() => setUnreadOnly((v) => !v)}
|
||||
>
|
||||
Unread{state.unreadTotal > 0 ? ` (${state.unreadTotal})` : ''}
|
||||
</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
|
||||
key={repo}
|
||||
className={filter === repo ? 'active' : ''}
|
||||
onClick={() => setFilter(repo)}
|
||||
key={author}
|
||||
className={authorFilter === author ? 'active' : ''}
|
||||
onClick={() => setAuthorFilter((cur) => (cur === author ? null : author))}
|
||||
>
|
||||
{shortRepo(repo)}
|
||||
{author === state.user ? 'mine' : author} ({count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -214,6 +214,25 @@ button {
|
||||
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-list {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user