feat: language stream graph on dashboard

Full-stack feature showing programming languages by commit activity
as a stream graph on the dashboard.

Backend:
- migration: repo_languages table (source, repo, language, bytes, color)
- worker: fetch language breakdowns via GitHub GraphQL (batched,
  20 repos/request) and Gitea REST API during poll cycles
- API: GET /v1/languages/daily (daily commit counts per language),
  GET /v1/languages/repos (all stored repo language data)
- fix timezone bug in daily_counts and language_daily_counts: the
  PostgreSQL server timezone (Europe/Sofia, UTC+3) shifted day
  boundaries, miscounting events near midnight. Now uses explicit
  UTC boundaries in generate_series JOINs.
- use per-source CASE for repo name extraction in language query
  to match gitea payload structure (repo.full_name vs repo.name)
- Gitea languages use GitHub colors via COALESCE fallback

Frontend:
- LanguageStreamGraph component: pure SVG stream graph, weekly
  buckets, centered baseline, top 8 languages + Other, GitHub
  canonical language colors, legend with color dots
- DashPage/ProjectPage: fetch repo languages once via new endpoint
  instead of per-repo forge proxy calls (eliminates 200+ GitHub
  API calls and 403 rate limit errors)
- removed fetchLanguages forge proxy wrapper (dead code)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 06:27:59 +03:00
parent c66aaeb268
commit ee93429317
12 changed files with 604 additions and 63 deletions

View File

