Skip to content

Commit 32f99d9

Browse files
committed
feat(achievements): add progress estimation for locked achievements
Closes #1754 Adds a new achievement progress estimator that computes evidence-based progress estimates for GitHub achievements a user has not yet earned. Achievement estimation strategy --------------------------------- Only achievements with a reliable data proxy are given numeric estimates. Achievements without sufficient data explicitly report "Progress unavailable" rather than displaying a fabricated percentage. pull-shark — estimated from all-time merged PR count via GitHub GraphQL viewer.pullRequests(states:[MERGED]) galaxy-brain — estimated from all-time accepted discussion-answer count via viewer.repositoryDiscussionComments(onlyAnswers:true) quickdraw — unavailable (requires real-time close timing data) pair-extraordinaire — unavailable (co-authored-commit query not in stack) Milestone tiers match the official GitHub thresholds: Pull Shark: Bronze 2 / Silver 16 / Gold 128 merged PRs Galaxy Brain: Bronze 2 / Silver 8 / Gold 16 accepted answers Files added ----------- src/lib/achievement-progress.ts Pure functions: computeMilestoneProgress, estimatePullSharkProgress, estimateGalaxyBrainProgress, getQuickdrawProgress, getPairExtraordinaireProgress, buildLockedAchievementProgress. AchievementProgressInfo and AchievementMilestone types. src/app/api/metrics/achievement-progress/route.ts New GET endpoint. Fetches merged-PR and accepted-answer counts with one GitHub GraphQL query, intersects with the user's cached unlocked achievements, and returns AchievementProgressInfo[] only for locked achievements. Cached 10 min; handles 401/revocation and transient network failures gracefully. src/components/GitHubAchievementProgress.tsx Client widget showing a 2-column card grid of locked-achievement progress. Displays an animated progress bar + percentage when data is available, or "Progress unavailable" otherwise. Each card notes next tier name and the "Estimated progress" label. test/achievement-progress.test.ts 48 tests covering computeMilestoneProgress boundary cases, estimatePullSharkProgress, estimateGalaxyBrainProgress, getQuickdrawProgress, getPairExtraordinaireProgress, and buildLockedAchievementProgress (locked/unlocked filtering, mixed data availability, sentCount accuracy). Files modified -------------- src/app/dashboard/page.tsx Imports GitHubAchievementProgress and adds it to the Goals & Insights section sidebar via LazyWidget.
1 parent 7a748bd commit 32f99d9

5 files changed

