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) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 17:05:28 +03:00
parent 7de23303bd
commit 27ce16e630
10 changed files with 274 additions and 4 deletions

View File

@@ -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<NaiveDate>,
to: Option<NaiveDate>,
}
async fn daily_counts(
State(state): State<AppState>,
Query(params): Query<DailyCountsParams>,
) -> Result<Json<Vec<DailyCount>>, 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"];

View File

@@ -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<Vec<Event>, StoreError>;
async fn source_summaries(&self, include_private: bool) -> Result<Vec<SourceSummary>, StoreError>;
async fn list_projects(&self) -> Result<Vec<ProjectSummary>, StoreError>;
async fn daily_counts(&self, from: NaiveDate, to: NaiveDate) -> Result<Vec<DailyCount>, StoreError>;
}
/// Write-side port consumed by `moments-worker`. Idempotent upserts on `id`.

View File

@@ -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<Vec<DailyCount>, 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]

View File

@@ -84,6 +84,13 @@ pub struct SourceSummary {
pub latest: Option<DateTime<Utc>>,
}
/// 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 {