From 27ce16e6307c3694c77510072d028cceb1c59ccd Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Tue, 5 May 2026 17:05:28 +0300 Subject: [PATCH] feat(ui): contribution graph with daily activity heatmap Add /v1/activity/daily endpoint returning per-day event counts via generate_series + LEFT JOIN. Frontend renders an SVG contribution graph with circles colored by quantile-based thresholds. Clicking a day navigates to /activity/YYYY-MM-DD showing that day's events. New /activity/:timespan route parses single dates (YYYY-MM-DD) and ranges (YYYY-MM-DD..YYYY-MM-DD) from the URL to initialize the activity timeline filter. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/moments-api/src/main.rs | 21 ++- crates/moments-core/src/lib.rs | 4 +- crates/moments-data/src/lib.rs | 32 ++++- crates/moments-entities/src/lib.rs | 7 + ui/src/App.css | 17 +++ ui/src/App.tsx | 1 + ui/src/api/client.ts | 11 ++ ui/src/components/ContributionGraph.tsx | 163 ++++++++++++++++++++++++ ui/src/pages/DashPage.tsx | 2 + ui/src/pages/TimelineHome.tsx | 20 +++ 10 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 ui/src/components/ContributionGraph.tsx diff --git a/crates/moments-api/src/main.rs b/crates/moments-api/src/main.rs index f20f091..0b24cd2 100644 --- a/crates/moments-api/src/main.rs +++ b/crates/moments-api/src/main.rs @@ -7,11 +7,11 @@ use axum::{ response::IntoResponse, routing::get, }; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, NaiveDate, Utc}; use clap::Parser; use moments_core::{EventReader, reshape}; use moments_data::PgStore; -use moments_entities::{EventQuery, ProjectSummary, Source, SourceSummary, TimelineItem}; +use moments_entities::{DailyCount, EventQuery, ProjectSummary, Source, SourceSummary, TimelineItem}; use serde::Deserialize; use tower_http::{cors::CorsLayer, trace::TraceLayer}; use tracing::info; @@ -56,6 +56,7 @@ async fn main() -> anyhow::Result<()> { .route("/v1/events", get(list_events)) .route("/v1/sources", get(list_sources)) .route("/v1/projects", get(list_projects)) + .route("/v1/activity/daily", get(daily_counts)) .route("/v1/forge/{source}/{*rest}", get(forge_proxy)) .with_state(state) .layer(TraceLayer::new_for_http()) @@ -139,6 +140,22 @@ async fn list_projects( Ok(Json(projects)) } +#[derive(Debug, Deserialize)] +struct DailyCountsParams { + from: Option, + to: Option, +} + +async fn daily_counts( + State(state): State, + Query(params): Query, +) -> Result>, ApiError> { + let to = params.to.unwrap_or_else(|| Utc::now().date_naive()); + let from = params.from.unwrap_or_else(|| to - chrono::Duration::days(365)); + let counts = state.store.daily_counts(from, to).await.map_err(internal)?; + Ok(Json(counts)) +} + /// Allowlisted forge hosts that the proxy may contact. const ALLOWED_HOSTS: &[&str] = &["api.github.com", "git.lair.cafe"]; diff --git a/crates/moments-core/src/lib.rs b/crates/moments-core/src/lib.rs index 183dd57..c42449c 100644 --- a/crates/moments-core/src/lib.rs +++ b/crates/moments-core/src/lib.rs @@ -5,7 +5,8 @@ pub use presentation::reshape; pub use sources::{EventSource, PollerState, PollerStateStore, SourceError, run_poller}; use async_trait::async_trait; -use moments_entities::{Event, EventQuery, ProjectSummary, SourceSummary}; +use chrono::NaiveDate; +use moments_entities::{DailyCount, Event, EventQuery, ProjectSummary, SourceSummary}; #[derive(Debug, thiserror::Error)] pub enum StoreError { @@ -19,6 +20,7 @@ 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>; + async fn daily_counts(&self, from: NaiveDate, to: NaiveDate) -> 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 1986ad4..0cb317f 100644 --- a/crates/moments-data/src/lib.rs +++ b/crates/moments-data/src/lib.rs @@ -8,7 +8,8 @@ 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, ProjectSummary, Source, SourceSummary}; +use chrono::NaiveDate; +use moments_entities::{DailyCount, Event, EventQuery, ProjectSummary, Source, SourceSummary}; use sqlx::Row; use sqlx::postgres::{PgPool, PgPoolOptions}; use std::str::FromStr; @@ -192,6 +193,35 @@ impl EventReader for PgStore { }) .collect() } + + async fn daily_counts(&self, from: NaiveDate, to: NaiveDate) -> Result, StoreError> { + let rows = sqlx::query( + r#" + SELECT d::date AS date, + COUNT(e.id)::bigint AS count + FROM generate_series($1::date, $2::date, '1 day') d + LEFT JOIN events e + ON e.occurred_at >= d AND e.occurred_at < d + interval '1 day' + AND e.public = true + GROUP BY d::date + ORDER BY d::date + "#, + ) + .bind(from) + .bind(to) + .fetch_all(&self.pool) + .await + .map_err(map_err)?; + + rows.into_iter() + .map(|r| { + Ok(DailyCount { + date: r.try_get("date").map_err(map_err)?, + count: r.try_get("count").map_err(map_err)?, + }) + }) + .collect() + } } #[async_trait] diff --git a/crates/moments-entities/src/lib.rs b/crates/moments-entities/src/lib.rs index 6950133..25b6191 100644 --- a/crates/moments-entities/src/lib.rs +++ b/crates/moments-entities/src/lib.rs @@ -84,6 +84,13 @@ pub struct SourceSummary { pub latest: Option>, } +/// Per-day event count for the contribution graph. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DailyCount { + pub date: chrono::NaiveDate, + pub count: i64, +} + /// Per-repo activity rollup for the dashboard. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProjectSummary { diff --git a/ui/src/App.css b/ui/src/App.css index 7609ec5..560fb38 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -77,6 +77,23 @@ a.hot-pink { opacity: 0.3; } +.graph-label { + fill: #ecf0f1; + font-size: 9px; + opacity: 0.6; +} + +.graph-cell { + cursor: pointer; + transition: opacity 0.15s; +} + +.graph-cell:hover { + opacity: 0.8; + stroke: #ecf0f1; + stroke-width: 1; +} + .project-card { background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 92ae5d0..3bf838c 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -18,6 +18,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 5b928da..9755b19 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -65,6 +65,11 @@ export interface ProjectSummary { last_activity: string | null; } +export interface DailyCount { + date: string; + count: number; +} + export interface EventQuery { from?: Date; to?: Date; @@ -102,6 +107,12 @@ export async function fetchSources(): Promise { return resp.json(); } +export async function fetchDailyCounts(from: string, to: string): Promise { + const resp = await fetch(`${API_BASE}/activity/daily?from=${from}&to=${to}`); + if (!resp.ok) throw new Error(`daily-counts: 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}`); diff --git a/ui/src/components/ContributionGraph.tsx b/ui/src/components/ContributionGraph.tsx new file mode 100644 index 0000000..ae00180 --- /dev/null +++ b/ui/src/components/ContributionGraph.tsx @@ -0,0 +1,163 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; + +import { fetchDailyCounts } from '../api/client'; + +const CELL_SIZE = 12; +const GAP = 3; +const RADIUS = CELL_SIZE / 2; +const ROWS = 7; // days per week +const LEFT_LABEL_WIDTH = 28; +const TOP_LABEL_HEIGHT = 16; + +const DAY_LABELS = ['', 'mon', '', 'wed', '', 'fri', '']; +const MONTH_LABELS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; + +const COLORS = [ + 'rgba(255,255,255,0.05)', // 0: empty + '#0e4429', // 1: low + '#006d32', // 2: medium-low + '#26a641', // 3: medium + '#39d353', // 4: high +]; + +export function ContributionGraph() { + const to = new Date(); + const from = new Date(to); + from.setFullYear(from.getFullYear() - 1); + + const fromStr = fmt(from); + const toStr = fmt(to); + + const dailyQ = useQuery({ + queryKey: ['daily-counts', fromStr, toStr], + queryFn: () => fetchDailyCounts(fromStr, toStr), + staleTime: 5 * 60_000, + }); + + const navigate = useNavigate(); + + const { weeks, monthMarkers, thresholds, totalCount } = useMemo(() => { + const counts = dailyQ.data ?? []; + const countMap = new Map(counts.map((d) => [d.date, d.count])); + + // Start from the Sunday before `from` + const start = new Date(from); + start.setDate(start.getDate() - start.getDay()); + + const weeks: { date: string; count: number; col: number; row: number }[][] = []; + const monthMarkers: { col: number; label: string }[] = []; + let col = 0; + let prevMonth = -1; + const cursor = new Date(start); + + while (cursor <= to) { + const week: typeof weeks[0] = []; + for (let row = 0; row < ROWS; row++) { + const dateStr = fmt(cursor); + const count = countMap.get(dateStr) ?? 0; + week.push({ date: dateStr, count, col, row }); + + // Track month transitions (on the first day of each week) + if (row === 0) { + const m = cursor.getMonth(); + if (m !== prevMonth) { + monthMarkers.push({ col, label: MONTH_LABELS[m] }); + prevMonth = m; + } + } + cursor.setDate(cursor.getDate() + 1); + } + weeks.push(week); + col++; + } + + // Compute quantile thresholds from non-zero counts + const nonZero = counts.map((d) => d.count).filter((c) => c > 0).sort((a, b) => a - b); + const thresholds = computeThresholds(nonZero); + const totalCount = counts.reduce((sum, d) => sum + d.count, 0); + + return { weeks, monthMarkers, thresholds, totalCount }; + }, [dailyQ.data]); + + const cols = weeks.length; + const svgWidth = LEFT_LABEL_WIDTH + cols * (CELL_SIZE + GAP); + const svgHeight = TOP_LABEL_HEIGHT + ROWS * (CELL_SIZE + GAP); + + if (dailyQ.isLoading) return

