'use client';
import { useEffect, useState, useMemo } from 'react';
import { getRepos, getHeatmap, getRecentCommits, calcStats } from '@/lib/gitea';
import type { GiteaRepo, GiteaCommit, HeatmapEntry } from '@/lib/types';
// ── Calendar constants ──
const CELL = 12, GAP = 3, STEP = CELL + GAP;
const DAY_LABEL_W = 26;
const WEEKS = 52;
const LEVELS = [
'rgba(15,23,42,0.05)',
'rgba(15,23,42,0.12)',
'rgba(15,23,42,0.22)',
'rgba(15,23,42,0.38)',
'rgba(15,23,42,0.62)',
];
function getLevel(n: number) {
if (n === 0) return 0;
if (n <= 2) return 1;
if (n <= 5) return 2;
if (n <= 9) return 3;
return 4;
}
function timeAgo(s: string) {
const d = Date.now() - new Date(s).getTime();
const m = Math.floor(d / 60000);
if (m < 1) return 'just now';
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
const days = Math.floor(h / 24);
if (days < 30) return `${days}d`;
return `${Math.floor(days / 30)}mo`;
}
// ── Stat item ──
function Stat({
label,
value,
sub,
}: {
label: string;
value: string | number;
sub: string;
}) {
return (
{label}
{value}
{sub}
);
}
// ── Contribution Calendar ──
function ContributionCalendar({ data }: { data: HeatmapEntry[] }) {
const [tooltip, setTooltip] = useState<{
x: number;
y: number;
date: string;
count: number;
} | null>(null);
const dateMap = useMemo(() => {
const m = new Map();
data.forEach((e) => {
const k = new Date(e.timestamp * 1000).toISOString().split('T')[0];
m.set(k, (m.get(k) || 0) + e.contributions);
});
return m;
}, [data]);
const { weeks, monthLabels } = useMemo(() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const startSunday = new Date(today);
startSunday.setDate(today.getDate() - today.getDay() - 51 * 7);
const weeks: { date: Date; key: string; count: number }[][] = [];
const monthLabels: { label: string; x: number }[] = [];
let lastMonth = -1;
const cur = new Date(startSunday);
for (let w = 0; w < WEEKS; w++) {
const week: { date: Date; key: string; count: number }[] = [];
for (let d = 0; d < 7; d++) {
const k = cur.toISOString().split('T')[0];
week.push({ date: new Date(cur), key: k, count: dateMap.get(k) || 0 });
cur.setDate(cur.getDate() + 1);
}
const mo = week[0].date.getMonth();
if (mo !== lastMonth) {
monthLabels.push({
label: week[0].date.toLocaleDateString('en-US', { month: 'short' }),
x: w * STEP + DAY_LABEL_W,
});
lastMonth = mo;
}
weeks.push(week);
}
return { weeks, monthLabels };
}, [dateMap]);
const svgW = DAY_LABEL_W + WEEKS * STEP;
const svgH = 7 * STEP + 20;
return (
Less
{LEVELS.map((c, i) => (
))}
More
{tooltip && (
{tooltip.count}
{' contribution'}
{tooltip.count !== 1 ? 's' : ''}
{tooltip.date}
)}
);
}
// ── Monthly chart ──
function MonthlyChart({ data }: { data: HeatmapEntry[] }) {
const months = useMemo(() => {
return Array.from({ length: 12 }, (_, i) => {
const d = new Date();
d.setMonth(d.getMonth() - 11 + i);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
const label = d.toLocaleDateString('en-US', { month: 'short' });
const count = data
.filter((e) => {
const ed = new Date(e.timestamp * 1000);
return `${ed.getFullYear()}-${String(ed.getMonth() + 1).padStart(2, '0')}` === key;
})
.reduce((s, e) => s + e.contributions, 0);
return { label, count };
});
}, [data]);
const maxVal = Math.max(...months.map((m) => m.count), 1);
return (
Monthly activity
{months.map((m, i) => {
const pct = (m.count / maxVal) * 100;
return (
);
})}
);
}
// ── Recent commits ──
function RecentCommits({ commits }: { commits: GiteaCommit[] }) {
if (!commits.length) {
return No recent commits.
;
}
return (
Recent commits
{commits.slice(0, 10).map((c) => {
const sha = (c.sha || '').slice(0, 7);
const msg = (c.commit?.message || '').split('\n')[0].slice(0, 72);
const date = c.created || c.commit?.author?.date;
return (
-
{c._repo}
{sha}
{date ? timeAgo(date) : ''}
{msg}
);
})}
);
}
// ── Main section ──
export default function CommitSection() {
const [repos, setRepos] = useState([]);
const [heatmap, setHeatmap] = useState([]);
const [commits, setCommits] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function load() {
try {
const [r, h] = await Promise.all([getRepos(6), getHeatmap()]);
setRepos(r);
setHeatmap(h);
const c = await getRecentCommits(r);
setCommits(c);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Unknown error';
setError(msg);
} finally {
setLoading(false);
}
}
load();
}, []);
const stats = useMemo(() => calcStats(heatmap), [heatmap]);
return (
{/* Left side */}
{loading ? (
) : error ? (
Could not load heatmap
{error}
) : (
)}
{/* Right side */}
{loading ? (
{[...Array(5)].map((_, i) => (
))}
) : (
)}
{!!repos.length && (
)}
);
}