Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 108 additions & 52 deletions src/components/LanguageBreakdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

import { useEffect, useState } from "react";
import { useAccount } from "@/components/AccountContext";
import {
PieChart,
Pie,
Cell,
Tooltip,
ResponsiveContainer,
type TooltipProps,
} from "recharts";

interface Language {
name: string;
Expand All @@ -20,6 +28,16 @@ const LANG_COLORS: Record<string, string> = {
HTML: "#e34c26",
Ruby: "#701516",
Shell: "#89e051",
Swift: "#F05138",
Kotlin: "#7F52FF",
"C++": "#f34b7d",
C: "#555555",
"C#": "#178600",
PHP: "#4F5D95",
Dart: "#00B4AB",
Scala: "#c22d40",
Vue: "#41b883",
Other: "#6b7280",
};

const FALLBACK_COLOR = "#6b7280";
Expand All @@ -28,18 +46,17 @@ function getColor(name: string): string {
return LANG_COLORS[name] ?? FALLBACK_COLOR;
}

function LanguageDot({ color, label }: { color: string; label: string }) {
function LanguageTooltip({ active, payload }: TooltipProps<number, string>) {
if (!active || !payload || payload.length === 0) return null;
const entry = payload[0];
if (!entry) return null;
return (
<svg
width="0.75rem"
height="0.75rem"
viewBox="0 0 8 8"
className="shrink-0"
role="img"
aria-label={label}
>
<circle cx="4" cy="4" r="4" fill={color} />
</svg>
<div className="rounded-md border border-[var(--border)] bg-[var(--tooltip)] px-3 py-2 text-sm text-[var(--tooltip-foreground)] shadow-lg">
<div className="font-medium">{entry.name}</div>
<div className="mt-0.5 text-xs text-[var(--muted-foreground)]">
{entry.value}%
</div>
</div>
);
}

Expand All @@ -52,15 +69,13 @@ export default function LanguageBreakdown() {
useEffect(() => {
setLoading(true);
setError(null);
const url = selectedAccount !== null
? `/api/metrics/languages?accountId=${encodeURIComponent(selectedAccount)}`
: "/api/metrics/languages";
const url =
selectedAccount !== null
? `/api/metrics/languages?accountId=${encodeURIComponent(selectedAccount)}`
: "/api/metrics/languages";
fetch(url)
.then((r) => {
if (!r.ok) {
throw new Error("API error");
}

if (!r.ok) throw new Error("API error");
return r.json();
})
.then((d: { languages: Language[] }) => setLanguages(d.languages ?? []))
Expand All @@ -70,12 +85,15 @@ export default function LanguageBreakdown() {
.finally(() => setLoading(false));
}, [selectedAccount]);

const totalPercentage = languages.reduce((sum, lang) => sum + lang.percentage, 0);
const totalPercentage = languages.reduce(
(sum, lang) => sum + lang.percentage,
0
);
const roundedTotal = Math.round(totalPercentage * 10) / 10;
const displayLanguages = [...languages];
if (roundedTotal < 100 && languages.length > 0) {
displayLanguages.push({

const chartData: Language[] = [...languages];
if (roundedTotal < 99.5 && languages.length > 0) {
chartData.push({
name: "Other",
bytes: 0,
percentage: Math.round((100 - roundedTotal) * 10) / 10,
Expand All @@ -98,9 +116,12 @@ export default function LanguageBreakdown() {
<span className="sr-only">Loading language breakdown</span>
<div
aria-hidden="true"
className="h-6 rounded-full skeleton-shimmer"
className="mx-auto h-[180px] w-[180px] rounded-full skeleton-shimmer"
/>
<div aria-hidden="true" className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-3">
<div
aria-hidden="true"
className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-3"
>
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-5 rounded skeleton-shimmer" />
))}
Expand All @@ -111,48 +132,83 @@ export default function LanguageBreakdown() {
{error}
</p>
) : languages.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">
No language data available.
</p>
<div className="flex min-h-[200px] items-center justify-center rounded-lg border border-dashed border-[var(--border)] bg-[var(--card-muted)]">
<p className="text-sm text-[var(--muted-foreground)]">
No language data available.
</p>
</div>
) : (
<>
{/* Stacked bar */}
<div className="flex h-6 w-full overflow-hidden rounded-full bg-[var(--control)]">
{displayLanguages.map((lang) => (
<div
key={lang.name}
className="h-full transition-all duration-500 first:rounded-l-full last:rounded-r-full"
style={{
width: `${lang.percentage}%`,
backgroundColor: lang.name === "Other" ? "var(--control)" : getColor(lang.name),
minWidth: lang.percentage > 0 ? "4px" : "0px",
}}
title={`${lang.name}: ${lang.percentage}%`}
/>
))}
<div className="flex flex-col items-center gap-6 sm:flex-row sm:items-center sm:gap-8">
{/* Donut chart */}
<div
className="relative shrink-0"
role="img"
aria-label="Donut chart showing language distribution"
>
<ResponsiveContainer width={180} height={180}>
<PieChart>
<Pie
data={chartData}
dataKey="percentage"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={55}
outerRadius={85}
paddingAngle={2}
startAngle={90}
endAngle={-270}
strokeWidth={0}
>
{chartData.map((entry) => (
<Cell
key={entry.name}
fill={getColor(entry.name)}
opacity={0.9}
/>
))}
</Pie>
<Tooltip content={<LanguageTooltip />} />
</PieChart>
</ResponsiveContainer>
{/* Centre label */}
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-xs font-medium text-[var(--muted-foreground)]">
Languages
</span>
<span className="text-lg font-bold text-[var(--card-foreground)]">
{chartData.length}
</span>
</div>
</div>

{/* Legend */}
<div className="mt-4 flex flex-wrap gap-x-4 gap-y-2">
{displayLanguages.map((lang) => (
<div className="flex w-full flex-col gap-2 sm:flex-1">
{chartData.map((lang) => (
<div
key={lang.name}
className="flex min-w-0 max-w-full basis-full items-center gap-2 text-sm sm:basis-[calc(50%-0.5rem)]"
className="flex items-center gap-2 text-sm"
>
<LanguageDot
color={lang.name === "Other" ? "var(--control)" : getColor(lang.name)}
label={`${lang.name}: ${lang.percentage}%`}
/>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
className="shrink-0"
role="img"
aria-label={lang.name}
>
<circle cx="5" cy="5" r="5" fill={getColor(lang.name)} />
</svg>
<span className="min-w-0 flex-1 truncate text-[var(--card-foreground)]">
{lang.name}
</span>
<span className="ml-auto shrink-0 text-[var(--muted-foreground)]">
<span className="shrink-0 tabular-nums text-[var(--muted-foreground)]">
{lang.percentage}%
</span>
</div>
))}
</div>
</>
</div>
)}
</div>
);
Expand Down
Loading