@@ -11,7 +11,7 @@ use chrono::{DateTime, Datelike, NaiveDate, Utc};
use clap::Parser; use clap::Parser;
use moments_core::{EventReader, reshape}; use moments_core::{EventReader, reshape};
use moments_data::PgStore; use moments_data::PgStore;
use moments_entities::{DailyCount, EventQuery, ProjectSummary, Source, SourceSummary, TimelineItem}; use moments_entities::{DailyCount, EventQuery, LanguageDailyCount, ProjectSummary, RepoLanguage, Source, SourceSummary, TimelineItem};
use serde::Deserialize; use serde::Deserialize;
use tower_http::{cors::CorsLayer, trace::TraceLayer}; use tower_http::{cors::CorsLayer, trace::TraceLayer};
use tracing::info; use tracing::info;
@@ -57,6 +57,8 @@ async fn main() -> anyhow::Result<()> {
.route("/v1/sources", get(list_sources)) .route("/v1/sources", get(list_sources))
.route("/v1/projects", get(list_projects)) .route("/v1/projects", get(list_projects))
.route("/v1/activity/daily", get(daily_counts)) .route("/v1/activity/daily", get(daily_counts))
.route("/v1/languages/daily", get(language_daily_counts))
.route("/v1/languages/repos", get(repo_languages))
.route("/v1/forge/{source}/{*rest}", get(forge_proxy)) .route("/v1/forge/{source}/{*rest}", get(forge_proxy))
.route("/v1/og/contributions.png", get(og_contributions)) .route("/v1/og/contributions.png", get(og_contributions))
.with_state(state) .with_state(state)
@@ -157,6 +159,23 @@ async fn daily_counts(
Ok(Json(counts)) Ok(Json(counts))
} }
async fn language_daily_counts(
State(state): State<AppState>,
Query(params): Query<DailyCountsParams>,
) -> Result<Json<Vec<LanguageDailyCount>>, 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.language_daily_counts(from, to).await.map_err(internal)?;
Ok(Json(counts))
}
async fn repo_languages(
State(state): State<AppState>,
) -> Result<Json<Vec<RepoLanguage>>, ApiError> {
let langs = state.store.repo_languages().await.map_err(internal)?;
Ok(Json(langs))
}
async fn og_contributions( async fn og_contributions(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {

View File

@@ -6,7 +6,7 @@ pub use sources::{EventSource, PollerState, PollerStateStore, SourceError, run_p
use async_trait::async_trait; use async_trait::async_trait;
use chrono::NaiveDate; use chrono::NaiveDate;
use moments_entities::{DailyCount, Event, EventQuery, ProjectSummary, SourceSummary}; use moments_entities::{DailyCount, Event, EventQuery, LanguageDailyCount, ProjectSummary, RepoLanguage, SourceSummary};
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum StoreError { pub enum StoreError {
@@ -21,10 +21,13 @@ pub trait EventReader: Send + Sync {
async fn source_summaries(&self, include_private: bool) -> Result<Vec<SourceSummary>, StoreError>; async fn source_summaries(&self, include_private: bool) -> Result<Vec<SourceSummary>, StoreError>;
async fn list_projects(&self) -> Result<Vec<ProjectSummary>, StoreError>; async fn list_projects(&self) -> Result<Vec<ProjectSummary>, StoreError>;
async fn daily_counts(&self, from: NaiveDate, to: NaiveDate) -> Result<Vec<DailyCount>, StoreError>; async fn daily_counts(&self, from: NaiveDate, to: NaiveDate) -> Result<Vec<DailyCount>, StoreError>;
async fn language_daily_counts(&self, from: NaiveDate, to: NaiveDate) -> Result<Vec<LanguageDailyCount>, StoreError>;
async fn repo_languages(&self) -> Result<Vec<RepoLanguage>, StoreError>;
} }
/// Write-side port consumed by `moments-worker`. Idempotent upserts on `id`. /// Write-side port consumed by `moments-worker`. Idempotent upserts on `id`.
#[async_trait] #[async_trait]
pub trait EventWriter: Send + Sync { pub trait EventWriter: Send + Sync {
async fn upsert_events(&self, events: &[Event]) -> Result<usize, StoreError>; async fn upsert_events(&self, events: &[Event]) -> Result<usize, StoreError>;
async fn upsert_repo_languages(&self, languages: &[RepoLanguage]) -> Result<usize, StoreError>;
} }

View File

@@ -0,0 +1,9 @@
CREATE TABLE repo_languages (
source TEXT NOT NULL,
repo TEXT NOT NULL,
language TEXT NOT NULL,
bytes BIGINT NOT NULL,
color TEXT,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (source, repo, language)
);

View File

@@ -9,12 +9,13 @@
//! Each item carries a self-contained payload — including the event-emitting //! Each item carries a self-contained payload — including the event-emitting
//! host — so the reshape layer can construct URLs without needing config. //! host — so the reshape layer can construct URLs without needing config.
use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use moments_core::{EventSource, EventWriter, PollerStateStore, SourceError}; use moments_core::{EventSource, EventWriter, PollerStateStore, SourceError};
use moments_entities::{Event, Source}; use moments_entities::{Event, RepoLanguage, Source};
use reqwest::{Client, header}; use reqwest::{Client, header};
use serde_json::Value; use serde_json::Value;
use tracing::debug; use tracing::debug;
@@ -126,17 +127,19 @@ impl GiteaSource {
/// for org feeds which contain all members' activity). /// for org feeds which contain all members' activity).
/// ///
/// `base_url` should contain everything except the `&page=N` suffix. /// `base_url` should contain everything except the `&page=N` suffix.
/// Returns (ingested_count, set_of_repo_full_names).
async fn poll_feed( async fn poll_feed(
&self, &self,
state_key: &str, state_key: &str,
base_url: &str, base_url: &str,
filter_user: bool, filter_user: bool,
) -> Result<usize, SourceError> { ) -> Result<(usize, HashSet<String>), SourceError> {
let prior = self.state.load(state_key).await?; let prior = self.state.load(state_key).await?;
let first_run = prior.is_none(); let first_run = prior.is_none();
let max_pages = if first_run { MAX_BACKFILL_PAGES } else { 1 }; let max_pages = if first_run { MAX_BACKFILL_PAGES } else { 1 };
let mut total = 0usize; let mut total = 0usize;
let mut repos = HashSet::new();
for page in 1..=max_pages { for page in 1..=max_pages {
let url = format!("{base_url}&page={page}"); let url = format!("{base_url}&page={page}");
let req = self.apply_headers(self.client.get(&url)); let req = self.apply_headers(self.client.get(&url));
@@ -155,6 +158,17 @@ impl GiteaSource {
break; break;
} }
// Collect repo names from feed items
for item in &items {
if let Some(name) = item
.get("repo")
.and_then(|r| r.get("full_name"))
.and_then(Value::as_str)
{
repos.insert(name.to_string());
}
}
let events: Vec<Event> = items let events: Vec<Event> = items
.iter() .iter()
.filter(|it| { .filter(|it| {
@@ -177,6 +191,44 @@ impl GiteaSource {
} }
self.state.touch(state_key).await?; self.state.touch(state_key).await?;
Ok((total, repos))
}
/// Fetch language breakdowns for the given repos via the Gitea REST API.
async fn fetch_languages(&self, repos: &HashSet<String>) -> Result<usize, SourceError> {
let mut total = 0usize;
for repo in repos {
let url = format!(
"https://{}/api/v1/repos/{}/languages",
self.config.host, repo
);
let req = self.apply_headers(self.client.get(&url));
let resp = req
.send()
.await
.map_err(|e| SourceError::Http(e.to_string()))?;
if !resp.status().is_success() {
tracing::warn!(repo = %repo, status = %resp.status(), "gitea language fetch failed; skipping");
continue;
}
let lang_map: std::collections::HashMap<String, i64> = resp
.json()
.await
.map_err(|e| SourceError::Parse(e.to_string()))?;
let languages: Vec<RepoLanguage> = lang_map
.into_iter()
.map(|(language, bytes)| RepoLanguage {
source: Source::Gitea,
repo: repo.clone(),
language,
bytes,
color: None, // Gitea doesn't return colors
})
.collect();
total += self.writer.upsert_repo_languages(&languages).await?;
}
debug!(total, repos = repos.len(), "gitea repo languages updated");
Ok(total) Ok(total)
} }
} }
@@ -188,9 +240,12 @@ impl EventSource for GiteaSource {
} }
async fn poll(&self) -> Result<usize, SourceError> { async fn poll(&self) -> Result<usize, SourceError> {
let mut all_repos = HashSet::new();
// Poll user's own activity feed (existing behavior). // Poll user's own activity feed (existing behavior).
let user_url = self.user_feed_base_url(); let user_url = self.user_feed_base_url();
let mut total = self.poll_feed(SOURCE_NAME, &user_url, false).await?; let (mut total, repos) = self.poll_feed(SOURCE_NAME, &user_url, false).await?;
all_repos.extend(repos);
// Discover orgs and poll each org's activity feed, filtering for // Discover orgs and poll each org's activity feed, filtering for
// events performed by this user. // events performed by this user.
@@ -199,13 +254,20 @@ impl EventSource for GiteaSource {
let state_key = format!("gitea:org:{org}"); let state_key = format!("gitea:org:{org}");
let org_url = self.org_feed_base_url(org); let org_url = self.org_feed_base_url(org);
match self.poll_feed(&state_key, &org_url, true).await { match self.poll_feed(&state_key, &org_url, true).await {
Ok(n) => total += n, Ok((n, repos)) => {
total += n;
all_repos.extend(repos);
}
Err(e) => { Err(e) => {
tracing::warn!(org = %org, error = %e, "failed to poll org feed"); tracing::warn!(org = %org, error = %e, "failed to poll org feed");
} }
} }
} }
if let Err(e) = self.fetch_languages(&all_repos).await {
tracing::warn!(error = %e, "gitea language fetch failed; continuing");
}
debug!(ingested = total, orgs = orgs.len(), "gitea poll complete"); debug!(ingested = total, orgs = orgs.len(), "gitea poll complete");
Ok(total) Ok(total)
} }

View File

@@ -20,7 +20,7 @@ use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use moments_core::{EventSource, EventWriter, PollerStateStore, SourceError}; use moments_core::{EventSource, EventWriter, PollerStateStore, SourceError};
use moments_entities::{Event, Source}; use moments_entities::{Event, RepoLanguage, Source};
use reqwest::{Client, header}; use reqwest::{Client, header};
use serde_json::Value; use serde_json::Value;
use tracing::{debug, warn}; use tracing::{debug, warn};
@@ -296,6 +296,105 @@ impl GithubRepoSource {
self.state.save(&state_key, None, newest).await?; self.state.save(&state_key, None, newest).await?;
Ok(total) Ok(total)
} }
/// Batch-fetch language breakdowns for repos via GraphQL, upserting
/// into repo_languages. Repos are batched using GraphQL aliases to
/// minimise round trips.
async fn fetch_languages(&self, repos: &[Repo]) -> Result<usize, SourceError> {
let token = match &self.config.token {
Some(t) => t,
None => return Ok(0),
};
let mut total = 0usize;
for chunk in repos.chunks(20) {
let mut fragments = Vec::with_capacity(chunk.len());
for (i, repo) in chunk.iter().enumerate() {
let parts: Vec<&str> = repo.full_name.splitn(2, '/').collect();
if parts.len() != 2 {
continue;
}
fragments.push(format!(
r#"r{i}: repository(owner: "{}", name: "{}") {{ languages(first: 20, orderBy: {{field: SIZE, direction: DESC}}) {{ edges {{ size node {{ name color }} }} }} }}"#,
parts[0], parts[1]
));
}
if fragments.is_empty() {
continue;
}
let query = format!("{{ {} }}", fragments.join(" "));
let body = serde_json::json!({ "query": query });
let resp = self
.client
.post("https://api.github.com/graphql")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::USER_AGENT, USER_AGENT)
.header(header::CONTENT_TYPE, "application/json")
.json(&body)
.send()
.await
.map_err(|e| SourceError::Http(e.to_string()))?;
if !resp.status().is_success() {
warn!(status = %resp.status(), "GraphQL language fetch failed");
break;
}
let data: Value = resp
.json()
.await
.map_err(|e| SourceError::Parse(e.to_string()))?;
if let Some(errors) = data.get("errors").and_then(Value::as_array) {
if let Some(msg) = errors.first().and_then(|e| e.get("message")).and_then(Value::as_str) {
warn!(error = %msg, "GraphQL language fetch had errors");
}
}
let data_obj = match data.get("data") {
Some(d) => d,
None => continue,
};
let mut languages = Vec::new();
for (i, repo) in chunk.iter().enumerate() {
let alias = format!("r{i}");
let edges = data_obj
.get(&alias)
.and_then(|r| r.get("languages"))
.and_then(|l| l.get("edges"))
.and_then(Value::as_array);
if let Some(edges) = edges {
for edge in edges {
let size = edge.get("size").and_then(Value::as_i64).unwrap_or(0);
let name = edge
.get("node")
.and_then(|n| n.get("name"))
.and_then(Value::as_str);
let color = edge
.get("node")
.and_then(|n| n.get("color"))
.and_then(Value::as_str);
if let Some(name) = name {
languages.push(RepoLanguage {
source: Source::Github,
repo: repo.full_name.clone(),
language: name.to_string(),
bytes: size,
color: color.map(String::from),
});
}
}
}
}
total += self.writer.upsert_repo_languages(&languages).await?;
}
debug!(total, "repo languages updated");
Ok(total)
}
} }
#[async_trait] #[async_trait]
@@ -327,6 +426,10 @@ impl EventSource for GithubRepoSource {
} }
} }
if let Err(e) = self.fetch_languages(&repos).await {
warn!(error = %e, "language fetch failed; continuing");
}
self.state.touch(SOURCE_NAME).await?; self.state.touch(SOURCE_NAME).await?;
debug!(ingested = total, repos = repos.len(), "github-repo poll complete"); debug!(ingested = total, repos = repos.len(), "github-repo poll complete");
Ok(total) Ok(total)

