diff --git a/crates/moments-api/src/main.rs b/crates/moments-api/src/main.rs index eda4ea9..ec634ba 100644 --- a/crates/moments-api/src/main.rs +++ b/crates/moments-api/src/main.rs @@ -11,7 +11,7 @@ use chrono::{DateTime, Datelike, NaiveDate, Utc}; use clap::Parser; use moments_core::{EventReader, reshape}; 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 tower_http::{cors::CorsLayer, trace::TraceLayer}; use tracing::info; @@ -57,6 +57,8 @@ async fn main() -> anyhow::Result<()> { .route("/v1/sources", get(list_sources)) .route("/v1/projects", get(list_projects)) .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/og/contributions.png", get(og_contributions)) .with_state(state) @@ -157,6 +159,23 @@ async fn daily_counts( Ok(Json(counts)) } +async fn language_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.language_daily_counts(from, to).await.map_err(internal)?; + Ok(Json(counts)) +} + +async fn repo_languages( + State(state): State, +) -> Result>, ApiError> { + let langs = state.store.repo_languages().await.map_err(internal)?; + Ok(Json(langs)) +} + async fn og_contributions( State(state): State, ) -> Result { diff --git a/crates/moments-core/src/lib.rs b/crates/moments-core/src/lib.rs index c42449c..a355dc5 100644 --- a/crates/moments-core/src/lib.rs +++ b/crates/moments-core/src/lib.rs @@ -6,7 +6,7 @@ pub use sources::{EventSource, PollerState, PollerStateStore, SourceError, run_p use async_trait::async_trait; 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)] pub enum StoreError { @@ -21,10 +21,13 @@ pub trait EventReader: Send + Sync { 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>; + async fn language_daily_counts(&self, from: NaiveDate, to: NaiveDate) -> Result, StoreError>; + async fn repo_languages(&self) -> Result, StoreError>; } /// Write-side port consumed by `moments-worker`. Idempotent upserts on `id`. #[async_trait] pub trait EventWriter: Send + Sync { async fn upsert_events(&self, events: &[Event]) -> Result; + async fn upsert_repo_languages(&self, languages: &[RepoLanguage]) -> Result; } diff --git a/crates/moments-data/migrations/0004_repo_languages.sql b/crates/moments-data/migrations/0004_repo_languages.sql new file mode 100644 index 0000000..8071d32 --- /dev/null +++ b/crates/moments-data/migrations/0004_repo_languages.sql @@ -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) +); diff --git a/crates/moments-data/src/gitea.rs b/crates/moments-data/src/gitea.rs index 37c34be..bb39fcf 100644 --- a/crates/moments-data/src/gitea.rs +++ b/crates/moments-data/src/gitea.rs @@ -9,12 +9,13 @@ //! Each item carries a self-contained payload — including the event-emitting //! host — so the reshape layer can construct URLs without needing config. +use std::collections::HashSet; use std::sync::Arc; use async_trait::async_trait; use chrono::{DateTime, Utc}; use moments_core::{EventSource, EventWriter, PollerStateStore, SourceError}; -use moments_entities::{Event, Source}; +use moments_entities::{Event, RepoLanguage, Source}; use reqwest::{Client, header}; use serde_json::Value; use tracing::debug; @@ -126,17 +127,19 @@ impl GiteaSource { /// for org feeds which contain all members' activity). /// /// `base_url` should contain everything except the `&page=N` suffix. + /// Returns (ingested_count, set_of_repo_full_names). async fn poll_feed( &self, state_key: &str, base_url: &str, filter_user: bool, - ) -> Result { + ) -> Result<(usize, HashSet), SourceError> { let prior = self.state.load(state_key).await?; let first_run = prior.is_none(); let max_pages = if first_run { MAX_BACKFILL_PAGES } else { 1 }; let mut total = 0usize; + let mut repos = HashSet::new(); for page in 1..=max_pages { let url = format!("{base_url}&page={page}"); let req = self.apply_headers(self.client.get(&url)); @@ -155,6 +158,17 @@ impl GiteaSource { 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 = items .iter() .filter(|it| { @@ -177,6 +191,44 @@ impl GiteaSource { } 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) -> Result { + 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 = resp + .json() + .await + .map_err(|e| SourceError::Parse(e.to_string()))?; + + let languages: Vec = 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) } } @@ -188,9 +240,12 @@ impl EventSource for GiteaSource { } async fn poll(&self) -> Result { + let mut all_repos = HashSet::new(); + // Poll user's own activity feed (existing behavior). 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 // events performed by this user. @@ -199,13 +254,20 @@ impl EventSource for GiteaSource { let state_key = format!("gitea:org:{org}"); let org_url = self.org_feed_base_url(org); 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) => { 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"); Ok(total) } diff --git a/crates/moments-data/src/github_repo.rs b/crates/moments-data/src/github_repo.rs index bd2dc66..dffa2e0 100644 --- a/crates/moments-data/src/github_repo.rs +++ b/crates/moments-data/src/github_repo.rs @@ -20,7 +20,7 @@ use std::sync::Arc; use async_trait::async_trait; use chrono::{DateTime, Utc}; use moments_core::{EventSource, EventWriter, PollerStateStore, SourceError}; -use moments_entities::{Event, Source}; +use moments_entities::{Event, RepoLanguage, Source}; use reqwest::{Client, header}; use serde_json::Value; use tracing::{debug, warn}; @@ -296,6 +296,105 @@ impl GithubRepoSource { self.state.save(&state_key, None, newest).await?; 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 { + 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] @@ -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?; debug!(ingested = total, repos = repos.len(), "github-repo poll complete"); Ok(total) diff --git a/crates/moments-data/src/hg.rs b/crates/moments-data/src/hg.rs index a71d9dc..1d7d4eb 100644 --- a/crates/moments-data/src/hg.rs +++ b/crates/moments-data/src/hg.rs @@ -248,6 +248,12 @@ mod tests { ) -> Result { Ok(0) } + async fn upsert_repo_languages( + &self, + _languages: &[moments_entities::RepoLanguage], + ) -> Result { + Ok(0) + } } struct NoopState; #[async_trait] diff --git a/crates/moments-data/src/lib.rs b/crates/moments-data/src/lib.rs index 1204fe8..4edf34e 100644 --- a/crates/moments-data/src/lib.rs +++ b/crates/moments-data/src/lib.rs @@ -9,7 +9,7 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; use moments_core::{EventReader, EventWriter, PollerState, PollerStateStore, StoreError}; 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::postgres::{PgPool, PgPoolOptions}; use std::str::FromStr; @@ -203,7 +203,8 @@ impl EventReader for PgStore { 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' + 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 GROUP BY d::date ORDER BY d::date @@ -224,6 +225,90 @@ impl EventReader for PgStore { }) .collect() } + + async fn language_daily_counts(&self, from: NaiveDate, to: NaiveDate) -> Result, 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, 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] @@ -331,4 +416,37 @@ impl EventWriter for PgStore { tx.commit().await.map_err(map_err)?; Ok(inserted) } + + async fn upsert_repo_languages(&self, languages: &[RepoLanguage]) -> Result { + 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) + } } diff --git a/crates/moments-entities/src/lib.rs b/crates/moments-entities/src/lib.rs index 25b6191..7f832cf 100644 --- a/crates/moments-entities/src/lib.rs +++ b/crates/moments-entities/src/lib.rs @@ -104,6 +104,25 @@ pub struct ProjectSummary { pub last_activity: Option>, } +/// 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, + 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, +} + // --------------------------------------------------------------------- // Presentation shape — what `GET /v1/events` actually returns. // The API reshapes raw payloads into these so the frontend stays dumb. diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 9755b19..bcdb04d 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -70,6 +70,13 @@ export interface DailyCount { count: number; } +export interface LanguageDailyCount { + date: string; + language: string; + color: string | null; + commits: number; +} + export interface EventQuery { from?: Date; to?: Date; @@ -113,6 +120,26 @@ export async function fetchDailyCounts(from: string, to: string): Promise { + 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 { + 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 { const resp = await fetch(`${API_BASE}/projects`); 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; } - -/** Fetch repo languages as { language: bytes } map via the forge proxy. */ -export async function fetchLanguages(source: Source, host: string, repo: string): Promise | 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(); -} diff --git a/ui/src/components/LanguageStreamGraph.tsx b/ui/src/components/LanguageStreamGraph.tsx new file mode 100644 index 0000000..49452b1 --- /dev/null +++ b/ui/src/components/LanguageStreamGraph.tsx @@ -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(); + const weeklyMap = new Map>(); + + 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(); + 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

