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);
|
}, 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}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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'),
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user