-
-
+
+
+
+
Last 18 weeks
+
+
+ {{ stats.streak }}-day streak
+
+
+
+
+
+
+
+
+ {{ hovered.text }}
+
+
+
+
+
+
-
+
{{ fmt(stats?.[item.key]) }}
-
{{ item.label }}
+
{{ item.label }}
+
+ +{{ stats[item.monthKey] }} this month
+
+
+
+
+
+
+
Languages
+
+
+
+
+
+
+
+ {{ lang.name }} {{ Math.round(lang.percent) }}%
+
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 700d601..cb9f370 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -61,7 +61,7 @@ export default defineNuxtConfig({
},
runtimeConfig: {
- // Optional GitHub token (set NUXT_GITHUB_TOKEN) for higher API rate limits.
+ // GitHub token (set NUXT_GITHUB_TOKEN); required by the GraphQL API behind /api/github.
githubToken: '',
// Resend (set NUXT_RESEND_API_KEY). `onboarding@resend.dev` works without a
// verified domain and delivers to your Resend account email.
diff --git a/server/api/github.get.ts b/server/api/github.get.ts
index 14ed5c8..8773c07 100644
--- a/server/api/github.get.ts
+++ b/server/api/github.get.ts
@@ -1,60 +1,128 @@
/**
- * Aggregates profile-wide GitHub stats via the public search + users APIs.
- *
- * Cached for an hour (with stale-while-revalidate), so GitHub is hit at most
- * ~once/hour regardless of traffic. A GITHUB_TOKEN bumps the rate limit further.
+ * Profile stats, contribution calendar and language mix from one GraphQL
+ * request (1 rate-limit point) plus two REST commit searches (GraphQL search
+ * can't query commits), so it can be cached briefly and stay fresh.
+ * GitHub's GraphQL API requires a token (NUXT_GITHUB_TOKEN).
*/
-const ONE_HOUR = 60 * 60
+const HEATMAP_WEEKS = 18
+
+const QUERY = /* GraphQL */ `
+query ($login: String!, $prs: String!, $prsMonth: String!) {
+ prs: search(query: $prs, type: ISSUE) { issueCount }
+ prsMonth: search(query: $prsMonth, type: ISSUE) { issueCount }
+ user(login: $login) {
+ contributionsCollection {
+ contributionCalendar {
+ weeks { contributionDays { date contributionCount contributionLevel } }
+ }
+ }
+ repositories(ownerAffiliations: OWNER, isFork: false, privacy: PUBLIC, first: 100) {
+ nodes {
+ languages(first: 10, orderBy: { field: SIZE, direction: DESC }) {
+ edges { size node { name color } }
+ }
+ }
+ }
+ }
+}`
+
+interface Day { date: string; contributionCount: number; contributionLevel: string }
+interface Response {
+ data?: {
+ prs: { issueCount: number }
+ prsMonth: { issueCount: number }
+ user: {
+ contributionsCollection: { contributionCalendar: { weeks: { contributionDays: Day[] }[] } }
+ repositories: {
+ nodes: { languages: { edges: { size: number; node: { name: string; color: string | null } }[] } }[]
+ }
+ }
+ }
+ errors?: { message: string }[]
+}
+
+const LEVELS: Record
= {
+ NONE: 0, FIRST_QUARTILE: 1, SECOND_QUARTILE: 2, THIRD_QUARTILE: 3, FOURTH_QUARTILE: 4,
+}
export default defineCachedEventHandler(async (event) => {
const config = useRuntimeConfig(event)
const username = config.public.github
- const token = config.githubToken
+ const monthStart = new Date().toISOString().slice(0, 8) + '01'
- const headers: Record = {
+ const headers = {
'User-Agent': 'kashall.dev',
Accept: 'application/vnd.github+json',
+ Authorization: `Bearer ${config.githubToken}`,
}
- if (token) headers.Authorization = `Bearer ${token}`
-
- const count = async (q: string): Promise => {
- const res = await $fetch<{ total_count: number }>('https://api.github.com/search/issues', {
+ const commitCount = async (q: string) =>
+ (await $fetch<{ total_count: number }>('https://api.github.com/search/commits', {
query: { q, per_page: 1 },
headers,
- })
- return res.total_count ?? 0
- }
+ })).total_count
// Let GitHub errors (e.g. rate limiting) throw: a thrown handler is NOT
- // cached, so we never persist an all-zero response. SWR keeps serving the
+ // cached, so we never persist an empty response. SWR keeps serving the
// last good value until a fetch succeeds again.
- const [pullRequests, mergedPullRequests, issues, comments] = await Promise.all([
- count(`type:pr author:${username}`),
- count(`type:pr is:merged author:${username}`),
- count(`type:issue author:${username}`),
- count(`commenter:${username} -author:${username}`),
+ const [res, commits, commitsThisMonth] = await Promise.all([
+ $fetch('https://api.github.com/graphql', {
+ method: 'POST',
+ headers,
+ body: {
+ query: QUERY,
+ variables: {
+ login: username,
+ prs: `type:pr author:${username}`,
+ prsMonth: `type:pr author:${username} created:>=${monthStart}`,
+ },
+ },
+ }),
+ commitCount(`author:${username}`),
+ commitCount(`author:${username} author-date:>=${monthStart}`),
])
+ if (!res.data) throw createError({ statusCode: 502, message: res.errors?.[0]?.message ?? 'GitHub API error' })
+
+ const { user } = res.data
+ const days = user.contributionsCollection.contributionCalendar.weeks.flatMap(w => w.contributionDays)
- const user = await $fetch<{ public_repos?: number; followers?: number; following?: number }>(
- `https://api.github.com/users/${username}`,
- { headers },
- ).catch(() => ({}))
+ // Today only breaks the streak once it's over, so skip it while it's empty.
+ let streak = 0
+ for (let i = days.length - 1; i >= 0; i--) {
+ if (days[i]!.contributionCount > 0) streak++
+ else if (i !== days.length - 1) break
+ }
+
+ const sizes = new Map()
+ for (const repo of user.repositories.nodes) {
+ for (const { size, node } of repo.languages.edges) {
+ const entry = sizes.get(node.name) ?? { size: 0, color: node.color }
+ entry.size += size
+ sizes.set(node.name, entry)
+ }
+ }
+ const totalSize = [...sizes.values()].reduce((sum, l) => sum + l.size, 0)
+ const languages = [...sizes.entries()]
+ .sort((a, b) => b[1].size - a[1].size)
+ .slice(0, 5)
+ .map(([name, l]) => ({ name, color: l.color, percent: totalSize ? (l.size / totalSize) * 100 : 0 }))
return {
username,
- pullRequests,
- mergedPullRequests,
- issues,
- comments,
- repositories: user.public_repos ?? 0,
- followers: user.followers ?? 0,
- following: user.following ?? 0,
+ pullRequests: res.data.prs.issueCount,
+ pullRequestsThisMonth: res.data.prsMonth.issueCount,
+ commits,
+ commitsThisMonth,
+ streak,
+ weeks: user.contributionsCollection.contributionCalendar.weeks.slice(-HEATMAP_WEEKS).map(w =>
+ w.contributionDays.map(d => ({ date: d.date, count: d.contributionCount, level: LEVELS[d.contributionLevel] ?? 0 })),
+ ),
+ languages,
fetchedAt: new Date().toISOString(),
}
}, {
name: 'github-stats',
getKey: () => 'stats',
- maxAge: ONE_HOUR,
- staleMaxAge: ONE_HOUR, // serve stale for up to another hour while revalidating
+ maxAge: 60,
+ staleMaxAge: 60 * 60, // keep serving the last good value through GitHub outages
swr: true,
})