Overlay Gitea contributions on btop commit graph

This commit is contained in:
SirBlob committed 2026-11-01 01:27:47 -04:00
1 parent 7ca0003800
commit 0659b82e6f
7 files changed
+196 -38

No files matched your search

+17 -3
View File
@@ -1,6 +1,7 @@
import { profile } from '#lib/content';
import type { BtopData, GithubSummary, RepoProcess } from '#lib/btop/types';
import { fetchContributionCalendar } from './contributions';
import { buildCalendar, fetchGithubCalendar } from './contributions';
import { fetchGiteaDailyCounts, parseGiteaProfile } from './gitea';
const cacheDuration = 60 * 60 * 1000;
const githubHeaders = { Accept: 'application/vnd.github+json', 'User-Agent': 'portfolio-website' };
@@ -36,6 +37,17 @@ async function fetchSummary(username: string): Promise<GithubSummary> {
return { username, profileUrl: user.html_url, publicRepos: user.public_repos, followers: user.followers };
}
async function fetchGiteaCounts(): Promise<Map<string, number>> {
const gitea = parseGiteaProfile(profile.giteaUrl);
if (!gitea) return new Map();
try {
return await fetchGiteaDailyCounts(gitea);
} catch (error) {
console.error('Gitea heatmap lookup failed:', error);
return new Map();
}
}
async function fetchRepos(username: string): Promise<RepoProcess[]> {
const repos = await fetchJson<GithubRepo[]>(`https://api.github.com/users/${username}/repos?per_page=100&sort=pushed`);
return repos
@@ -58,11 +70,13 @@ export async function getBtopData(): Promise<BtopData | null> {
if (!username) return null;
try {
const [github, contributions, repos] = await Promise.all([
const [github, githubCalendar, giteaCounts, repos] = await Promise.all([
fetchSummary(username),
fetchContributionCalendar(username),
fetchGithubCalendar(username),
fetchGiteaCounts(),
fetchRepos(username)
]);
const contributions = buildCalendar(githubCalendar, giteaCounts, profile.giteaUrl ?? null);
const data = { github, contributions, repos };
cached = { data, fetchedAt: Date.now() };
return data;
+60 -15
View File
@@ -3,6 +3,12 @@ import type { ContributionCalendar, ContributionDay } from '#lib/btop/types';
const dayCellPattern = /data-date="(\d{4}-\d{2}-\d{2})" id="contribution-day-component-(\d+)-(\d+)" data-level="(\d)"/g;
const tooltipPattern = /for="contribution-day-component-(\d+-\d+)"[^>]*>(\d+|No) contributions? on/g;
const totalPattern = /([\d,]+)\s+contributions?\s+in the last year/;
const levelCount = 4;
interface GithubCalendar {
weeks: ContributionDay[][];
total: number;
}
function parseCounts(html: string): Map<string, number> {
const counts = new Map<string, number>();
@@ -15,14 +21,40 @@ function parseCounts(html: string): Map<string, number> {
function parseWeeks(html: string): ContributionDay[][] {
const counts = parseCounts(html);
const weeks: ContributionDay[][] = [];
for (const [, date, weekday, week, level] of html.matchAll(dayCellPattern)) {
const weekIndex = Number(week);
weeks[weekIndex] ??= [];
weeks[weekIndex][Number(weekday)] = { date, level: Number(level), count: counts.get(`${weekday}-${week}`) ?? 0 };
for (const [, date, weekday, week] of html.matchAll(dayCellPattern)) {
const githubCount = counts.get(`${weekday}-${week}`) ?? 0;
weeks[Number(week)] ??= [];
weeks[Number(week)][Number(weekday)] = { date, level: 0, count: githubCount, githubCount, giteaCount: 0 };
}
return weeks.map((days) => days.filter(Boolean));
}
export async function fetchGithubCalendar(username: string): Promise<GithubCalendar> {
const response = await fetch(`https://github.com/users/${username}/contributions`, {
headers: { 'User-Agent': 'portfolio-website' }
});
if (!response.ok) throw new Error(`GitHub contributions returned ${response.status}`);
const html = await response.text();
const weeks = parseWeeks(html);
const summedTotal = weeks.flat().reduce((sum, day) => sum + day.githubCount, 0);
return { weeks, total: Number(html.match(totalPattern)?.[1].replace(/,/g, '') ?? summedTotal) };
}
function levelThresholds(counts: number[]): number[] {
const activeCounts = counts.filter((count) => count > 0).sort((first, second) => first - second);
if (activeCounts.length === 0) return [];
return Array.from({ length: levelCount - 1 }, (_, index) => {
const position = Math.floor(((index + 1) / levelCount) * activeCounts.length);
return activeCounts[Math.min(position, activeCounts.length - 1)];
});
}
function levelFor(count: number, thresholds: number[]): number {
if (count === 0) return 0;
return 1 + thresholds.filter((threshold) => count > threshold).length;
}
function currentStreak(days: ContributionDay[]): number {
const today = new Date().toISOString().slice(0, 10);
const pastDays = days.filter((day) => day.date <= today);
@@ -33,17 +65,30 @@ function currentStreak(days: ContributionDay[]): number {
return streak;
}
export async function fetchContributionCalendar(username: string): Promise<ContributionCalendar> {
const response = await fetch(`https://github.com/users/${username}/contributions`, {
headers: { 'User-Agent': 'portfolio-website' }
});
if (!response.ok) throw new Error(`GitHub contributions returned ${response.status}`);
const html = await response.text();
const weeks = parseWeeks(html);
const days = weeks.flat().sort((first, second) => first.date.localeCompare(second.date));
const total = Number(html.match(totalPattern)?.[1].replace(/,/g, '') ?? days.reduce((sum, day) => sum + day.count, 0));
export function buildCalendar(
github: GithubCalendar,
giteaCounts: Map<string, number>,
giteaProfileUrl: string | null
): ContributionCalendar {
const mergedWeeks = github.weeks.map((week) =>
week.map((day) => {
const giteaCount = giteaCounts.get(day.date) ?? 0;
return { ...day, giteaCount, count: day.githubCount + giteaCount };
})
);
const days = mergedWeeks.flat().sort((first, second) => first.date.localeCompare(second.date));
const thresholds = levelThresholds(days.map((day) => day.count));
const weeks = mergedWeeks.map((week) => week.map((day) => ({ ...day, level: levelFor(day.count, thresholds) })));
const giteaTotal = days.reduce((sum, day) => sum + day.giteaCount, 0);
const bestDay = days.reduce<ContributionDay | null>((best, day) => (!best || day.count > best.count ? day : best), null);
return { total, weeks, currentStreak: currentStreak(days), bestDay };
return {
total: github.total + giteaTotal,
githubTotal: github.total,
giteaTotal,
giteaProfileUrl,
weeks,
currentStreak: currentStreak(days),
bestDay
};
}
+38
View File
@@ -0,0 +1,38 @@
const timeZone = 'America/New_York';
interface HeatmapEntry {
timestamp: number;
contributions: number;
}
export interface GiteaProfile {
baseUrl: string;
username: string;
profileUrl: string;
}
export function parseGiteaProfile(profileUrl: string | undefined): GiteaProfile | null {
if (!profileUrl) return null;
const url = new URL(profileUrl);
const username = url.pathname.split('/').filter(Boolean)[0];
return username ? { baseUrl: url.origin, username, profileUrl } : null;
}
function easternDate(timestampSeconds: number): string {
return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(
new Date(timestampSeconds * 1000)
);
}
export async function fetchGiteaDailyCounts({ baseUrl, username }: GiteaProfile): Promise<Map<string, number>> {
const response = await fetch(`${baseUrl}/api/v1/users/${username}/heatmap`, { headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error(`Gitea heatmap returned ${response.status}`);
const entries: HeatmapEntry[] = await response.json();
const dailyCounts = new Map<string, number>();
for (const { timestamp, contributions } of entries) {
const date = easternDate(timestamp);
dailyCounts.set(date, (dailyCounts.get(date) ?? 0) + contributions);
}
return dailyCounts;
}