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:
2026-05-05 15:19:49 +03:00
parent a71b4e6b84
commit a70fab4feb
11 changed files with 305 additions and 63 deletions

View File

@@ -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;

View File

@@ -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 />} />
<Routes>
<Route element={<Layout />}>
<Route index element={<DashPage />} />
<Route path="/dash" element={<DashPage />} />
<Route path="/activity" element={<TimelineHome />} />
<Route path="/cv" element={<CvPage />} />
</Routes>
<footer className="site-footer">
no cookies are set or read by this site, which is why no consent banner
is shown.
</footer>
</>
</Route>
</Routes>
);
}

View File

@@ -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();
}

View 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>
</>
);
}

View File

@@ -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
View 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;
}
}

View File

@@ -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>
</>
);
}