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
+
+
+
+
+
+ );
+}
+
+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];