feat(frontend): add optional contribution graph to SVG embed card - #386
Conversation
|
@hellosunghyun is attempting to deploy a commit to the Inevitable Team on Vercel. A member of the Team first needs to authorize it. |
There was a problem hiding this comment.
2 issues found across 4 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/frontend/src/app/api/embed/[username]/svg/route.ts">
<violation number="1" location="packages/frontend/src/app/api/embed/[username]/svg/route.ts:73">
P2: Contribution data is fetched even when `compact` mode ignores the graph, so `?compact=1&graph=1` triggers an unnecessary backend call with no rendering impact.</violation>
<violation number="2" location="packages/frontend/src/app/api/embed/[username]/svg/route.ts:73">
P2: Optional graph fetch can throw and trigger the outer 500 response, preventing the stats-only card from rendering when contributions fail. Consider falling back to null if the graph query errors so the embed still renders base stats.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| return createSvgResponse(svg, { status: 200 }); | ||
| } | ||
|
|
||
| const contributions = showGraph ? await getUserEmbedContributions(username) : null; |
There was a problem hiding this comment.
P2: Optional graph fetch can throw and trigger the outer 500 response, preventing the stats-only card from rendering when contributions fail. Consider falling back to null if the graph query errors so the embed still renders base stats.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/frontend/src/app/api/embed/[username]/svg/route.ts, line 73:
<comment>Optional graph fetch can throw and trigger the outer 500 response, preventing the stats-only card from rendering when contributions fail. Consider falling back to null if the graph query errors so the embed still renders base stats.</comment>
<file context>
@@ -64,11 +70,14 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
return createSvgResponse(svg, { status: 200 });
}
+ const contributions = showGraph ? await getUserEmbedContributions(username) : null;
+
const svg = renderProfileEmbedSvg(data, {
</file context>
| const contributions = showGraph ? await getUserEmbedContributions(username) : null; | |
| const contributions = showGraph | |
| ? await getUserEmbedContributions(username).catch(() => null) | |
| : null; |
There was a problem hiding this comment.
Pull request overview
Adds an optional GitHub-style contribution heatmap to the existing profile SVG embed card, enabled via a ?graph=1|true query parameter, while preserving current output when the graph is disabled.
Changes:
- Introduces a contributions data fetch (
getUserEmbedContributions) backed bydaily_breakdownwith 60s caching. - Extends the SVG renderer to optionally draw a full-year contributions grid + legend and increase card height when enabled (disabled in compact mode).
- Adds test coverage for graph rendering behavior, theming, height changes, and compact-mode exclusion.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
packages/frontend/src/lib/embed/renderProfileEmbedSvg.ts |
Adds theme palette grades and renderContributionGrid() plus dynamic height adjustment. |
packages/frontend/src/lib/embed/getUserEmbedStats.ts |
Adds EmbedContributionDay + cached getUserEmbedContributions() query and intensity mapping. |
packages/frontend/src/app/api/embed/[username]/svg/route.ts |
Adds ?graph= parsing and conditional contributions fetch passed to renderer. |
packages/frontend/__tests__/lib/renderProfileEmbedSvg.test.ts |
Adds tests validating graph presence/absence, height changes, labels, and theme colors. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -1,4 +1,4 @@ | |||
| import type { UserEmbedStats } from "./getUserEmbedStats"; | |||
| import type { UserEmbedStats, EmbedContributionDay } from "./getUserEmbedStats"; | |||
| import { escapeXml, formatCompact, formatNumber, formatCurrency } from "../format"; | |||
There was a problem hiding this comment.
formatCompact is imported but never used in this file. Please remove the unused import (or switch to using it) to avoid dead code and keep lint/noUnusedLocals clean.
| import { escapeXml, formatCompact, formatNumber, formatCurrency } from "../format"; | |
| import { escapeXml, formatNumber, formatCurrency } from "../format"; |
| const oneYearAgo = new Date(); | ||
| oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); | ||
| const cutoff = oneYearAgo.toISOString().split("T")[0]; |
There was a problem hiding this comment.
The contribution query cutoff is based on new Date() minus 1 year, but the renderer’s grid start is aligned to the previous Sunday (and also does +1 day). This can exclude up to ~6 days that are still visible in the first week of the grid, causing those cells to show as 0 even when data exists. Consider computing the cutoff using the same UTC start-date logic as renderContributionGrid() (or querying a few extra days before the cutoff).
| const oneYearAgo = new Date(); | |
| oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); | |
| const cutoff = oneYearAgo.toISOString().split("T")[0]; | |
| const today = new Date(); | |
| // Use UTC-based date and include a small buffer (7 days) before "one year ago" | |
| // so that all dates visible in the first week of the contribution grid are included. | |
| const cutoffDate = new Date(Date.UTC(today.getUTCFullYear() - 1, today.getUTCMonth(), today.getUTCDate())); | |
| cutoffDate.setUTCDate(cutoffDate.getUTCDate() - 7); | |
| const cutoff = cutoffDate.toISOString().split("T")[0]; |
| .select({ date: dailyBreakdown.date, cost: dailyBreakdown.cost }) | ||
| .from(dailyBreakdown) | ||
| .innerJoin(submissions, eq(dailyBreakdown.submissionId, submissions.id)) | ||
| .where(and(eq(submissions.userId, user.id), gte(dailyBreakdown.date, cutoff))) | ||
| .orderBy(dailyBreakdown.date); | ||
|
|
||
| if (rows.length === 0) return []; | ||
|
|
||
| const dayMap = new Map<string, number>(); | ||
| for (const row of rows) { | ||
| dayMap.set(row.date, (dayMap.get(row.date) || 0) + (Number(row.cost) || 0)); | ||
| } | ||
|
|
||
| const costs = Array.from(dayMap.values()).filter((c) => c > 0); | ||
| const maxCost = Math.max(...costs, 0); | ||
|
|
||
| return Array.from(dayMap.entries()).map(([date, cost]) => ({ | ||
| date, | ||
| intensity: ( | ||
| maxCost === 0 ? 0 : cost === 0 ? 0 : cost <= maxCost * 0.25 ? 1 : cost <= maxCost * 0.5 ? 2 : cost <= maxCost * 0.75 ? 3 : 4 | ||
| ) as 0 | 1 | 2 | 3 | 4, | ||
| })); |
There was a problem hiding this comment.
This query fetches every daily_breakdown row for the last year and then aggregates per-day in JavaScript. For users with many submissions, this can be a lot of rows and unnecessary data transfer. Consider aggregating in SQL (SUM(cost) grouped by dailyBreakdown.date) so the DB returns one row per day.
| .select({ date: dailyBreakdown.date, cost: dailyBreakdown.cost }) | |
| .from(dailyBreakdown) | |
| .innerJoin(submissions, eq(dailyBreakdown.submissionId, submissions.id)) | |
| .where(and(eq(submissions.userId, user.id), gte(dailyBreakdown.date, cutoff))) | |
| .orderBy(dailyBreakdown.date); | |
| if (rows.length === 0) return []; | |
| const dayMap = new Map<string, number>(); | |
| for (const row of rows) { | |
| dayMap.set(row.date, (dayMap.get(row.date) || 0) + (Number(row.cost) || 0)); | |
| } | |
| const costs = Array.from(dayMap.values()).filter((c) => c > 0); | |
| const maxCost = Math.max(...costs, 0); | |
| return Array.from(dayMap.entries()).map(([date, cost]) => ({ | |
| date, | |
| intensity: ( | |
| maxCost === 0 ? 0 : cost === 0 ? 0 : cost <= maxCost * 0.25 ? 1 : cost <= maxCost * 0.5 ? 2 : cost <= maxCost * 0.75 ? 3 : 4 | |
| ) as 0 | 1 | 2 | 3 | 4, | |
| })); | |
| .select({ | |
| date: dailyBreakdown.date, | |
| cost: sql<number>`sum(${dailyBreakdown.cost})`.as("cost"), | |
| }) | |
| .from(dailyBreakdown) | |
| .innerJoin(submissions, eq(dailyBreakdown.submissionId, submissions.id)) | |
| .where(and(eq(submissions.userId, user.id), gte(dailyBreakdown.date, cutoff))) | |
| .groupBy(dailyBreakdown.date) | |
| .orderBy(dailyBreakdown.date); | |
| if (rows.length === 0) return []; | |
| const costs = rows | |
| .map((row) => Number(row.cost) || 0) | |
| .filter((c) => c > 0); | |
| const maxCost = Math.max(...costs, 0); | |
| return rows.map((row) => { | |
| const cost = Number(row.cost) || 0; | |
| return { | |
| date: row.date, | |
| intensity: ( | |
| maxCost === 0 | |
| ? 0 | |
| : cost === 0 | |
| ? 0 | |
| : cost <= maxCost * 0.25 | |
| ? 1 | |
| : cost <= maxCost * 0.5 | |
| ? 2 | |
| : cost <= maxCost * 0.75 | |
| ? 3 | |
| : 4 | |
| ) as 0 | 1 | 2 | 3 | 4, | |
| }; | |
| }); |
| const costs = Array.from(dayMap.values()).filter((c) => c > 0); | ||
| const maxCost = Math.max(...costs, 0); | ||
|
|
||
| return Array.from(dayMap.entries()).map(([date, cost]) => ({ | ||
| date, | ||
| intensity: ( | ||
| maxCost === 0 ? 0 : cost === 0 ? 0 : cost <= maxCost * 0.25 ? 1 : cost <= maxCost * 0.5 ? 2 : cost <= maxCost * 0.75 ? 3 : 4 | ||
| ) as 0 | 1 | 2 | 3 | 4, |
There was a problem hiding this comment.
PR description says the intensity is computed using quartiles, but the implementation uses fixed thresholds relative to maxCost (25/50/75% of max). If quartiles are intended, compute percentile cutoffs from the distribution of daily costs (e.g., 25th/50th/75th percentiles of non-zero days) rather than scaling from the max; otherwise please update the description to match the behavior.
| return createSvgResponse(svg, { status: 200 }); | ||
| } | ||
|
|
||
| const contributions = showGraph ? await getUserEmbedContributions(username) : null; |
There was a problem hiding this comment.
When ?graph=1 and ?compact=1 are both set, the route still fetches contributions even though the renderer explicitly ignores them in compact mode. Consider gating the fetch with showGraph && !compact to avoid unnecessary DB work for compact cards.
| const contributions = showGraph ? await getUserEmbedContributions(username) : null; | |
| const contributions = showGraph && !compact ? await getUserEmbedContributions(username) : null; |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…-redesign # Conflicts: # packages/frontend/src/lib/embed/renderProfileEmbedSvg.ts
|
@hellosunghyun thanks! |
junhoyeo
left a comment
There was a problem hiding this comment.
Great addition! Contribution graph looks solid. Review suggestions will be addressed in a follow-up PR.

Summary
?graph=1query parameterUsage
What Changed
Data layer (
getUserEmbedStats.ts)EmbedContributionDaytype ({ date: string, intensity: 0|1|2|3|4 })getUserEmbedContributions()— queriesdaily_breakdowntable for last year, computes cost-based intensity quartiles, cached 60s viaunstable_cacheRender layer (
renderProfileEmbedSvg.ts)contributionsfield added toRenderProfileEmbedOptions(optional, null by default)renderContributionGrid()— builds full-year heatmap grid:#161B22→#39D353), light (#EBEDF0→#216E39)186 + 120when graph is presentRoute layer (
route.ts)parseGraph()for?graph=1|trueTests
Verified
mainSummary by cubic
Adds an optional GitHub‑style contribution graph to the SVG embed card and refreshes the card design. Height grows only when the graph is enabled; no extra queries when off.
New Features
?graph=1|trueon/api/embed/[username]/svgRefactors
token-grad; rank colors updated; badge shows “RANK · TOKENS/COST”Written for commit c5e7c70. Summary will update on new commits.