Overlay Gitea contributions on btop commit graph
This commit is contained in:
1 parent
7ca0003800
commit
0659b82e6f
7 files changed
+196
-38
No files matched your search
@@ -6,6 +6,7 @@
|
|||||||
"title": "Computer Science Student",
|
"title": "Computer Science Student",
|
||||||
"email": "sirblob0@gmail.com",
|
"email": "sirblob0@gmail.com",
|
||||||
"donateUrl": "https://ko-fi.com/sirblob",
|
"donateUrl": "https://ko-fi.com/sirblob",
|
||||||
|
"giteaUrl": "https://git.sirblob.co/SirBlob",
|
||||||
"avatar": "/blob_nerd.png",
|
"avatar": "/blob_nerd.png",
|
||||||
"photo": "/portfolio-photo.jpg",
|
"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.",
|
"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.",
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ export interface ContributionDay {
|
|||||||
date: string;
|
date: string;
|
||||||
level: number;
|
level: number;
|
||||||
count: number;
|
count: number;
|
||||||
|
githubCount: number;
|
||||||
|
giteaCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContributionCalendar {
|
export interface ContributionCalendar {
|
||||||
total: number;
|
total: number;
|
||||||
|
githubTotal: number;
|
||||||
|
giteaTotal: number;
|
||||||
|
giteaProfileUrl: string | null;
|
||||||
weeks: ContributionDay[][];
|
weeks: ContributionDay[][];
|
||||||
currentStreak: number;
|
currentStreak: number;
|
||||||
bestDay: ContributionDay | null;
|
bestDay: ContributionDay | null;
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { BtopData } from '#lib/btop/types';
|
import type { BtopData, ContributionDay } from '#lib/btop/types';
|
||||||
import BtopBox from './BtopBox.svelte';
|
import BtopBox from './BtopBox.svelte';
|
||||||
|
|
||||||
let { data }: { data: BtopData | null } = $props();
|
let { data }: { data: BtopData | null } = $props();
|
||||||
|
|
||||||
const levelOpacity = [0, 0.3, 0.55, 0.8, 1];
|
const levelOpacity = [1, 0.35, 0.6, 0.8, 1];
|
||||||
|
|
||||||
const calendar = $derived(data?.contributions ?? null);
|
const calendar = $derived(data?.contributions ?? null);
|
||||||
|
|
||||||
|
function dayBackground({ githubCount, giteaCount }: ContributionDay): string | undefined {
|
||||||
|
if (githubCount > 0 && giteaCount > 0) return 'linear-gradient(135deg, var(--green) 50%, var(--orange) 50%)';
|
||||||
|
if (giteaCount > 0) return 'var(--orange)';
|
||||||
|
if (githubCount > 0) return 'var(--green)';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayTitle(day: ContributionDay): string {
|
||||||
|
return `${day.count} contributions on ${day.date} (GitHub ${day.githubCount}, Gitea ${day.giteaCount})`;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<BtopBox title="net · commits" number={3} color="blue">
|
<BtopBox title="net · commits" number={3} color="blue">
|
||||||
@@ -19,27 +30,38 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a class="heatmap" href={data.github.profileUrl} target="_blank" rel="noopener noreferrer" aria-label="GitHub contribution graph">
|
<div class="heatmap" aria-label="Contribution graph">
|
||||||
{#each calendar.weeks as week, weekIndex (weekIndex)}
|
{#each calendar.weeks as week, weekIndex (weekIndex)}
|
||||||
<span class="week">
|
<span class="week">
|
||||||
{#each week as day (day.date)}
|
{#each week as day (day.date)}
|
||||||
<span
|
<span
|
||||||
class="day"
|
class="day"
|
||||||
style:opacity={day.level === 0 ? 1 : levelOpacity[day.level]}
|
class:empty={day.count === 0}
|
||||||
class:empty={day.level === 0}
|
style:background={dayBackground(day)}
|
||||||
title="{day.count} contributions on {day.date}"
|
style:opacity={levelOpacity[day.level]}
|
||||||
|
title={dayTitle(day)}
|
||||||
></span>
|
></span>
|
||||||
{/each}
|
{/each}
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</a>
|
</div>
|
||||||
|
|
||||||
<div class="legend muted">
|
<div class="legend">
|
||||||
less
|
<a class="source" href={data.github.profileUrl} target="_blank" rel="noopener noreferrer">
|
||||||
{#each levelOpacity as opacity, level (level)}
|
<span class="swatch github"></span>GitHub <b>{calendar.githubTotal.toLocaleString('en-US')}</b>
|
||||||
<span class="day" class:empty={level === 0} style:opacity={level === 0 ? 1 : opacity}></span>
|
</a>
|
||||||
{/each}
|
{#if calendar.giteaProfileUrl}
|
||||||
more
|
<a class="source" href={calendar.giteaProfileUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<span class="swatch gitea"></span>Gitea <b>{calendar.giteaTotal.toLocaleString('en-US')}</b>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<span class="scale muted">
|
||||||
|
less
|
||||||
|
{#each levelOpacity as opacity, level (level)}
|
||||||
|
<span class="swatch" class:empty={level === 0} style:opacity></span>
|
||||||
|
{/each}
|
||||||
|
more
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="muted loading">connecting to github...</p>
|
<p class="muted loading">connecting to github...</p>
|
||||||
@@ -76,25 +98,57 @@
|
|||||||
display: block;
|
display: block;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
min-width: 4px;
|
min-width: 4px;
|
||||||
background: var(--green);
|
|
||||||
border-radius: 1px;
|
border-radius: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day.empty {
|
.empty {
|
||||||
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend {
|
.legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
gap: 0.3rem 0.9rem;
|
||||||
gap: 3px;
|
margin-top: 0.5rem;
|
||||||
margin-top: 0.4rem;
|
font-size: 0.72rem;
|
||||||
font-size: 0.7rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
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 {
|
.loading {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface Profile {
|
|||||||
title: string;
|
title: string;
|
||||||
email: string;
|
email: string;
|
||||||
donateUrl?: string;
|
donateUrl?: string;
|
||||||
|
giteaUrl?: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
photo?: string;
|
photo?: string;
|
||||||
bio: string;
|
bio: string;
|
||||||
|
|||||||
+17
-3
@@ -1,6 +1,7 @@
|
|||||||
import { profile } from '#lib/content';
|
import { profile } from '#lib/content';
|
||||||
import type { BtopData, GithubSummary, RepoProcess } from '#lib/btop/types';
|
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 cacheDuration = 60 * 60 * 1000;
|
||||||
const githubHeaders = { Accept: 'application/vnd.github+json', 'User-Agent': 'portfolio-website' };
|
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 };
|
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[]> {
|
async function fetchRepos(username: string): Promise<RepoProcess[]> {
|
||||||
const repos = await fetchJson<GithubRepo[]>(`https://api.github.com/users/${username}/repos?per_page=100&sort=pushed`);
|
const repos = await fetchJson<GithubRepo[]>(`https://api.github.com/users/${username}/repos?per_page=100&sort=pushed`);
|
||||||
return repos
|
return repos
|
||||||
@@ -58,11 +70,13 @@ export async function getBtopData(): Promise<BtopData | null> {
|
|||||||
if (!username) return null;
|
if (!username) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [github, contributions, repos] = await Promise.all([
|
const [github, githubCalendar, giteaCounts, repos] = await Promise.all([
|
||||||
fetchSummary(username),
|
fetchSummary(username),
|
||||||
fetchContributionCalendar(username),
|
fetchGithubCalendar(username),
|
||||||
|
fetchGiteaCounts(),
|
||||||
fetchRepos(username)
|
fetchRepos(username)
|
||||||
]);
|
]);
|
||||||
|
const contributions = buildCalendar(githubCalendar, giteaCounts, profile.giteaUrl ?? null);
|
||||||
const data = { github, contributions, repos };
|
const data = { github, contributions, repos };
|
||||||
cached = { data, fetchedAt: Date.now() };
|
cached = { data, fetchedAt: Date.now() };
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -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 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 tooltipPattern = /for="contribution-day-component-(\d+-\d+)"[^>]*>(\d+|No) contributions? on/g;
|
||||||
const totalPattern = /([\d,]+)\s+contributions?\s+in the last year/;
|
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> {
|
function parseCounts(html: string): Map<string, number> {
|
||||||
const counts = new 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[][] {
|
function parseWeeks(html: string): ContributionDay[][] {
|
||||||
const counts = parseCounts(html);
|
const counts = parseCounts(html);
|
||||||
const weeks: ContributionDay[][] = [];
|
const weeks: ContributionDay[][] = [];
|
||||||
for (const [, date, weekday, week, level] of html.matchAll(dayCellPattern)) {
|
for (const [, date, weekday, week] of html.matchAll(dayCellPattern)) {
|
||||||
const weekIndex = Number(week);
|
const githubCount = counts.get(`${weekday}-${week}`) ?? 0;
|
||||||
weeks[weekIndex] ??= [];
|
weeks[Number(week)] ??= [];
|
||||||
weeks[weekIndex][Number(weekday)] = { date, level: Number(level), count: counts.get(`${weekday}-${week}`) ?? 0 };
|
weeks[Number(week)][Number(weekday)] = { date, level: 0, count: githubCount, githubCount, giteaCount: 0 };
|
||||||
}
|
}
|
||||||
return weeks.map((days) => days.filter(Boolean));
|
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 {
|
function currentStreak(days: ContributionDay[]): number {
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const pastDays = days.filter((day) => day.date <= today);
|
const pastDays = days.filter((day) => day.date <= today);
|
||||||
@@ -33,17 +65,30 @@ function currentStreak(days: ContributionDay[]): number {
|
|||||||
return streak;
|
return streak;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchContributionCalendar(username: string): Promise<ContributionCalendar> {
|
export function buildCalendar(
|
||||||
const response = await fetch(`https://github.com/users/${username}/contributions`, {
|
github: GithubCalendar,
|
||||||
headers: { 'User-Agent': 'portfolio-website' }
|
giteaCounts: Map<string, number>,
|
||||||
});
|
giteaProfileUrl: string | null
|
||||||
if (!response.ok) throw new Error(`GitHub contributions returned ${response.status}`);
|
): ContributionCalendar {
|
||||||
|
const mergedWeeks = github.weeks.map((week) =>
|
||||||
const html = await response.text();
|
week.map((day) => {
|
||||||
const weeks = parseWeeks(html);
|
const giteaCount = giteaCounts.get(day.date) ?? 0;
|
||||||
const days = weeks.flat().sort((first, second) => first.date.localeCompare(second.date));
|
return { ...day, giteaCount, count: day.githubCount + giteaCount };
|
||||||
const total = Number(html.match(totalPattern)?.[1].replace(/,/g, '') ?? days.reduce((sum, day) => sum + day.count, 0));
|
})
|
||||||
|
);
|
||||||
|
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);
|
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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user