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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<AppState>,
|
||||
) -> Result<Json<Vec<ProjectSummary>>, ApiError> {
|
||||
let projects = state.store.list_projects().await.map_err(internal)?;
|
||||
Ok(Json(projects))
|
||||
}
|
||||
|
||||
fn parse_sources(raw: &str) -> Result<Vec<Source>, ApiError> {
|
||||
raw.split(',')
|
||||
.map(str::trim)
|
||||
|
||||
@@ -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<Vec<Event>, StoreError>;
|
||||
async fn source_summaries(&self, include_private: bool) -> Result<Vec<SourceSummary>, StoreError>;
|
||||
async fn list_projects(&self) -> Result<Vec<ProjectSummary>, StoreError>;
|
||||
}
|
||||
|
||||
/// Write-side port consumed by `moments-worker`. Idempotent upserts on `id`.
|
||||
|
||||
@@ -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<Vec<ProjectSummary>, 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::<i64, _>("commit_count").map_err(map_err).unwrap_or(0),
|
||||
issue_count: r.try_get::<i64, _>("issue_count").map_err(map_err).unwrap_or(0),
|
||||
pr_count: r.try_get::<i64, _>("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]
|
||||
|
||||
@@ -82,6 +82,19 @@ pub struct SourceSummary {
|
||||
pub latest: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 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<DateTime<Utc>>,
|
||||
pub last_activity: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Presentation shape — what `GET /v1/events` actually returns.
|
||||
// The API reshapes raw payloads into these so the frontend stays dumb.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Routes>
|
||||
<Route path="/" element={<TimelineHome />} />
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<DashPage />} />
|
||||
<Route path="/dash" element={<DashPage />} />
|
||||
<Route path="/activity" element={<TimelineHome />} />
|
||||
<Route path="/cv" element={<CvPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<footer className="site-footer">
|
||||
no cookies are set or read by this site, which is why no consent banner
|
||||
is shown.
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<SourceSummary[]> {
|
||||
if (!resp.ok) throw new Error(`sources: HTTP ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function fetchProjects(): Promise<ProjectSummary[]> {
|
||||
const resp = await fetch(`${API_BASE}/projects`);
|
||||
if (!resp.ok) throw new Error(`projects: HTTP ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
42
ui/src/components/Layout.tsx
Normal file
42
ui/src/components/Layout.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<Container className="py-4">
|
||||
<header className="site-header d-flex flex-wrap justify-content-between align-items-center mb-4">
|
||||
<h1 className="mb-0">hi, i'm rob</h1>
|
||||
<nav className="d-flex flex-wrap gap-3 align-items-center">
|
||||
<NavLink to="/" end>dash</NavLink>
|
||||
<NavLink to="/activity">activity</NavLink>
|
||||
<NavLink to="/cv">cv</NavLink>
|
||||
<span className="nav-divider">|</span>
|
||||
{externalLinks.map((el) => (
|
||||
<a
|
||||
key={el.url}
|
||||
href={el.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{el.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
<Outlet />
|
||||
</Container>
|
||||
<footer className="site-footer">
|
||||
no cookies are set or read by this site, which is why no consent banner
|
||||
is shown.
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Container className="py-4">
|
||||
<>
|
||||
<CvHeader />
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<Spinner animation="border" role="status" size="sm" />
|
||||
<span>loading cv…</span>
|
||||
</div>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +49,7 @@ export function CvPage() {
|
||||
? ' (github limits unauthenticated requests to 60/hour per ip — try again shortly)'
|
||||
: '';
|
||||
return (
|
||||
<Container className="py-4">
|
||||
<>
|
||||
<CvHeader />
|
||||
<Alert variant="danger">
|
||||
<Alert.Heading>cv unavailable</Alert.Heading>
|
||||
@@ -62,7 +61,7 @@ export function CvPage() {
|
||||
retry
|
||||
</button>
|
||||
</Alert>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,15 +71,15 @@ export function CvPage() {
|
||||
|
||||
if (bodySections.length === 0 && navSections.length === 0) {
|
||||
return (
|
||||
<Container className="py-4">
|
||||
<>
|
||||
<CvHeader />
|
||||
<Alert variant="warning">cv unavailable: no sections in config</Alert>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="py-4">
|
||||
<>
|
||||
<CvHeader />
|
||||
<Row>
|
||||
<Col lg={9} className="cv-body">
|
||||
@@ -103,6 +102,6 @@ export function CvPage() {
|
||||
<CvTimeline data={data} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
102
ui/src/pages/DashPage.tsx
Normal file
102
ui/src/pages/DashPage.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<Row className="mb-3">
|
||||
<Col>
|
||||
<p>
|
||||
i rarely say anything that warrants capital letters. a peek into the
|
||||
projects i'm working on is below.
|
||||
</p>
|
||||
</Col>
|
||||
</Row>
|
||||
{projectsQ.isLoading && <p>loading...</p>}
|
||||
{projectsQ.isError && (
|
||||
<p>error: {(projectsQ.error as Error).message}</p>
|
||||
)}
|
||||
<Row xs={1} md={2} lg={3} className="g-3">
|
||||
{ranked.map((p) => (
|
||||
<Col key={`${p.source}:${p.repo}`}>
|
||||
<div className="project-card p-3">
|
||||
<h5 className="mb-1">
|
||||
<a
|
||||
href={repoUrl(p)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{p.repo}
|
||||
</a>
|
||||
</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>}
|
||||
{p.issue_count > 0 && <span>{p.issue_count} issues</span>}
|
||||
{p.pr_count > 0 && <span>{p.pr_count} prs</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', opacity: 0.6, marginTop: '0.25rem' }}>
|
||||
{formatRange(p.first_activity, p.last_activity)}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<Record<Source, boolean>>({
|
||||
github: true,
|
||||
@@ -61,38 +52,7 @@ export function TimelineHome() {
|
||||
const events = eventsQ.data ?? [];
|
||||
|
||||
return (
|
||||
<Container className="py-4">
|
||||
<Row className="mb-3">
|
||||
<Col>
|
||||
<h1>hi, i'm rob</h1>
|
||||
</Col>
|
||||
<Col className="d-flex flex-wrap gap-3 justify-content-end align-items-center">
|
||||
{externalLinks.map((el) => (
|
||||
<a
|
||||
key={el.url}
|
||||
href={el.url}
|
||||
title={el.alt}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{el.alt}
|
||||
</a>
|
||||
))}
|
||||
</Col>
|
||||
</Row>
|
||||
<Row className="mb-4">
|
||||
<Col>
|
||||
<p>
|
||||
i rarely say anything that warrants capital letters. if you're here
|
||||
to see my resume, please go to{' '}
|
||||
<Link className="hot-pink" to="/cv">
|
||||
/cv
|
||||
</Link>
|
||||
. a peek into the projects i'm working on is below.
|
||||
</p>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<>
|
||||
<Filters
|
||||
enabledSources={enabledSources}
|
||||
onSourceToggle={(s, on) =>
|
||||
@@ -123,6 +83,6 @@ export function TimelineHome() {
|
||||
</VerticalTimeline>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user