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

@@ -11,7 +11,7 @@ use chrono::{DateTime, Utc};
use clap::Parser;
use moments_core::{EventReader, reshape};
use moments_data::PgStore;
use moments_entities::{EventQuery, Source, SourceSummary, TimelineItem};
use moments_entities::{EventQuery, ProjectSummary, Source, SourceSummary, TimelineItem};
use serde::Deserialize;
use tower_http::{cors::CorsLayer, trace::TraceLayer};
use tracing::info;
@@ -50,6 +50,7 @@ async fn main() -> anyhow::Result<()> {
.route("/v1/healthz", get(healthz))
.route("/v1/events", get(list_events))
.route("/v1/sources", get(list_sources))
.route("/v1/projects", get(list_projects))
.with_state(state)
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive());
@@ -122,6 +123,13 @@ async fn list_sources(
Ok(Json(summaries))
}
async fn list_projects(
State(state): State<AppState>,
) -> Result<Json<Vec<ProjectSummary>>, ApiError> {
let projects = state.store.list_projects().await.map_err(internal)?;
Ok(Json(projects))
}
fn parse_sources(raw: &str) -> Result<Vec<Source>, ApiError> {
raw.split(',')
.map(str::trim)

View File

@@ -5,7 +5,7 @@ pub use presentation::reshape;
pub use sources::{EventSource, PollerState, PollerStateStore, SourceError, run_poller};
use async_trait::async_trait;
use moments_entities::{Event, EventQuery, SourceSummary};
use moments_entities::{Event, EventQuery, ProjectSummary, SourceSummary};
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
@@ -18,6 +18,7 @@ pub enum StoreError {
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>;
}
/// Write-side port consumed by `moments-worker`. Idempotent upserts on `id`.

View File

@@ -8,7 +8,7 @@ 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, Source, SourceSummary};
use moments_entities::{Event, EventQuery, ProjectSummary, Source, SourceSummary};
use sqlx::Row;
use sqlx::postgres::{PgPool, PgPoolOptions};
use std::str::FromStr;
@@ -115,6 +115,62 @@ impl EventReader for PgStore {
})
.collect()
}
async fn list_projects(&self) -> Result<Vec<ProjectSummary>, StoreError> {
let rows = sqlx::query(
r#"
SELECT source, repo, host,
SUM(commits)::bigint AS commit_count,
SUM(issues)::bigint AS issue_count,
SUM(prs)::bigint AS pr_count,
MIN(occurred_at) AS first_activity,
MAX(occurred_at) AS last_activity
FROM (
SELECT source, occurred_at,
COALESCE(
payload->'repo'->>'name',
payload->'repository'->>'full_name',
payload->>'_repo',
payload->>'product'
) AS repo,
CASE source
WHEN 'github' THEN 'github.com'
WHEN 'gitea' THEN COALESCE(payload->>'_host', 'git.lair.cafe')
WHEN 'hg' THEN COALESCE(payload->>'_host', 'hg-edge.mozilla.org')
WHEN 'bugzilla' THEN 'bugzilla.mozilla.org'
ELSE 'unknown'
END AS host,
CASE WHEN action IN ('Commit', 'PushEvent', 'commit_repo') THEN 1 ELSE 0 END AS commits,
CASE WHEN action IN ('Issue', 'IssuesEvent') THEN 1 ELSE 0 END AS issues,
CASE WHEN action IN ('PullRequest', 'PullRequestEvent') THEN 1 ELSE 0 END AS prs
FROM events
WHERE public = true
) sub
WHERE repo IS NOT NULL AND repo != ''
GROUP BY source, repo, host
ORDER BY MAX(occurred_at) DESC
"#,
)
.fetch_all(&self.pool)
.await
.map_err(map_err)?;
rows.into_iter()
.map(|r| {
let source_str: String = r.try_get("source").map_err(map_err)?;
Ok(ProjectSummary {
source: Source::from_str(&source_str).map_err(map_err)?,
repo: r.try_get("repo").map_err(map_err)?,
host: r.try_get("host").map_err(map_err)?,
commit_count: r.try_get::<i64, _>("commit_count").map_err(map_err).unwrap_or(0),
issue_count: r.try_get::<i64, _>("issue_count").map_err(map_err).unwrap_or(0),
pr_count: r.try_get::<i64, _>("pr_count").map_err(map_err).unwrap_or(0),
first_activity: r.try_get("first_activity").map_err(map_err)?,
last_activity: r.try_get("last_activity").map_err(map_err)?,
})
})
.collect()
}
}
#[async_trait]

View File

@@ -82,6 +82,19 @@ pub struct SourceSummary {
pub latest: Option<DateTime<Utc>>,
}
/// Per-repo activity rollup for the dashboard.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectSummary {
pub repo: String,
pub source: Source,
pub host: String,
pub commit_count: i64,
pub issue_count: i64,
pub pr_count: i64,
pub first_activity: Option<DateTime<Utc>>,
pub last_activity: Option<DateTime<Utc>>,
}
// ---------------------------------------------------------------------
// Presentation shape — what `GET /v1/events` actually returns.
// The API reshapes raw payloads into these so the frontend stays dumb.