From 80f3f7c5cb84359cd9413c14d5a224a91b878070 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Tue, 5 May 2026 15:22:11 +0300 Subject: [PATCH] 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) --- crates/moments-api/src/main.rs | 3 ++ crates/moments-data/src/lib.rs | 7 ++++ crates/moments-entities/src/lib.rs | 2 ++ ui/src/App.tsx | 2 ++ ui/src/api/client.ts | 2 ++ ui/src/pages/DashPage.tsx | 28 +++------------- ui/src/pages/ProjectPage.tsx | 53 ++++++++++++++++++++++++++++++ 7 files changed, 73 insertions(+), 24 deletions(-) create mode 100644 ui/src/pages/ProjectPage.tsx diff --git a/crates/moments-api/src/main.rs b/crates/moments-api/src/main.rs index 89822d0..0bbf7cd 100644 --- a/crates/moments-api/src/main.rs +++ b/crates/moments-api/src/main.rs @@ -82,6 +82,8 @@ struct EventsQueryParams { to: Option>, /// Comma-separated list, e.g. `source=github,gitea`. source: Option, + /// Filter to a specific repo, e.g. `repo=grenade/moments`. + repo: Option, limit: Option, } @@ -101,6 +103,7 @@ async fn list_events( from: params.from, to: params.to, sources, + repo: params.repo, // Public timeline only — private events stay in the DB but are never // surfaced. A future authenticated path can flip this. include_private: false, diff --git a/crates/moments-data/src/lib.rs b/crates/moments-data/src/lib.rs index 36128ac..25ba137 100644 --- a/crates/moments-data/src/lib.rs +++ b/crates/moments-data/src/lib.rs @@ -54,6 +54,12 @@ impl EventReader for PgStore { AND ($2::timestamptz IS NULL OR occurred_at < $2) AND ($3::text[] IS NULL OR source = ANY($3)) AND ($4::bool OR public = true) + AND ($6::text IS NULL OR COALESCE( + payload->'repo'->>'name', + payload->'repository'->>'full_name', + payload->>'_repo', + payload->>'product' + ) = $6) ORDER BY occurred_at DESC LIMIT $5 "#, @@ -63,6 +69,7 @@ impl EventReader for PgStore { .bind(sources.as_deref()) .bind(query.include_private) .bind(query.limit as i64) + .bind(query.repo.as_deref()) .fetch_all(&self.pool) .await .map_err(map_err)?; diff --git a/crates/moments-entities/src/lib.rs b/crates/moments-entities/src/lib.rs index 54e5cef..6950133 100644 --- a/crates/moments-entities/src/lib.rs +++ b/crates/moments-entities/src/lib.rs @@ -67,6 +67,8 @@ pub struct EventQuery { pub from: Option>, pub to: Option>, pub sources: Option>, + /// Filter to events matching a specific repo (matched against payload). + pub repo: Option, /// When false (default), only `public = true` rows are returned. The API /// pins this to false today; a future authenticated path can flip it. pub include_private: bool, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 08ebffa..92ae5d0 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index cb21953..e5bc98f 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -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 { 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}`); diff --git a/ui/src/pages/DashPage.tsx b/ui/src/pages/DashPage.tsx index 5048e0f..f44138f 100644 --- a/ui/src/pages/DashPage.tsx +++ b/ui/src/pages/DashPage.tsx @@ -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() { {ranked.map((p) => ( +
-
- - {p.repo} - -
+
{p.repo}
{p.source}
{p.commit_count > 0 && {p.commit_count} commits} @@ -51,6 +45,7 @@ export function DashPage() { {formatRange(p.first_activity, p.last_activity)}
+ ))}
@@ -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(); diff --git a/ui/src/pages/ProjectPage.tsx b/ui/src/pages/ProjectPage.tsx new file mode 100644 index 0000000..e9406bd --- /dev/null +++ b/ui/src/pages/ProjectPage.tsx @@ -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 ( + <> + + +

{repo}

+ {source} + +
+ + +

+ {eventsQ.isLoading + ? 'loading...' + : eventsQ.isError + ? `error: ${(eventsQ.error as Error).message}` + : `${events.length} ${events.length === 1 ? 'activity' : 'activities'}`} +

+ + {events.map((item) => ( + + ))} + + +
+ + ); +}