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:
@@ -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> {
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
9
crates/moments-data/migrations/0004_repo_languages.sql
Normal file
9
crates/moments-data/migrations/0004_repo_languages.sql
Normal 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)
|
||||||
|
);
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
|
|||||||
178
ui/src/components/LanguageStreamGraph.tsx
Normal file
178
ui/src/components/LanguageStreamGraph.tsx
Normal 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);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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';
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user