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:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user