loading language graph...

; + if (langQ.isError || languages.length === 0) return null; + + return ( +
+

languages by commit activity

+ + {paths.map((d, i) => ( + + {legendItems[i].language} + + ))} + +
+ {legendItems.map(({ language, color }) => ( + + + {language} + + ))} +
+
+ ); +} + +function fmt(d: Date): string { + return d.toISOString().slice(0, 10); +} diff --git a/ui/src/pages/DashPage.tsx b/ui/src/pages/DashPage.tsx index f6de981..dad7932 100644 --- a/ui/src/pages/DashPage.tsx +++ b/ui/src/pages/DashPage.tsx @@ -1,10 +1,12 @@ +import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import Col from 'react-bootstrap/Col'; 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 { LanguageStreamGraph } from '../components/LanguageStreamGraph'; export function DashPage() { const projectsQ = useQuery({ @@ -13,6 +15,22 @@ export function DashPage() { refetchInterval: 60_000, }); + const langsQ = useQuery({ + queryKey: ['repo-languages'], + queryFn: fetchRepoLanguages, + staleTime: 10 * 60_000, + }); + + const langsByRepo = useMemo(() => { + const map = new Map>(); + 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 ranked = rankProjects(projects); @@ -27,6 +45,7 @@ export function DashPage() { + {projectsQ.isLoading &&

loading...

} {projectsQ.isError && ( @@ -35,7 +54,7 @@ export function DashPage() { {ranked.map((p) => ( - + ))} @@ -43,15 +62,7 @@ export function DashPage() { ); } -function ProjectCard({ project: p }: { project: ProjectSummary }) { - 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; +function ProjectCard({ project: p, langs }: { project: ProjectSummary; langs: Record | null }) { const topLangs = langs ? topLanguages(langs, 3) : null; return ( diff --git a/ui/src/pages/ProjectPage.tsx b/ui/src/pages/ProjectPage.tsx index ba345ac..490e0ff 100644 --- a/ui/src/pages/ProjectPage.tsx +++ b/ui/src/pages/ProjectPage.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import Col from 'react-bootstrap/Col'; @@ -5,7 +6,7 @@ import Row from 'react-bootstrap/Row'; import ReactMarkdown from 'react-markdown'; 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'; export function ProjectPage() { @@ -40,22 +41,39 @@ export function ProjectPage() { staleTime: 5 * 60_000, }); - const langsQ = useQuery({ - queryKey: ['languages', source, host, repo], - queryFn: () => fetchLanguages(source as Source, host, repo), - enabled: !!host && (source === 'github' || source === 'gitea'), - staleTime: 5 * 60_000, + const repoLangsQ = useQuery({ + queryKey: ['repo-languages'], + queryFn: fetchRepoLanguages, + staleTime: 10 * 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 = {}; + for (const e of entries) result[e.language] = e.bytes; + return result; + }, [repoLangsQ.data, source, repo]); + + const langColors = useMemo(() => { + const map: Record = {}; + 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 langs = langsQ.data; return ( <>

{source}{repo}

- {langs && } + {langs && }
@@ -107,7 +125,7 @@ function forgeIcon(source: string): string { } } -function LanguageBar({ languages }: { languages: Record }) { +function LanguageBar({ languages, colorMap }: { languages: Record; colorMap: Record }) { const total = Object.values(languages).reduce((a, b) => a + b, 0); if (total === 0) return null; @@ -120,7 +138,7 @@ function LanguageBar({ languages }: { languages: Record }) {
))} @@ -128,7 +146,7 @@ function LanguageBar({ languages }: { languages: Record }) {
{sorted.slice(0, 8).map(([lang, bytes]) => ( - + {lang} {((bytes / total) * 100).toFixed(1)}% ))} @@ -136,26 +154,3 @@ function LanguageBar({ languages }: { languages: Record }) {
); } - -const LANG_COLORS: Record = { - 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'; -}