From a70fab4feb9c194e76482633ca4eb0d74b84e228 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Tue, 5 May 2026 15:19:49 +0300 Subject: [PATCH] feat(ui): add /dash route, shared nav, project dashboard with /v1/projects API Restructure routes: / and /dash show a project overview dashboard, /activity hosts the existing timeline, /cv remains. Shared Layout component provides consistent nav header and footer across all routes. New /v1/projects endpoint aggregates per-repo activity stats (commits, issues, PRs, date range) from existing event data via SQL. Dashboard ranks projects by weighted recency + volume score and renders a card grid. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/moments-api/src/main.rs | 10 ++- crates/moments-core/src/lib.rs | 3 +- crates/moments-data/src/lib.rs | 58 +++++++++++++++- crates/moments-entities/src/lib.rs | 13 ++++ ui/src/App.css | 44 +++++++++++++ ui/src/App.tsx | 18 ++--- ui/src/api/client.ts | 17 +++++ ui/src/components/Layout.tsx | 42 ++++++++++++ ui/src/pages/CvPage.tsx | 17 +++-- ui/src/pages/DashPage.tsx | 102 +++++++++++++++++++++++++++++ ui/src/pages/TimelineHome.tsx | 44 +------------ 11 files changed, 305 insertions(+), 63 deletions(-) create mode 100644 ui/src/components/Layout.tsx create mode 100644 ui/src/pages/DashPage.tsx diff --git a/crates/moments-api/src/main.rs b/crates/moments-api/src/main.rs index 6d7d4eb..89822d0 100644 --- a/crates/moments-api/src/main.rs +++ b/crates/moments-api/src/main.rs @@ -11,7 +11,7 @@ use chrono::{DateTime, Utc}; use clap::Parser; use moments_core::{EventReader, reshape}; use moments_data::PgStore; -use moments_entities::{EventQuery, Source, SourceSummary, TimelineItem}; +use moments_entities::{EventQuery, ProjectSummary, Source, SourceSummary, TimelineItem}; use serde::Deserialize; use tower_http::{cors::CorsLayer, trace::TraceLayer}; use tracing::info; @@ -50,6 +50,7 @@ async fn main() -> anyhow::Result<()> { .route("/v1/healthz", get(healthz)) .route("/v1/events", get(list_events)) .route("/v1/sources", get(list_sources)) + .route("/v1/projects", get(list_projects)) .with_state(state) .layer(TraceLayer::new_for_http()) .layer(CorsLayer::permissive()); @@ -122,6 +123,13 @@ async fn list_sources( Ok(Json(summaries)) } +async fn list_projects( + State(state): State, +) -> Result>, ApiError> { + let projects = state.store.list_projects().await.map_err(internal)?; + Ok(Json(projects)) +} + fn parse_sources(raw: &str) -> Result, ApiError> { raw.split(',') .map(str::trim) diff --git a/crates/moments-core/src/lib.rs b/crates/moments-core/src/lib.rs index 7cf96fe..183dd57 100644 --- a/crates/moments-core/src/lib.rs +++ b/crates/moments-core/src/lib.rs @@ -5,7 +5,7 @@ pub use presentation::reshape; pub use sources::{EventSource, PollerState, PollerStateStore, SourceError, run_poller}; use async_trait::async_trait; -use moments_entities::{Event, EventQuery, SourceSummary}; +use moments_entities::{Event, EventQuery, ProjectSummary, SourceSummary}; #[derive(Debug, thiserror::Error)] pub enum StoreError { @@ -18,6 +18,7 @@ pub enum StoreError { pub trait EventReader: Send + Sync { async fn list_events(&self, query: &EventQuery) -> Result, StoreError>; async fn source_summaries(&self, include_private: bool) -> Result, StoreError>; + async fn list_projects(&self) -> Result, StoreError>; } /// Write-side port consumed by `moments-worker`. Idempotent upserts on `id`. diff --git a/crates/moments-data/src/lib.rs b/crates/moments-data/src/lib.rs index 8ca0671..36128ac 100644 --- a/crates/moments-data/src/lib.rs +++ b/crates/moments-data/src/lib.rs @@ -8,7 +8,7 @@ pub mod hg; use async_trait::async_trait; use chrono::{DateTime, Utc}; use moments_core::{EventReader, EventWriter, PollerState, PollerStateStore, StoreError}; -use moments_entities::{Event, EventQuery, Source, SourceSummary}; +use moments_entities::{Event, EventQuery, ProjectSummary, Source, SourceSummary}; use sqlx::Row; use sqlx::postgres::{PgPool, PgPoolOptions}; use std::str::FromStr; @@ -115,6 +115,62 @@ impl EventReader for PgStore { }) .collect() } + + async fn list_projects(&self) -> Result, StoreError> { + let rows = sqlx::query( + r#" + SELECT source, repo, host, + SUM(commits)::bigint AS commit_count, + SUM(issues)::bigint AS issue_count, + SUM(prs)::bigint AS pr_count, + MIN(occurred_at) AS first_activity, + MAX(occurred_at) AS last_activity + FROM ( + SELECT source, occurred_at, + COALESCE( + payload->'repo'->>'name', + payload->'repository'->>'full_name', + payload->>'_repo', + payload->>'product' + ) AS repo, + CASE source + WHEN 'github' THEN 'github.com' + WHEN 'gitea' THEN COALESCE(payload->>'_host', 'git.lair.cafe') + WHEN 'hg' THEN COALESCE(payload->>'_host', 'hg-edge.mozilla.org') + WHEN 'bugzilla' THEN 'bugzilla.mozilla.org' + ELSE 'unknown' + END AS host, + CASE WHEN action IN ('Commit', 'PushEvent', 'commit_repo') THEN 1 ELSE 0 END AS commits, + CASE WHEN action IN ('Issue', 'IssuesEvent') THEN 1 ELSE 0 END AS issues, + CASE WHEN action IN ('PullRequest', 'PullRequestEvent') THEN 1 ELSE 0 END AS prs + FROM events + WHERE public = true + ) sub + WHERE repo IS NOT NULL AND repo != '' + GROUP BY source, repo, host + ORDER BY MAX(occurred_at) DESC + "#, + ) + .fetch_all(&self.pool) + .await + .map_err(map_err)?; + + rows.into_iter() + .map(|r| { + let source_str: String = r.try_get("source").map_err(map_err)?; + Ok(ProjectSummary { + source: Source::from_str(&source_str).map_err(map_err)?, + repo: r.try_get("repo").map_err(map_err)?, + host: r.try_get("host").map_err(map_err)?, + commit_count: r.try_get::("commit_count").map_err(map_err).unwrap_or(0), + issue_count: r.try_get::("issue_count").map_err(map_err).unwrap_or(0), + pr_count: r.try_get::("pr_count").map_err(map_err).unwrap_or(0), + first_activity: r.try_get("first_activity").map_err(map_err)?, + last_activity: r.try_get("last_activity").map_err(map_err)?, + }) + }) + .collect() + } } #[async_trait] diff --git a/crates/moments-entities/src/lib.rs b/crates/moments-entities/src/lib.rs index ed433cb..54e5cef 100644 --- a/crates/moments-entities/src/lib.rs +++ b/crates/moments-entities/src/lib.rs @@ -82,6 +82,19 @@ pub struct SourceSummary { pub latest: Option>, } +/// Per-repo activity rollup for the dashboard. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectSummary { + pub repo: String, + pub source: Source, + pub host: String, + pub commit_count: i64, + pub issue_count: i64, + pub pr_count: i64, + pub first_activity: Option>, + pub last_activity: Option>, +} + // --------------------------------------------------------------------- // Presentation shape — what `GET /v1/events` actually returns. // The API reshapes raw payloads into these so the frontend stays dumb. diff --git a/ui/src/App.css b/ui/src/App.css index 63e6c01..4ee7f9b 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -53,6 +53,50 @@ a.hot-pink { color: #1565c0; } +.site-header h1 { + font-size: 1.75rem; +} + +.site-header nav a { + color: #ecf0f1; + opacity: 0.7; +} + +.site-header nav a:hover { + color: #ff4081; + opacity: 1; + text-decoration: none; +} + +.site-header nav a.active { + color: #ff4081; + opacity: 1; +} + +.nav-divider { + opacity: 0.3; +} + +.project-card { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + height: 100%; +} + +.project-card h5 { + font-size: 0.9rem; +} + +.project-card a { + color: #ff4081; +} + +.project-card .text-muted { + color: rgba(236, 240, 241, 0.5) !important; + font-size: 0.7rem; +} + .site-footer { margin-top: 3rem; padding: 1rem 0; diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 51e0841..08ebffa 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -5,20 +5,20 @@ import 'rc-slider/assets/index.css'; import 'react-vertical-timeline-component/style.min.css'; import './App.css'; +import { Layout } from './components/Layout'; +import { DashPage } from './pages/DashPage'; import { TimelineHome } from './pages/TimelineHome'; import { CvPage } from './pages/CvPage'; export default function App() { return ( - <> - - } /> + + }> + } /> + } /> + } /> } /> - -
- no cookies are set or read by this site, which is why no consent banner - is shown. -
- +
+
); } diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 0b55d02..cb21953 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -54,6 +54,17 @@ export interface SourceSummary { latest: string | null; } +export interface ProjectSummary { + repo: string; + source: Source; + host: string; + commit_count: number; + issue_count: number; + pr_count: number; + first_activity: string | null; + last_activity: string | null; +} + export interface EventQuery { from?: Date; to?: Date; @@ -82,3 +93,9 @@ export async function fetchSources(): Promise { if (!resp.ok) throw new Error(`sources: HTTP ${resp.status}`); return resp.json(); } + +export async function fetchProjects(): Promise { + const resp = await fetch(`${API_BASE}/projects`); + if (!resp.ok) throw new Error(`projects: HTTP ${resp.status}`); + return resp.json(); +} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx new file mode 100644 index 0000000..42cf682 --- /dev/null +++ b/ui/src/components/Layout.tsx @@ -0,0 +1,42 @@ +import { NavLink, Outlet } from 'react-router-dom'; +import Container from 'react-bootstrap/Container'; + +const externalLinks = [ + { url: 'https://linkedin.com/in/thijssen/', label: 'linkedin' }, + { url: 'https://stackoverflow.com/users/68115/grenade', label: 'stackoverflow' }, + { url: 'https://github.com/grenade', label: 'github' }, + { url: 'https://git.lair.cafe/grenade', label: 'gitea' }, +]; + +export function Layout() { + return ( + <> + +
+

hi, i'm rob

+ +
+ +
+
+ no cookies are set or read by this site, which is why no consent banner + is shown. +
+ + ); +} diff --git a/ui/src/pages/CvPage.tsx b/ui/src/pages/CvPage.tsx index ba3479e..a169332 100644 --- a/ui/src/pages/CvPage.tsx +++ b/ui/src/pages/CvPage.tsx @@ -3,7 +3,6 @@ import { useLocation } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import Alert from 'react-bootstrap/Alert'; import Col from 'react-bootstrap/Col'; -import Container from 'react-bootstrap/Container'; import Row from 'react-bootstrap/Row'; import Spinner from 'react-bootstrap/Spinner'; @@ -34,13 +33,13 @@ export function CvPage() { if (cvQ.isLoading) { return ( - + <>
loading cv…
-
+ ); } @@ -50,7 +49,7 @@ export function CvPage() { ? ' (github limits unauthenticated requests to 60/hour per ip — try again shortly)' : ''; return ( - + <> cv unavailable @@ -62,7 +61,7 @@ export function CvPage() { retry - + ); } @@ -72,15 +71,15 @@ export function CvPage() { if (bodySections.length === 0 && navSections.length === 0) { return ( - + <> cv unavailable: no sections in config - + ); } return ( - + <> @@ -103,6 +102,6 @@ export function CvPage() { - + ); } diff --git a/ui/src/pages/DashPage.tsx b/ui/src/pages/DashPage.tsx new file mode 100644 index 0000000..5048e0f --- /dev/null +++ b/ui/src/pages/DashPage.tsx @@ -0,0 +1,102 @@ +import { useQuery } from '@tanstack/react-query'; +import Col from 'react-bootstrap/Col'; +import Row from 'react-bootstrap/Row'; + +import { fetchProjects, type ProjectSummary } from '../api/client'; + +export function DashPage() { + const projectsQ = useQuery({ + queryKey: ['projects'], + queryFn: fetchProjects, + refetchInterval: 60_000, + }); + + const projects = projectsQ.data ?? []; + const ranked = rankProjects(projects).slice(0, 24); + + return ( + <> + + +

+ i rarely say anything that warrants capital letters. a peek into the + projects i'm working on is below. +

+ +
+ {projectsQ.isLoading &&

loading...

} + {projectsQ.isError && ( +

error: {(projectsQ.error as Error).message}

+ )} + + {ranked.map((p) => ( + +
+
+ + {p.repo} + +
+ {p.source} +
+ {p.commit_count > 0 && {p.commit_count} commits} + {p.issue_count > 0 && {p.issue_count} issues} + {p.pr_count > 0 && {p.pr_count} prs} +
+
+ {formatRange(p.first_activity, p.last_activity)} +
+
+ + ))} +
+ + ); +} + +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(); + if (first && last) return `${fmt(first)} — ${fmt(last)}`; + if (last) return fmt(last); + return ''; +} + +function rankProjects(projects: ProjectSummary[]): ProjectSummary[] { + if (projects.length === 0) return []; + const now = Date.now(); + const maxVolume = Math.max(...projects.map((p) => p.commit_count + p.issue_count + p.pr_count)); + const oldest = Math.min( + ...projects.map((p) => (p.last_activity ? new Date(p.last_activity).getTime() : 0)), + ); + const range = now - oldest || 1; + + return [...projects].sort((a, b) => score(b) - score(a)); + + function score(p: ProjectSummary): number { + const volume = (p.commit_count + p.issue_count + p.pr_count) / (maxVolume || 1); + const recency = p.last_activity + ? (new Date(p.last_activity).getTime() - oldest) / range + : 0; + return 0.6 * recency + 0.4 * volume; + } +} diff --git a/ui/src/pages/TimelineHome.tsx b/ui/src/pages/TimelineHome.tsx index 1d1f339..eb824b6 100644 --- a/ui/src/pages/TimelineHome.tsx +++ b/ui/src/pages/TimelineHome.tsx @@ -1,8 +1,6 @@ import { useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Link } from 'react-router-dom'; import Col from 'react-bootstrap/Col'; -import Container from 'react-bootstrap/Container'; import Row from 'react-bootstrap/Row'; import { VerticalTimeline } from 'react-vertical-timeline-component'; @@ -13,13 +11,6 @@ import { TimelineEntry } from '../components/TimelineEntry'; const RANGE_MIN = new Date('2010-01-01T00:00:00Z').getTime(); const RANGE_MAX = Date.now(); -const externalLinks: { url: string; alt: string }[] = [ - { url: 'https://linkedin.com/in/thijssen/', alt: 'linkedin' }, - { url: 'https://stackoverflow.com/users/68115/grenade', alt: 'stackoverflow' }, - { url: 'https://github.com/grenade', alt: 'github' }, - { url: 'https://git.lair.cafe/grenade', alt: 'gitea' }, -]; - export function TimelineHome() { const [enabledSources, setEnabledSources] = useState>({ github: true, @@ -61,38 +52,7 @@ export function TimelineHome() { const events = eventsQ.data ?? []; return ( - - - -

hi, i'm rob

- - - {externalLinks.map((el) => ( - - {el.alt} - - ))} - -
- - -

- i rarely say anything that warrants capital letters. if you're here - to see my resume, please go to{' '} - - /cv - - . a peek into the projects i'm working on is below. -

- -
- + <> @@ -123,6 +83,6 @@ export function TimelineHome() { -
+ ); }