Lines changed: 839 additions & 0 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { getServerSession } from "next-auth";
2+
import { NextRequest } from "next/server";
3+
import { authOptions } from "@/lib/auth";
4+
import { resolveAppUser } from "@/lib/resolve-user";
5+
import { getCachedGitHubAchievements } from "@/lib/github-achievements";
6+
import { buildLockedAchievementProgress } from "@/lib/achievement-progress";
7+
import { isMetricsCacheBypassed, withMetricsCache } from "@/lib/metrics-cache";
8+
9+
export const dynamic = "force-dynamic";
10+
11+
// Fetches all-time merged-PR count and all-time accepted-discussion-answer
12+
// count for the authenticated viewer in a single round-trip.
13+
const ACHIEVEMENT_PROGRESS_QUERY = `
14+
query DevTrackAchievementProgress {
15+
viewer {
16+
pullRequests(states: [MERGED]) {
17+
totalCount
18+
}
19+
repositoryDiscussionComments(onlyAnswers: true) {
20+
totalCount
21+
}
22+
}
23+
}
24+
`;
25+
26+
interface AchievementProgressGQLResponse {
27+
data?: {
28+
viewer?: {
29+
pullRequests?: { totalCount: number };
30+
repositoryDiscussionComments?: { totalCount: number };
31+
} | null;
32+
};
33+
errors?: Array<{ message?: string }>;
34+
}
35+
36+
export async function GET(req: NextRequest) {
37+
const session = await getServerSession(authOptions);
38+
39+
if (!session?.accessToken || !session.githubId || !session.githubLogin) {
40+
return Response.json({ error: "Unauthorized" }, { status: 401 });
41+
}
42+
if (session.error === "TokenRevoked") {
43+
return Response.json({ error: "token_expired" }, { status: 401 });
44+
}
45+
46+
const user = await resolveAppUser(session.githubId, session.githubLogin);
47+
if (!user) {
48+
return Response.json({ error: "Unauthorized" }, { status: 401 });
49+
}
50+
51+
const bypass = isMetricsCacheBypassed(req);
52+
// Build the cache key directly — avoids coupling this endpoint to the
53+
// METRICS_CACHE_TTL_SECONDS const which only maps already-known endpoints.
54+
const cacheKey = `metrics:${user.id}:achievement-progress:default`;
55+
const TTL_SECONDS = 10 * 60; // 10 minutes
56+
57+
try {
58+
const result = await withMetricsCache(
59+
{ bypass, key: cacheKey, ttlSeconds: TTL_SECONDS },
60+
async () => {
61+
let mergedPrCount: number | null = null;
62+
let acceptedAnswers: number | null = null;
63+
let tokenRevoked = false;
64+
65+
try {
66+
const gqlRes = await fetch("https://api.github.com/graphql", {
67+
method: "POST",
68+
headers: {
69+
Authorization: `Bearer ${session.accessToken!}`,
70+
"Content-Type": "application/json",
71+
},
72+
body: JSON.stringify({ query: ACHIEVEMENT_PROGRESS_QUERY }),
73+
cache: "no-store",
74+
});
75+
76+
if (gqlRes.status === 401) {
77+
tokenRevoked = true;
78+
} else if (gqlRes.ok) {
79+
const gqlData = (await gqlRes.json()) as AchievementProgressGQLResponse;
80+
const viewer = gqlData.data?.viewer;
81+
if (typeof viewer?.pullRequests?.totalCount === "number") {
82+
mergedPrCount = viewer.pullRequests.totalCount;
83+
}
84+
if (typeof viewer?.repositoryDiscussionComments?.totalCount === "number") {
85+
acceptedAnswers = viewer.repositoryDiscussionComments.totalCount;
86+
}
87+
}
88+
// Non-200/non-401 responses (rate limit, 5xx) leave counts as null
89+
// so affected achievements report "Progress unavailable".
90+
} catch (err) {
91+
// Transient network error — counts remain null.
92+
console.warn("[achievement-progress] GitHub GraphQL fetch failed:", err);
93+
}
94+
95+
if (tokenRevoked) {
96+
// Surface as a sentinel so the outer handler can return 401.
97+
throw Object.assign(new Error("token_expired"), { code: "TOKEN_REVOKED" });
98+
}
99+
100+
// Determine which achievements the user has already unlocked so we
101+
// only surface progress for locked ones.
102+
const cached = await getCachedGitHubAchievements(user.id);
103+
const unlockedSlugs = new Set(
104+
(cached?.achievements ?? []).map((a) => a.slug)
105+
);
106+
107+
const progress = buildLockedAchievementProgress(
108+
unlockedSlugs,
109+
mergedPrCount,
110+
acceptedAnswers
111+
);
112+
113+
return { progress };
114+
}
115+
);
116+
117+
return Response.json(result);
118+
} catch (err) {
119+
if (
120+
err instanceof Error &&
121+
(err as Error & { code?: string }).code === "TOKEN_REVOKED"
122+
) {
123+
return Response.json({ error: "token_expired" }, { status: 401 });
124+
}
125+
console.error("[achievement-progress] handler failed:", err);
126+
return Response.json(
127+
{ error: "Failed to load achievement progress" },
128+
{ status: 502 }
129+
);
130+
}
131+
}

src/app/dashboard/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { redirect } from "next/navigation";
2929
import DashboardSSEProvider from "@/components/DashboardSSEProvider";
3030
import DailyNoteWidget from "@/components/DailyNoteWidget";
3131
import WidgetErrorBoundary from "@/components/WidgetErrorBoundary";
32+
import GitHubAchievementProgress from "@/components/GitHubAchievementProgress";
3233

