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:
@@ -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"];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user