my website at ewancroft.uk
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(github): add dynamic contribution graph

- Fetch user events from GitHub API
- Aggregate contributions by date
- Render GitHub-style contribution grid
- Shows last 90 days with intensity levels

+278 -5
+206
src/lib/services/github/contributions.ts
··· 1 + /** 2 + * GitHub contributions fetcher 3 + * Fetches user events and aggregates by date for contribution graph 4 + */ 5 + import type { GitHubProfile } from './types'; 6 + 7 + const GITHUB_API_BASE = 'https://api.github.com'; 8 + const CACHE_TTL = 1000 * 60 * 30; // 30 minutes 9 + 10 + interface CacheEntry<T> { 11 + data: T; 12 + timestamp: number; 13 + } 14 + 15 + const cache = new Map<string, CacheEntry<unknown>>(); 16 + 17 + interface GitHubEvent { 18 + id: string; 19 + type: string; 20 + actor: { 21 + login: string; 22 + }; 23 + repo: { 24 + name: string; 25 + }; 26 + payload?: Record<string, unknown>; 27 + created_at: string; 28 + } 29 + 30 + interface ContributionDay { 31 + date: string; 32 + count: number; 33 + level: 0 | 1 | 2 | 3 | 4; 34 + } 35 + 36 + interface ContributionData { 37 + total: number; 38 + days: ContributionDay[]; 39 + weeks: ContributionDay[][]; 40 + } 41 + 42 + function getCached<T>(key: string): T | null { 43 + const entry = cache.get(key) as CacheEntry<T> | undefined; 44 + if (!entry) return null; 45 + if (Date.now() - entry.timestamp > CACHE_TTL) { 46 + cache.delete(key); 47 + return null; 48 + } 49 + return entry.data; 50 + } 51 + 52 + function setCached<T>(key: string, data: T): void { 53 + cache.set(key, { data, timestamp: Date.now() }); 54 + } 55 + 56 + /** 57 + * Calculate intensity level (0-4) based on count 58 + */ 59 + function getLevel(count: number, maxCount: number): 0 | 1 | 2 | 3 | 4 { 60 + if (count === 0) return 0; 61 + const ratio = count / maxCount; 62 + if (ratio < 0.25) return 1; 63 + if (ratio < 0.5) return 2; 64 + if (ratio < 0.75) return 3; 65 + return 4; 66 + } 67 + 68 + /** 69 + * Format date as YYYY-MM-DD 70 + */ 71 + function formatDateKey(date: Date): string { 72 + return date.toISOString().split('T')[0]; 73 + } 74 + 75 + /** 76 + * Generate array of dates for the last N days 77 + */ 78 + function generateDateRange(days: number): Date[] { 79 + const dates: Date[] = []; 80 + const today = new Date(); 81 + today.setHours(0, 0, 0, 0); 82 + for (let i = 0; i < days; i++) { 83 + const d = new Date(today); 84 + d.setDate(d.getDate() - i); 85 + dates.push(d); 86 + } 87 + return dates.reverse(); 88 + } 89 + 90 + /** 91 + * Fetch GitHub events for a user 92 + */ 93 + async function fetchUserEvents( 94 + username: string, 95 + fetchFn?: typeof fetch, 96 + pages: number = 10 97 + ): Promise<GitHubEvent[]> { 98 + const fetchImpl = fetchFn || globalThis.fetch; 99 + const events: GitHubEvent[] = []; 100 + 101 + // Fetch multiple pages to get more historical data 102 + for (let page = 1; page <= pages; page++) { 103 + const response = await fetchImpl( 104 + `${GITHUB_API_BASE}/users/${username}/events?per_page=100&page=${page}`, 105 + { 106 + headers: { 107 + Accept: 'application/vnd.github.v3+json', 108 + 'User-Agent': 'ewancroft-uk-website' 109 + } 110 + } 111 + ); 112 + 113 + if (!response.ok) break; 114 + const pageEvents: GitHubEvent[] = await response.json(); 115 + if (pageEvents.length === 0) break; 116 + events.push(...pageEvents); 117 + } 118 + 119 + return events; 120 + } 121 + 122 + /** 123 + * Fetch and aggregate contribution data 124 + */ 125 + export async function fetchContributions( 126 + username: string, 127 + fetchFn?: typeof fetch, 128 + days: number = 90 129 + ): Promise<ContributionData> { 130 + const cacheKey = `contributions:${username}:${days}`; 131 + const cached = getCached<ContributionData>(cacheKey); 132 + if (cached) return cached; 133 + 134 + // Fetch events 135 + const events = await fetchUserEvents(username, fetchFn, 10); 136 + 137 + // Aggregate by date 138 + const countsByDate = new Map<string, number>(); 139 + events.forEach((event) => { 140 + const date = event.created_at.split('T')[0]; 141 + countsByDate.set(date, (countsByDate.get(date) || 0) + 1); 142 + }); 143 + 144 + // Generate date range 145 + const dateRange = generateDateRange(days); 146 + 147 + // Build contribution days 148 + const contributionDays: ContributionDay[] = dateRange.map((date) => { 149 + const dateKey = formatDateKey(date); 150 + const count = countsByDate.get(dateKey) || 0; 151 + return { date: dateKey, count, level: 0 }; 152 + }); 153 + 154 + // Calculate max for level scaling 155 + const maxCount = Math.max(...contributionDays.map((d) => d.count), 1); 156 + 157 + // Assign levels 158 + contributionDays.forEach((day) => { 159 + day.level = getLevel(day.count, maxCount); 160 + }); 161 + 162 + // Group into weeks (7 days each, Sunday = 0) 163 + const weeks: ContributionDay[][] = []; 164 + for (let i = 0; i < contributionDays.length; i += 7) { 165 + weeks.push(contributionDays.slice(i, i + 7)); 166 + } 167 + 168 + // Pad first week if needed to start on Sunday 169 + if (weeks.length > 0 && weeks[0].length < 7) { 170 + const firstDay = new Date(weeks[0][0].date); 171 + const dayOfWeek = firstDay.getDay(); 172 + const padding: ContributionDay[] = []; 173 + for (let i = 0; i < dayOfWeek; i++) { 174 + padding.push({ date: '', count: 0, level: 0 }); 175 + } 176 + weeks[0] = [...padding, ...weeks[0]]; 177 + } 178 + 179 + const data: ContributionData = { 180 + total: contributionDays.reduce((sum, d) => sum + d.count, 0), 181 + days: contributionDays, 182 + weeks 183 + }; 184 + 185 + setCached(cacheKey, data); 186 + return data; 187 + } 188 + 189 + /** 190 + * Get month labels for the contribution graph 191 + */ 192 + export function getMonthLabels(days: number = 90): { month: string; index: number }[] { 193 + const dateRange = generateDateRange(days); 194 + const months: { month: string; index: number }[] = []; 195 + let lastMonth = ''; 196 + 197 + dateRange.forEach((date, index) => { 198 + const monthName = date.toLocaleString('en-US', { month: 'short' }); 199 + if (monthName !== lastMonth) { 200 + months.push({ month: monthName, index }); 201 + lastMonth = monthName; 202 + } 203 + }); 204 + 205 + return months; 206 + }
+2
src/lib/services/github/index.ts
··· 2 2 * GitHub service exports 3 3 */ 4 4 export { fetchGitHubProfile, fetchGitHubRepos, fetchRepoLanguages, fetchNotableRepos, fetchGitHubData } from './fetch'; 5 + export { fetchContributions, getMonthLabels } from './contributions'; 5 6 export type { GitHubProfile, GitHubRepo, GitHubLanguageStats, GitHubRepoWithDetails } from './types'; 7 + export type { ContributionDay, ContributionData } from './contributions';
+64 -2
src/routes/github/+page.svelte
··· 5 5 import { formatCompactNumber } from '$lib/utils/formatNumber'; 6 6 import { formatLocalizedDate, getUserLocale } from '$lib/utils/locale'; 7 7 import { createSiteMeta, defaultSiteMeta } from '$lib/helper/siteMeta'; 8 - 9 8 let { data }: { data: PageData } = $props(); 10 - const { profile, repos } = data; 9 + const { profile, repos, contributions } = data; 11 10 12 11 const locale = getUserLocale(); 13 12 ··· 172 171 </div> 173 172 {/snippet} 174 173 </Card> 174 + 175 + <!-- Contribution Graph --> 176 + <section> 177 + <Card variant="elevated" padding="lg"> 178 + {#snippet children()} 179 + <div class="flex items-center justify-between"> 180 + <h2 class="text-xl font-bold text-ink-900 dark:text-ink-50"> 181 + {formatCompactNumber(contributions.total, locale)} contributions 182 + </h2> 183 + <span class="text-sm text-ink-600 dark:text-ink-300">last 90 days</span> 184 + </div> 185 + 186 + <!-- Contribution grid --> 187 + <div class="mt-4 overflow-x-auto"> 188 + <div class="flex gap-[3px]" style="width: min-content;"> 189 + {#each contributions.weeks as week} 190 + <div class="flex flex-col gap-[3px]"> 191 + {#each week as day} 192 + <div 193 + class="h-3 w-3 rounded-sm transition-colors" 194 + class:bg-canvas-200={day.level === 0} 195 + class:dark:bg-canvas-700={day.level === 0} 196 + class:bg-primary-200={day.level === 1} 197 + class:dark:bg-primary-900={day.level === 1} 198 + class:bg-primary-300={day.level === 2} 199 + class:dark:bg-primary-700={day.level === 2} 200 + class:bg-primary-400={day.level === 3} 201 + class:dark:bg-primary-500={day.level === 3} 202 + class:bg-primary-600={day.level === 4} 203 + class:dark:bg-primary-400={day.level === 4} 204 + title={day.date ? `${day.count} contribution${day.count !== 1 ? 's' : ''} on ${day.date}` : ''} 205 + role="img" 206 + aria-label={day.date ? `${day.count} contribution${day.count !== 1 ? 's' : ''} on ${day.date}` : ''} 207 + ></div> 208 + {/each} 209 + </div> 210 + {/each} 211 + </div> 212 + </div> 213 + 214 + <!-- Legend --> 215 + <div class="mt-4 flex items-center justify-end gap-2 text-xs text-ink-600 dark:text-ink-300"> 216 + <span>Less</span> 217 + {#each [0, 1, 2, 3, 4] as level} 218 + <div 219 + class="h-3 w-3 rounded-sm" 220 + class:bg-canvas-200={level === 0} 221 + class:dark:bg-canvas-700={level === 0} 222 + class:bg-primary-200={level === 1} 223 + class:dark:bg-primary-900={level === 1} 224 + class:bg-primary-300={level === 2} 225 + class:dark:bg-primary-700={level === 2} 226 + class:bg-primary-400={level === 3} 227 + class:dark:bg-primary-500={level === 3} 228 + class:bg-primary-600={level === 4} 229 + class:dark:bg-primary-400={level === 4} 230 + ></div> 231 + {/each} 232 + <span>More</span> 233 + </div> 234 + {/snippet} 235 + </Card> 236 + </section> 175 237 176 238 <!-- Repositories Grid --> 177 239 <section>
+6 -3
src/routes/github/+page.ts
··· 1 1 import type { PageLoad } from './$types'; 2 - import { fetchGitHubData } from '$lib/services/github'; 2 + import { fetchGitHubData, fetchContributions } from '$lib/services/github'; 3 3 4 4 const GITHUB_USERNAME = 'ewanc26'; 5 5 6 6 export const load: PageLoad = async ({ fetch }) => { 7 - const { profile, repos } = await fetchGitHubData(GITHUB_USERNAME, fetch); 8 - return { profile, repos }; 7 + const [profileData, contributions] = await Promise.all([ 8 + fetchGitHubData(GITHUB_USERNAME, fetch), 9 + fetchContributions(GITHUB_USERNAME, fetch, 90) 10 + ]); 11 + return { ...profileData, contributions }; 9 12 };