feat(ui): project drill-down route with repo-filtered event timeline

Add repo filter param to /v1/events (SQL COALESCE across payload
shapes per source). New /project/:source/* route renders a filtered
activity timeline for a single repo. Dashboard cards link to the
drill-down page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 15:22:11 +03:00
parent a70fab4feb
commit 80f3f7c5cb
7 changed files with 73 additions and 24 deletions

View File

@@ -8,6 +8,7 @@ import './App.css';
import { Layout } from './components/Layout';
import { DashPage } from './pages/DashPage';
import { TimelineHome } from './pages/TimelineHome';
import { ProjectPage } from './pages/ProjectPage';
import { CvPage } from './pages/CvPage';
export default function App() {
@@ -17,6 +18,7 @@ export default function App() {
<Route index element={<DashPage />} />
<Route path="/dash" element={<DashPage />} />
<Route path="/activity" element={<TimelineHome />} />
<Route path="/project/:source/*" element={<ProjectPage />} />
<Route path="/cv" element={<CvPage />} />
</Route>
</Routes>

View File

@@ -69,6 +69,7 @@ export interface EventQuery {
from?: Date;
to?: Date;
sources?: Source[];
repo?: string;
limit?: number;
}
@@ -81,6 +82,7 @@ export async function fetchEvents(q: EventQuery): Promise<TimelineItem[]> {
if (q.sources && q.sources.length > 0) {
params.set('source', q.sources.join(','));
}
if (q.repo) params.set('repo', q.repo);
if (q.limit) params.set('limit', String(q.limit));
const resp = await fetch(`${API_BASE}/events?${params}`);

View File

@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import Col from 'react-bootstrap/Col';
import Row from 'react-bootstrap/Row';
@@ -31,16 +32,9 @@ export function DashPage() {
<Row xs={1} md={2} lg={3} className="g-3">
{ranked.map((p) => (
<Col key={`${p.source}:${p.repo}`}>
<Link to={`/project/${p.source}/${p.repo}`} className="text-decoration-none">
<div className="project-card p-3">
<h5 className="mb-1">
<a
href={repoUrl(p)}
target="_blank"
rel="noopener noreferrer"
>
{p.repo}
</a>
</h5>
<h5 className="mb-1">{p.repo}</h5>
<small className="text-muted d-block mb-2">{p.source}</small>
<div className="d-flex gap-3" style={{ fontSize: '0.8rem' }}>
{p.commit_count > 0 && <span>{p.commit_count} commits</span>}
@@ -51,6 +45,7 @@ export function DashPage() {
{formatRange(p.first_activity, p.last_activity)}
</div>
</div>
</Link>
</Col>
))}
</Row>
@@ -58,21 +53,6 @@ export function DashPage() {
);
}
function repoUrl(p: ProjectSummary): string {
switch (p.source) {
case 'github':
return `https://github.com/${p.repo}`;
case 'gitea':
return `https://${p.host}/${p.repo}`;
case 'hg':
return `https://${p.host}/${p.repo}`;
case 'bugzilla':
return `https://${p.host}`;
default:
return '#';
}
}
function formatRange(first: string | null, last: string | null): string {
const fmt = (iso: string) =>
new Date(iso).toLocaleDateString('en-GB', { month: 'short', year: 'numeric' }).toLowerCase();

View File

@@ -0,0 +1,53 @@
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import Col from 'react-bootstrap/Col';
import Row from 'react-bootstrap/Row';
import { VerticalTimeline } from 'react-vertical-timeline-component';
import { fetchEvents, type Source } from '../api/client';
import { TimelineEntry } from '../components/TimelineEntry';
export function ProjectPage() {
const { source, '*': repoPath } = useParams();
const repo = repoPath ?? '';
const eventsQ = useQuery({
queryKey: ['project-events', source, repo],
queryFn: () =>
fetchEvents({
sources: source ? [source as Source] : undefined,
repo,
limit: 500,
}),
refetchInterval: 60_000,
});
const events = eventsQ.data ?? [];
return (
<>
<Row className="mb-3">
<Col>
<h2>{repo}</h2>
<small className="text-muted">{source}</small>
</Col>
</Row>
<Row>
<Col>
<p style={{ fontSize: '85%' }}>
{eventsQ.isLoading
? 'loading...'
: eventsQ.isError
? `error: ${(eventsQ.error as Error).message}`
: `${events.length} ${events.length === 1 ? 'activity' : 'activities'}`}
</p>
<VerticalTimeline>
{events.map((item) => (
<TimelineEntry key={item.id} item={item} />
))}
</VerticalTimeline>
</Col>
</Row>
</>
);
}