View File

@@ -248,6 +248,12 @@ mod tests {
) -> Result<usize, moments_core::StoreError> { ) -> Result<usize, moments_core::StoreError> {
Ok(0) Ok(0)
} }
async fn upsert_repo_languages(
&self,
_languages: &[moments_entities::RepoLanguage],
) -> Result<usize, moments_core::StoreError> {
Ok(0)
}
} }
struct NoopState; struct NoopState;
#[async_trait] #[async_trait]

View File

@@ -9,7 +9,7 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use moments_core::{EventReader, EventWriter, PollerState, PollerStateStore, StoreError}; use moments_core::{EventReader, EventWriter, PollerState, PollerStateStore, StoreError};
use chrono::NaiveDate; use chrono::NaiveDate;
use moments_entities::{DailyCount, Event, EventQuery, ProjectSummary, Source, SourceSummary}; use moments_entities::{DailyCount, Event, EventQuery, LanguageDailyCount, ProjectSummary, RepoLanguage, Source, SourceSummary};
use sqlx::Row; use sqlx::Row;
use sqlx::postgres::{PgPool, PgPoolOptions}; use sqlx::postgres::{PgPool, PgPoolOptions};
use std::str::FromStr; use std::str::FromStr;
@@ -203,7 +203,8 @@ impl EventReader for PgStore {
COUNT(e.id)::bigint AS count COUNT(e.id)::bigint AS count
FROM generate_series($1::date, $2::date, '1 day') d FROM generate_series($1::date, $2::date, '1 day') d
LEFT JOIN events e LEFT JOIN events e
ON e.occurred_at >= d AND e.occurred_at < d + interval '1 day' ON e.occurred_at >= (d::date || 'T00:00:00Z')::timestamptz
AND e.occurred_at < ((d::date + 1) || 'T00:00:00Z')::timestamptz
AND e.public = true AND e.public = true
GROUP BY d::date GROUP BY d::date
ORDER BY d::date ORDER BY d::date
@@ -224,6 +225,90 @@ impl EventReader for PgStore {
}) })
.collect() .collect()
} }
async fn language_daily_counts(&self, from: NaiveDate, to: NaiveDate) -> Result<Vec<LanguageDailyCount>, StoreError> {
let rows = sqlx::query(
r#"
SELECT d::date AS date,
rl.language,
COALESCE(MAX(rl.color),
(SELECT color FROM repo_languages
WHERE language = rl.language AND color IS NOT NULL
LIMIT 1)
) AS color,
COUNT(e.id)::bigint AS commits
FROM generate_series($1::date, $2::date, '1 day') d
JOIN events e
ON e.occurred_at >= (d::date || 'T00:00:00Z')::timestamptz
AND e.occurred_at < ((d::date + 1) || 'T00:00:00Z')::timestamptz
AND e.public = true
AND e.action IN ('Commit', 'PushEvent', 'commit_repo')
JOIN repo_languages rl
ON rl.source = e.source
AND rl.repo = CASE e.source
WHEN 'github' THEN COALESCE(
e.payload->'repo'->>'name',
e.payload->'repository'->>'full_name',
e.payload->>'_repo'
)
WHEN 'gitea' THEN COALESCE(
e.payload->'repo'->>'full_name',
e.payload->'repo'->>'name'
)
ELSE NULL
END
GROUP BY d::date, rl.language
ORDER BY d::date, commits DESC
"#,
)
.bind(from)
.bind(to)
.fetch_all(&self.pool)
.await
.map_err(map_err)?;
rows.into_iter()
.map(|r| {
Ok(LanguageDailyCount {
date: r.try_get("date").map_err(map_err)?,
language: r.try_get("language").map_err(map_err)?,
color: r.try_get("color").map_err(map_err)?,
commits: r.try_get("commits").map_err(map_err)?,
})
})
.collect()
}
async fn repo_languages(&self) -> Result<Vec<RepoLanguage>, StoreError> {
let rows = sqlx::query(
r#"
SELECT source, repo, language, bytes,
COALESCE(color,
(SELECT color FROM repo_languages r2
WHERE r2.language = repo_languages.language AND r2.color IS NOT NULL
LIMIT 1)
) AS color
FROM repo_languages
ORDER BY repo, bytes 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(RepoLanguage {
source: Source::from_str(&source_str).map_err(map_err)?,
repo: r.try_get("repo").map_err(map_err)?,
language: r.try_get("language").map_err(map_err)?,
bytes: r.try_get("bytes").map_err(map_err)?,
color: r.try_get("color").map_err(map_err)?,
})
})
.collect()
}
} }
#[async_trait] #[async_trait]
@@ -331,4 +416,37 @@ impl EventWriter for PgStore {
tx.commit().await.map_err(map_err)?; tx.commit().await.map_err(map_err)?;
Ok(inserted) Ok(inserted)
} }
async fn upsert_repo_languages(&self, languages: &[RepoLanguage]) -> Result<usize, StoreError> {
if languages.is_empty() {
return Ok(0);
}
let mut tx = self.pool.begin().await.map_err(map_err)?;
let mut count = 0usize;
for lang in languages {
let n = sqlx::query(
r#"
INSERT INTO repo_languages (source, repo, language, bytes, color, fetched_at)
VALUES ($1, $2, $3, $4, $5, now())
ON CONFLICT (source, repo, language) DO UPDATE
SET bytes = EXCLUDED.bytes,
color = EXCLUDED.color,
fetched_at = EXCLUDED.fetched_at
"#,
)
.bind(lang.source.as_str())
.bind(&lang.repo)
.bind(&lang.language)
.bind(lang.bytes)
.bind(&lang.color)
.execute(&mut *tx)
.await
.map_err(map_err)?
.rows_affected();
count += n as usize;
}
tx.commit().await.map_err(map_err)?;
Ok(count)
}
} }