3334
const SkeletonCard = () => (
3435
<div
@@ -304,6 +305,9 @@ export default async function DashboardPage() {
304305
<LazyWidget fallback={<SkeletonCard />}>
305306
<LanguageBreakdown />
306307
</LazyWidget>
308+
<LazyWidget fallback={<SkeletonCard />}>
309+
<GitHubAchievementProgress />
310+
</LazyWidget>
307311
<LazyWidget fallback={<SkeletonCard />}>
308312
<FriendComparison />
309313
</LazyWidget>
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"use client";
2+
3+
import { useCallback, useEffect, useState } from "react";
4+
import type { AchievementProgressInfo } from "@/lib/achievement-progress";
5+
6+
interface AchievementProgressResponse {
7+
progress: AchievementProgressInfo[];
8+
}
9+
10+
function ProgressBar({ value }: { value: number }) {
11+
return (
12+
<div
13+
role="progressbar"
14+
aria-valuenow={value}
15+
aria-valuemin={0}
16+
aria-valuemax={100}
17+
aria-label={`${value}% complete`}
18+
className="h-2 w-full overflow-hidden rounded-full bg-[var(--border)]"
19+
>
20+
<div
21+
className="h-full rounded-full bg-[var(--accent)] transition-all duration-500"
22+
style={{ width: `${value}%` }}
23+
/>
24+
</div>
25+
);
26+
}
27+
28+
function AchievementCard({ item }: { item: AchievementProgressInfo }) {
29+
return (
30+
<div className="rounded-lg border border-[var(--border)] bg-[var(--control)] p-4 space-y-3">
31+
<div className="flex items-start justify-between gap-2">
32+
<div className="min-w-0">
33+
<p className="text-sm font-semibold text-[var(--card-foreground)] truncate">
34+
{item.title}
35+
</p>
36+
<p className="mt-0.5 text-xs text-[var(--muted-foreground)] line-clamp-2">
37+
{item.description}
38+
</p>
39+
</div>
40+
{item.dataAvailable && item.nextMilestone && (
41+
<span className="shrink-0 rounded-full border border-[var(--border)] bg-[var(--card)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-[var(--muted-foreground)]">
42+
{item.nextMilestone.label}
43+
</span>
44+
)}
45+
</div>
46+
47+
{item.dataAvailable ? (
48+
<>
49+
<ProgressBar value={item.estimatedProgress} />
50+
<div className="flex items-center justify-between gap-2">
51+
<p className="text-xs text-[var(--muted-foreground)]">
52+
{item.progressDescription}
53+
</p>
54+
<span className="shrink-0 text-xs font-semibold text-[var(--accent)]">
55+
{item.estimatedProgress}%
56+
</span>
57+
</div>
58+
<p className="text-[10px] text-[var(--muted-foreground)] italic">
59+
Estimated progress
60+
</p>
61+
</>
62+
) : (
63+
<p className="text-xs text-[var(--muted-foreground)]">
64+
Progress unavailable
65+
</p>
66+
)}
67+
</div>
68+
);
69+
}
70+
71+
export default function GitHubAchievementProgress() {
72+
const [items, setItems] = useState<AchievementProgressInfo[]>([]);
73+
const [loading, setLoading] = useState(true);
74+
const [error, setError] = useState<string | null>(null);
75+
76+
const fetchProgress = useCallback(() => {
77+
setLoading(true);
78+
setError(null);
79+
fetch("/api/metrics/achievement-progress")
80+
.then(async (res) => {
81+
if (!res.ok) throw new Error("API error");
82+
return res.json() as Promise<AchievementProgressResponse>;
83+
})
84+
.then((data) => setItems(data.progress ?? []))
85+
.catch(() =>
86+
setError(
87+
"We couldn't load achievement progress right now. Please try again in a moment."
88+
)
89+
)
90+
.finally(() => setLoading(false));
91+
}, []);
92+
93+
useEffect(() => {
94+
fetchProgress();
95+
}, [fetchProgress]);
96+
97+
return (
98+
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm transition-all duration-300 hover:shadow-md hover:-translate-y-1">
99+
<div className="mb-4 flex items-center justify-between gap-2">
100+
<div>
101+
<h2 className="text-lg font-semibold text-[var(--card-foreground)]">
102+
Achievement Progress
103+
</h2>
104+
<p className="mt-0.5 text-xs text-[var(--muted-foreground)]">
105+
Estimated progress toward locked GitHub achievements
106+
</p>
107+
</div>
108+
</div>
109+
110+
{loading ? (
111+
<div
112+
role="status"
113+
aria-live="polite"
114+
aria-busy="true"
115+
className="grid grid-cols-1 gap-3 sm:grid-cols-2"
116+
>
117+
<span className="sr-only">Loading achievement progress</span>
118+
{[1, 2, 3, 4].map((i) => (
119+
<div
120+
key={i}
121+
aria-hidden="true"
122+
className="h-28 rounded-lg skeleton-shimmer"
123+
/>
124+
))}
125+
</div>
126+
) : error ? (
127+
<div className="rounded-lg border border-[var(--destructive)]/20 bg-[var(--destructive)]/10 p-4 text-sm text-[var(--destructive)]">
128+
<p>{error}</p>
129+
<button
130+
type="button"
131+
onClick={fetchProgress}
132+
className="mt-3 rounded-md border border-[var(--destructive)]/30 px-3 py-1.5 text-xs font-medium text-[var(--destructive)] transition-colors hover:bg-[var(--destructive)]/10"
133+
>
134+
Try again
135+
</button>
136+
</div>
137+
) : items.length === 0 ? (
138+
<p className="text-sm text-[var(--muted-foreground)]">
139+
All tracked achievements have been unlocked — check your GitHub profile for the full list.
140+
</p>
141+
) : (
142+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
143+
{items.map((item) => (
144+
<AchievementCard key={item.slug} item={item} />
145+
))}
146+
</div>
147+
)}
148+
</div>
149+
);
150+
}

0 commit comments

Comments
 (0)