loading contribution graph...

; + if (dailyQ.isError) return null; + + return ( +
+

+ {totalCount} contributions in the last year +

+
+ + {/* Day-of-week labels */} + {DAY_LABELS.map((label, i) => + label ? ( + + {label} + + ) : null, + )} + {/* Month labels */} + {monthMarkers.map(({ col, label }, i) => ( + + {label} + + ))} + {/* Circles */} + {weeks.flatMap((week) => + week.map(({ date, count, col, row }) => ( + navigate(`/activity/${date}`)} + > + {`${date}: ${count} ${count === 1 ? 'contribution' : 'contributions'}`} + + )), + )} + +
+
+ ); +} + +function fmt(d: Date): string { + return d.toISOString().slice(0, 10); +} + +function colorFor(count: number, thresholds: number[]): string { + if (count === 0) return COLORS[0]; + if (count <= thresholds[0]) return COLORS[1]; + if (count <= thresholds[1]) return COLORS[2]; + if (count <= thresholds[2]) return COLORS[3]; + return COLORS[4]; +} + +function computeThresholds(sorted: number[]): number[] { + if (sorted.length === 0) return [1, 2, 3]; + const p = (pct: number) => sorted[Math.min(Math.floor(pct * sorted.length), sorted.length - 1)]; + return [p(0.25), p(0.5), p(0.75)]; +} diff --git a/ui/src/pages/DashPage.tsx b/ui/src/pages/DashPage.tsx index 58337db..fac4a4a 100644 --- a/ui/src/pages/DashPage.tsx +++ b/ui/src/pages/DashPage.tsx @@ -4,6 +4,7 @@ import Col from 'react-bootstrap/Col'; import Row from 'react-bootstrap/Row'; import { fetchProjects, fetchLanguages, type ProjectSummary, type Source } from '../api/client'; +import { ContributionGraph } from '../components/ContributionGraph'; export function DashPage() { const projectsQ = useQuery({ @@ -25,6 +26,7 @@ export function DashPage() {

+ {projectsQ.isLoading &&

loading...

} {projectsQ.isError && (

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

diff --git a/ui/src/pages/TimelineHome.tsx b/ui/src/pages/TimelineHome.tsx index eb824b6..b79b44b 100644 --- a/ui/src/pages/TimelineHome.tsx +++ b/ui/src/pages/TimelineHome.tsx @@ -1,4 +1,5 @@ import { useMemo, useState } from 'react'; +import { useParams } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import Col from 'react-bootstrap/Col'; import Row from 'react-bootstrap/Row'; @@ -11,7 +12,24 @@ import { TimelineEntry } from '../components/TimelineEntry'; const RANGE_MIN = new Date('2010-01-01T00:00:00Z').getTime(); const RANGE_MAX = Date.now(); +function parseTimespan(timespan?: string): [number, number] | null { + if (!timespan) return null; + if (timespan.includes('..')) { + const [a, b] = timespan.split('..'); + const from = new Date(a + 'T00:00:00Z').getTime(); + const to = new Date(b + 'T23:59:59Z').getTime(); + if (!isNaN(from) && !isNaN(to)) return [from, to]; + } else { + const from = new Date(timespan + 'T00:00:00Z').getTime(); + const to = new Date(timespan + 'T23:59:59Z').getTime(); + if (!isNaN(from)) return [from, to]; + } + return null; +} + export function TimelineHome() { + const { timespan } = useParams(); + const [enabledSources, setEnabledSources] = useState>({ github: true, gitea: true, @@ -19,6 +37,8 @@ export function TimelineHome() { bugzilla: true, }); const [rangeValue, setRangeValue] = useState<[number, number]>(() => { + const parsed = parseTimespan(timespan); + if (parsed) return parsed; const now = Date.now(); const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000; return [thirtyDaysAgo, now];