View File

@@ -104,6 +104,25 @@ pub struct ProjectSummary {
pub last_activity: Option<DateTime<Utc>>, pub last_activity: Option<DateTime<Utc>>,
} }
/// Per-language daily commit count for the language stream graph.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LanguageDailyCount {
pub date: chrono::NaiveDate,
pub language: String,
pub color: Option<String>,
pub commits: i64,
}
/// Per-repo language breakdown from the forge.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoLanguage {
pub source: Source,
pub repo: String,
pub language: String,
pub bytes: i64,
pub color: Option<String>,
}
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Presentation shape — what `GET /v1/events` actually returns. // Presentation shape — what `GET /v1/events` actually returns.
// The API reshapes raw payloads into these so the frontend stays dumb. // The API reshapes raw payloads into these so the frontend stays dumb.

View File

@@ -70,6 +70,13 @@ export interface DailyCount {
count: number; count: number;
} }
export interface LanguageDailyCount {
date: string;
language: string;
color: string | null;
commits: number;
}
export interface EventQuery { export interface EventQuery {
from?: Date; from?: Date;
to?: Date; to?: Date;
@@ -113,6 +120,26 @@ export async function fetchDailyCounts(from: string, to: string): Promise<DailyC
return resp.json(); return resp.json();
} }
export async function fetchLanguageDailyCounts(from: string, to: string): Promise<LanguageDailyCount[]> {
const resp = await fetch(`${API_BASE}/languages/daily?from=${from}&to=${to}`);
if (!resp.ok) throw new Error(`language-daily-counts: HTTP ${resp.status}`);
return resp.json();
}
export interface RepoLanguageEntry {
source: Source;
repo: string;
language: string;
bytes: number;
color: string | null;
}
export async function fetchRepoLanguages(): Promise<RepoLanguageEntry[]> {
const resp = await fetch(`${API_BASE}/languages/repos`);
if (!resp.ok) throw new Error(`repo-languages: HTTP ${resp.status}`);
return resp.json();
}
export async function fetchProjects(): Promise<ProjectSummary[]> { export async function fetchProjects(): Promise<ProjectSummary[]> {
const resp = await fetch(`${API_BASE}/projects`); const resp = await fetch(`${API_BASE}/projects`);
if (!resp.ok) throw new Error(`projects: HTTP ${resp.status}`); if (!resp.ok) throw new Error(`projects: HTTP ${resp.status}`);
@@ -144,12 +171,3 @@ export async function fetchReadme(source: Source, host: string, repo: string): P
} }
return null; return null;
} }
/** Fetch repo languages as { language: bytes } map via the forge proxy. */
export async function fetchLanguages(source: Source, host: string, repo: string): Promise<Record<string, number> | null> {
if (source !== 'github' && source !== 'gitea') return null;
const hostParam = source === 'gitea' ? `?host=${encodeURIComponent(host)}` : '';
const resp = await fetch(`${API_BASE}/forge/${source}/repos/${repo}/languages${hostParam}`);
if (!resp.ok) return null;
return resp.json();
}

