From 0659b82e6f2bfdadbd9248e5b2e9a884e44a8be6 Mon Sep 17 00:00:00 2001 From: SirBlobby Date: Sun, 1 Nov 2026 01:27:47 -0400 Subject: [PATCH] Overlay Gitea contributions on btop commit graph --- content/profile.json | 1 + src/lib/btop/types.ts | 5 + .../components/btop/ContributionPanel.svelte | 94 +++++++++++++++---- src/lib/content/types.ts | 1 + src/lib/server/btop.ts | 20 +++- src/lib/server/contributions.ts | 75 ++++++++++++--- src/lib/server/gitea.ts | 38 ++++++++ 7 files changed, 196 insertions(+), 38 deletions(-) create mode 100644 src/lib/server/gitea.ts diff --git a/content/profile.json b/content/profile.json index 2c90a2d..dce5778 100644 --- a/content/profile.json +++ b/content/profile.json @@ -6,6 +6,7 @@ "title": "Computer Science Student", "email": "sirblob0@gmail.com", "donateUrl": "https://ko-fi.com/sirblob", + "giteaUrl": "https://git.sirblob.co/SirBlob", "avatar": "/blob_nerd.png", "photo": "/portfolio-photo.jpg", "bio": "An engineer who loves making things. I build fun coding projects, compete in hackathons and robotics competitions, and enjoy games like Minecraft and Pokémon TCG Live. I'm interested in Open Source, Robotics, Embedded Systems, and AI/ML.", diff --git a/src/lib/btop/types.ts b/src/lib/btop/types.ts index fc73400..fe9ee03 100644 --- a/src/lib/btop/types.ts +++ b/src/lib/btop/types.ts @@ -2,10 +2,15 @@ export interface ContributionDay { date: string; level: number; count: number; + githubCount: number; + giteaCount: number; } export interface ContributionCalendar { total: number; + githubTotal: number; + giteaTotal: number; + giteaProfileUrl: string | null; weeks: ContributionDay[][]; currentStreak: number; bestDay: ContributionDay | null; diff --git a/src/lib/components/btop/ContributionPanel.svelte b/src/lib/components/btop/ContributionPanel.svelte index a0e0d77..09f04dc 100644 --- a/src/lib/components/btop/ContributionPanel.svelte +++ b/src/lib/components/btop/ContributionPanel.svelte @@ -1,12 +1,23 @@ @@ -19,27 +30,38 @@ {/if} - + -
- less - {#each levelOpacity as opacity, level (level)} - - {/each} - more +
+ + GitHub {calendar.githubTotal.toLocaleString('en-US')} + + {#if calendar.giteaProfileUrl} + + Gitea {calendar.giteaTotal.toLocaleString('en-US')} + + {/if} + + less + {#each levelOpacity as opacity, level (level)} + + {/each} + more +
{:else}

connecting to github...

@@ -76,25 +98,57 @@ display: block; aspect-ratio: 1; min-width: 4px; - background: var(--green); border-radius: 1px; } - .day.empty { + .empty { background: color-mix(in srgb, var(--foreground) 10%, transparent); } .legend { display: flex; + flex-wrap: wrap; align-items: center; - justify-content: flex-end; - gap: 3px; - margin-top: 0.4rem; - font-size: 0.7rem; + gap: 0.3rem 0.9rem; + margin-top: 0.5rem; + font-size: 0.72rem; } - .legend .day { + .source { + display: inline-flex; + align-items: center; + gap: 0.3rem; + } + + .source:hover { + color: var(--accent); + } + + .source b { + color: var(--blue); + } + + .scale { + display: inline-flex; + align-items: center; + gap: 3px; + margin-left: auto; + } + + .swatch { + display: inline-block; width: 9px; + height: 9px; + border-radius: 1px; + background: var(--green); + } + + .swatch.gitea { + background: var(--orange); + } + + .swatch.empty { + background: color-mix(in srgb, var(--foreground) 10%, transparent); } .loading { diff --git a/src/lib/content/types.ts b/src/lib/content/types.ts index d3b02b6..6aca701 100644 --- a/src/lib/content/types.ts +++ b/src/lib/content/types.ts @@ -18,6 +18,7 @@ export interface Profile { title: string; email: string; donateUrl?: string; + giteaUrl?: string; avatar: string; photo?: string; bio: string; diff --git a/src/lib/server/btop.ts b/src/lib/server/btop.ts index 71ef4c0..c6bb528 100644 --- a/src/lib/server/btop.ts +++ b/src/lib/server/btop.ts @@ -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 { return { username, profileUrl: user.html_url, publicRepos: user.public_repos, followers: user.followers }; } +async function fetchGiteaCounts(): Promise> { + 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 { const repos = await fetchJson(`https://api.github.com/users/${username}/repos?per_page=100&sort=pushed`); return repos @@ -58,11 +70,13 @@ export async function getBtopData(): Promise { 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; diff --git a/src/lib/server/contributions.ts b/src/lib/server/contributions.ts index e840e82..6f46da3 100644 --- a/src/lib/server/contributions.ts +++ b/src/lib/server/contributions.ts @@ -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 { const counts = new Map(); @@ -15,14 +21,40 @@ function parseCounts(html: string): Map { 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 { + 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 { - 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, + 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((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 + }; } diff --git a/src/lib/server/gitea.ts b/src/lib/server/gitea.ts new file mode 100644 index 0000000..e20330b --- /dev/null +++ b/src/lib/server/gitea.ts @@ -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> { + 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(); + for (const { timestamp, contributions } of entries) { + const date = easternDate(timestamp); + dailyCounts.set(date, (dailyCounts.get(date) ?? 0) + contributions); + } + return dailyCounts; +}