View File

@@ -0,0 +1,178 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { fetchLanguageDailyCounts } from '../api/client';
const HEIGHT = 160;
const LABEL_HEIGHT = 16;
/** Language stream graph — stacked area showing language usage over time. */
export function LanguageStreamGraph() {
const to = new Date();
const from = new Date(to);
from.setFullYear(from.getFullYear() - 1);
const fromStr = fmt(from);
const toStr = fmt(to);
const langQ = useQuery({
queryKey: ['language-daily', fromStr, toStr],
queryFn: () => fetchLanguageDailyCounts(fromStr, toStr),
staleTime: 5 * 60_000,
});
const { languages, paths, legendItems } = useMemo(() => {
const raw = langQ.data ?? [];
if (raw.length === 0)
return { weeks: [], languages: [], paths: [], legendItems: [] };
// Aggregate daily counts into weekly buckets
const colorMap = new Map<string, string>();
const weeklyMap = new Map<string, Map<string, number>>();
for (const d of raw) {
if (d.color) colorMap.set(d.language, d.color);
// Bucket to ISO week (Monday-based, keyed by Monday date)
const dt = new Date(d.date + 'T00:00:00Z');
const day = dt.getUTCDay();
const monday = new Date(dt);
monday.setUTCDate(monday.getUTCDate() - ((day + 6) % 7));
const weekKey = monday.toISOString().slice(0, 10);
if (!weeklyMap.has(weekKey)) weeklyMap.set(weekKey, new Map());
const langs = weeklyMap.get(weekKey)!;
langs.set(d.language, (langs.get(d.language) ?? 0) + d.commits);
}
const weeks = [...weeklyMap.keys()].sort();
// Rank languages by total commits to pick top N + "other"
const totals = new Map<string, number>();
for (const langs of weeklyMap.values()) {
for (const [lang, count] of langs) {
totals.set(lang, (totals.get(lang) ?? 0) + count);
}
}
const ranked = [...totals.entries()].sort(([, a], [, b]) => b - a);
const topN = 8;
const topLangs = ranked.slice(0, topN).map(([l]) => l);
const hasOther = ranked.length > topN;
const languages = hasOther ? [...topLangs, 'Other'] : topLangs;
// Build stacked data per week
const stacked: number[][] = weeks.map((wk) => {
const langs = weeklyMap.get(wk)!;
const values = topLangs.map((l) => langs.get(l) ?? 0);
if (hasOther) {
let other = 0;
for (const [l, c] of langs) {
if (!topLangs.includes(l)) other += c;
}
values.push(other);
}
return values;
});
// Compute stream layout (centered baseline)
const maxTotal = Math.max(...stacked.map((row) => row.reduce((a, b) => a + b, 0)), 1);
const chartHeight = HEIGHT - LABEL_HEIGHT;
// For each week, compute y0 (centered) then stack upward
const layerCount = languages.length;
const y0s: number[][] = [];
const y1s: number[][] = [];
for (let w = 0; w < weeks.length; w++) {
const total = stacked[w].reduce((a, b) => a + b, 0);
const scaledTotal = (total / maxTotal) * chartHeight;
let baseline = (chartHeight - scaledTotal) / 2 + LABEL_HEIGHT;
const wy0: number[] = [];
const wy1: number[] = [];
for (let l = 0; l < layerCount; l++) {
const h = (stacked[w][l] / maxTotal) * chartHeight;
wy0.push(baseline);
baseline += h;
wy1.push(baseline);
}
y0s.push(wy0);
y1s.push(wy1);
}
// Build SVG paths for each language layer
const paths = languages.map((_, l) => {
if (weeks.length === 0) return '';
// Top edge left-to-right
const top = weeks.map((_, w) => {
const x = weeks.length > 1 ? (w / (weeks.length - 1)) * 100 : 50;
return `${x},${y0s[w][l]}`;
});
// Bottom edge right-to-left
const bottom = weeks
.map((_, w) => {
const x = weeks.length > 1 ? (w / (weeks.length - 1)) * 100 : 50;
return `${x},${y1s[w][l]}`;
})
.reverse();
return `M${top.join(' L')} L${bottom.join(' L')} Z`;
});
// Default colors for "Other" and fallback
const FALLBACK_COLORS = [
'#e34c26', '#563d7c', '#3178c6', '#dea584',
'#f1e05a', '#89e051', '#00ADD8', '#438eff',
];
const legendItems = languages.map((lang, i) => ({
language: lang,
color:
lang === 'Other'
? 'rgba(255,255,255,0.2)'
: colorMap.get(lang) ?? FALLBACK_COLORS[i % FALLBACK_COLORS.length],
total: ranked[i]?.[1] ?? 0,
}));
return { weeks, languages, paths, legendItems };
}, [langQ.data]);
if (langQ.isLoading) return <p style={{ fontSize: '0.8rem' }}>loading language graph...</p>;
if (langQ.isError || languages.length === 0) return null;
return (
<div className="contribution-graph mb-4">
<p style={{ fontSize: '0.8rem', opacity: 0.6 }}>languages by commit activity</p>
<svg viewBox={`0 0 100 ${HEIGHT}`} width="100%" preserveAspectRatio="none" className="d-block" style={{ height: `${HEIGHT}px` }}>
{paths.map((d, i) => (
<path
key={legendItems[i].language}
d={d}
fill={legendItems[i].color}
opacity={0.85}
>
<title>{legendItems[i].language}</title>
</path>
))}
</svg>
<div className="d-flex flex-wrap gap-2 mt-1" style={{ fontSize: '0.75rem' }}>
{legendItems.map(({ language, color }) => (
<span key={language} className="d-flex align-items-center gap-1">
<span
style={{
width: 10,
height: 10,
borderRadius: '50%',
backgroundColor: color,
display: 'inline-block',
}}
/>
{language}
</span>
))}
</div>
</div>
);
}
function fmt(d: Date): string {
return d.toISOString().slice(0, 10);
}

View File

@@ -1,10 +1,12 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import Col from 'react-bootstrap/Col'; import Col from 'react-bootstrap/Col';
import Row from 'react-bootstrap/Row'; import Row from 'react-bootstrap/Row';
import { fetchProjects, fetchLanguages, type ProjectSummary, type Source } from '../api/client'; import { fetchProjects, fetchRepoLanguages, type ProjectSummary } from '../api/client';
import { ContributionGraph, AllTimeGraph } from '../components/ContributionGraph'; import { ContributionGraph, AllTimeGraph } from '../components/ContributionGraph';
import { LanguageStreamGraph } from '../components/LanguageStreamGraph';
export function DashPage() { export function DashPage() {
const projectsQ = useQuery({ const projectsQ = useQuery({
@@ -13,6 +15,22 @@ export function DashPage() {
refetchInterval: 60_000, refetchInterval: 60_000,
}); });
const langsQ = useQuery({
queryKey: ['repo-languages'],
queryFn: fetchRepoLanguages,
staleTime: 10 * 60_000,
});
const langsByRepo = useMemo(() => {
const map = new Map<string, Record<string, number>>();
for (const entry of langsQ.data ?? []) {
const key = `${entry.source}:${entry.repo}`;
if (!map.has(key)) map.set(key, {});
map.get(key)![entry.language] = entry.bytes;
}
return map;
}, [langsQ.data]);
const projects = projectsQ.data ?? []; const projects = projectsQ.data ?? [];
const ranked = rankProjects(projects); const ranked = rankProjects(projects);
@@ -27,6 +45,7 @@ export function DashPage() {
</Col> </Col>
</Row> </Row>
<ContributionGraph /> <ContributionGraph />
<LanguageStreamGraph />
<AllTimeGraph /> <AllTimeGraph />
{projectsQ.isLoading && <p>loading...</p>} {projectsQ.isLoading && <p>loading...</p>}
{projectsQ.isError && ( {projectsQ.isError && (
@@ -35,7 +54,7 @@ export function DashPage() {
<Row xs={1} md={2} lg={3} className="g-3"> <Row xs={1} md={2} lg={3} className="g-3">
{ranked.map((p) => ( {ranked.map((p) => (
<Col key={`${p.source}:${p.repo}`}> <Col key={`${p.source}:${p.repo}`}>
<ProjectCard project={p} /> <ProjectCard project={p} langs={langsByRepo.get(`${p.source}:${p.repo}`) ?? null} />
</Col> </Col>
))} ))}
</Row> </Row>
@@ -43,15 +62,7 @@ export function DashPage() {
); );
} }
function ProjectCard({ project: p }: { project: ProjectSummary }) { function ProjectCard({ project: p, langs }: { project: ProjectSummary; langs: Record<string, number> | null }) {
const langsQ = useQuery({
queryKey: ['languages', p.source, p.host, p.repo],
queryFn: () => fetchLanguages(p.source as Source, p.host, p.repo),
enabled: p.source === 'github' || p.source === 'gitea',
staleTime: 10 * 60_000,
});
const langs = langsQ.data;
const topLangs = langs ? topLanguages(langs, 3) : null; const topLangs = langs ? topLanguages(langs, 3) : null;
return ( return (

View File

@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import Col from 'react-bootstrap/Col'; import Col from 'react-bootstrap/Col';
@@ -5,7 +6,7 @@ import Row from 'react-bootstrap/Row';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import { VerticalTimeline } from 'react-vertical-timeline-component'; import { VerticalTimeline } from 'react-vertical-timeline-component';
import { fetchEvents, fetchReadme, fetchLanguages, fetchProjects, type Source } from '../api/client'; import { fetchEvents, fetchReadme, fetchRepoLanguages, fetchProjects, type Source } from '../api/client';
import { TimelineEntry } from '../components/TimelineEntry'; import { TimelineEntry } from '../components/TimelineEntry';
export function ProjectPage() { export function ProjectPage() {
@@ -40,22 +41,39 @@ export function ProjectPage() {
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
}); });
const langsQ = useQuery({ const repoLangsQ = useQuery({
queryKey: ['languages', source, host, repo], queryKey: ['repo-languages'],
queryFn: () => fetchLanguages(source as Source, host, repo), queryFn: fetchRepoLanguages,
enabled: !!host && (source === 'github' || source === 'gitea'), staleTime: 10 * 60_000,
staleTime: 5 * 60_000,
}); });
const langs = useMemo(() => {
if (!repoLangsQ.data || !source) return null;
const entries = repoLangsQ.data.filter(
(e) => e.source === source && e.repo === repo,
);
if (entries.length === 0) return null;
const result: Record<string, number> = {};
for (const e of entries) result[e.language] = e.bytes;
return result;
}, [repoLangsQ.data, source, repo]);
const langColors = useMemo(() => {
const map: Record<string, string> = {};
for (const e of repoLangsQ.data ?? []) {
if (e.color && !map[e.language]) map[e.language] = e.color;
}
return map;
}, [repoLangsQ.data]);
const events = eventsQ.data ?? []; const events = eventsQ.data ?? [];
const langs = langsQ.data;
return ( return (
<> <>
<Row className="mb-3"> <Row className="mb-3">
<Col> <Col>
<h2><a href={repoUrl(source ?? '', host, repo)} target="_blank" rel="noopener noreferrer"><img src={forgeIcon(source ?? '')} alt={source} className="forge-icon" style={{ width: 24, height: 24 }} /></a>{repo}</h2> <h2><a href={repoUrl(source ?? '', host, repo)} target="_blank" rel="noopener noreferrer"><img src={forgeIcon(source ?? '')} alt={source} className="forge-icon" style={{ width: 24, height: 24 }} /></a>{repo}</h2>
{langs && <LanguageBar languages={langs} />} {langs && <LanguageBar languages={langs} colorMap={langColors} />}
</Col> </Col>
</Row> </Row>
@@ -107,7 +125,7 @@ function forgeIcon(source: string): string {
} }
} }
function LanguageBar({ languages }: { languages: Record<string, number> }) { function LanguageBar({ languages, colorMap }: { languages: Record<string, number>; colorMap: Record<string, string> }) {
const total = Object.values(languages).reduce((a, b) => a + b, 0); const total = Object.values(languages).reduce((a, b) => a + b, 0);
if (total === 0) return null; if (total === 0) return null;
@@ -120,7 +138,7 @@ function LanguageBar({ languages }: { languages: Record<string, number> }) {
<div <div
key={lang} key={lang}
className="language-bar-segment" className="language-bar-segment"
style={{ width: `${(bytes / total) * 100}%`, backgroundColor: langColor(lang) }} style={{ width: `${(bytes / total) * 100}%`, backgroundColor: colorMap[lang] ?? '#8b8b8b' }}
title={`${lang} ${((bytes / total) * 100).toFixed(1)}%`} title={`${lang} ${((bytes / total) * 100).toFixed(1)}%`}
/> />
))} ))}
@@ -128,7 +146,7 @@ function LanguageBar({ languages }: { languages: Record<string, number> }) {
<div className="d-flex flex-wrap gap-2 mt-1" style={{ fontSize: '0.75rem' }}> <div className="d-flex flex-wrap gap-2 mt-1" style={{ fontSize: '0.75rem' }}>
{sorted.slice(0, 8).map(([lang, bytes]) => ( {sorted.slice(0, 8).map(([lang, bytes]) => (
<span key={lang}> <span key={lang}>
<span className="language-dot" style={{ backgroundColor: langColor(lang) }} /> <span className="language-dot" style={{ backgroundColor: colorMap[lang] ?? '#8b8b8b' }} />
{lang} {((bytes / total) * 100).toFixed(1)}% {lang} {((bytes / total) * 100).toFixed(1)}%
</span> </span>
))} ))}
@@ -136,26 +154,3 @@ function LanguageBar({ languages }: { languages: Record<string, number> }) {
</div> </div>
); );
} }
const LANG_COLORS: Record<string, string> = {
Rust: '#dea584',
TypeScript: '#3178c6',
JavaScript: '#f1e05a',
Python: '#3572a5',
Go: '#00add8',
Shell: '#89e051',
HTML: '#e34c26',
CSS: '#563d7c',
C: '#555555',
'C++': '#f34b7d',
Java: '#b07219',
Ruby: '#701516',
Nix: '#7e7eff',
Makefile: '#427819',
Dockerfile: '#384d54',
SCSS: '#c6538c',
};
function langColor(lang: string): string {
return LANG_COLORS[lang] ?? '#8b8b8b';
}