Skip to content

feat(frontend): add optional contribution graph to SVG embed card - #386

Merged
junhoyeo merged 3 commits into
junhoyeo:mainfrom
hellosunghyun:feat/embed-contribution-graph
Apr 2, 2026
Merged

feat(frontend): add optional contribution graph to SVG embed card#386
junhoyeo merged 3 commits into
junhoyeo:mainfrom
hellosunghyun:feat/embed-contribution-graph

Conversation

@hellosunghyun

@hellosunghyun hellosunghyun commented Apr 1, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add GitHub-style contribution graph (잔디) to the SVG embed card, activated via ?graph=1 query parameter
  • The graph shows the last year of daily activity with GitHub's exact color palette
  • Card height extends from 186px to 306px when graph is enabled; no change when disabled

Usage

[![Tokscale Stats](https://tokscale.ai/api/embed/<username>/svg?graph=1)](https://tokscale.ai/u/<username>)

What Changed

Data layer (getUserEmbedStats.ts)

  • New EmbedContributionDay type ({ date: string, intensity: 0|1|2|3|4 })
  • New getUserEmbedContributions() — queries daily_breakdown table for last year, computes cost-based intensity quartiles, cached 60s via unstable_cache

Render layer (renderProfileEmbedSvg.ts)

  • contributions field added to RenderProfileEmbedOptions (optional, null by default)
  • renderContributionGrid() — builds full-year heatmap grid:
    • 9×9px cells with 2px gap, 52–53 week columns × 7 day rows
    • Day labels (Mon, Wed, Fri) on left, month labels on top
    • "Less □□□□□ More" legend at bottom right
    • GitHub-exact colors: dark (#161B22#39D353), light (#EBEDF0#216E39)
  • Graph ignored in compact mode — only available for full-width (680px) cards
  • Card height computed dynamically: 186 + 120 when graph is present

Route layer (route.ts)

  • New parseGraph() for ?graph=1|true
  • Contributions fetched only when graph param is present (zero overhead otherwise)

Tests

  • 7 new tests covering height extension, grid cells, day/month labels, legend, compact-mode exclusion, light theme colors, and null-contributions handling
  • All 21 tests pass (14 existing + 7 new)

Verified


Summary 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

    • Enable via ?graph=1|true on /api/embed/[username]/svg
    • Shows last year of daily activity with GitHub’s colors (light/dark)
    • Only for full-width cards; compact mode ignores the graph
    • Card height +120px when enabled (186px → 306px)
    • Data fetched only when requested and cached for 60s
  • Refactors

    • Redesigned card with brand icon, subtle divider, and metric accent bars; updated theme colors
    • Token value text uses token-grad; rank colors updated; badge shows “RANK · TOKENS/COST”
    • Auto-scales large metric numbers to fit
    • Error SVG updated to match the new style

Written for commit c5e7c70. Summary will update on new commits.

Copilot AI review requested due to automatic review settings April 1, 2026 14:50
@vercel

vercel Bot commented Apr 1, 2026

Copy link
Copy Markdown
Contributor

@hellosunghyun is attempting to deploy a commit to the Inevitable Team on Vercel.

A member of the Team first needs to authorize it.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/frontend/src/app/api/embed/[username]/svg/route.ts
return createSvgResponse(svg, { status: 200 });
}

const contributions = showGraph ? await getUserEmbedContributions(username) : null;

@cubic-dev-ai cubic-dev-ai Bot Apr 1, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
const contributions = showGraph ? await getUserEmbedContributions(username) : null;
const contributions = showGraph
? await getUserEmbedContributions(username).catch(() => null)
: null;
Fix with Cubic

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 by daily_breakdown with 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";

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
import { escapeXml, formatCompact, formatNumber, formatCurrency } from "../format";
import { escapeXml, formatNumber, formatCurrency } from "../format";

Copilot uses AI. Check for mistakes.
Comment on lines +109 to +111
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const cutoff = oneYearAgo.toISOString().split("T")[0];

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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];

Copilot uses AI. Check for mistakes.
Comment on lines +114 to +135
.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,
}));

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
.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,
};
});

Copilot uses AI. Check for mistakes.
Comment on lines +127 to +134
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,

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
return createSvgResponse(svg, { status: 200 });
}

const contributions = showGraph ? await getUserEmbedContributions(username) : null;

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
const contributions = showGraph ? await getUserEmbedContributions(username) : null;
const contributions = showGraph && !compact ? await getUserEmbedContributions(username) : null;

Copilot uses AI. Check for mistakes.
@vercel

vercel Bot commented Apr 1, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
tokscale Ready Ready Preview, Comment Apr 1, 2026 8:03pm

Request Review

@junhoyeo

junhoyeo commented Apr 1, 2026

Copy link
Copy Markdown
Owner

@hellosunghyun

Copy link
Copy Markdown
Contributor Author

@junhoyeo
You should watch it with the pr below!
The corresponding breaking problem is the one in the main branch right now.
#384

…-redesign

# Conflicts:
#	packages/frontend/src/lib/embed/renderProfileEmbedSvg.ts
@hellosunghyun

Copy link
Copy Markdown
Contributor Author

Combined Preview

This PR now includes a merge commit from the other related PR to show the combined result and avoid confusion during review.

Dark Theme

dark

Light Theme

light

These previews show both PRs (#384 visual redesign + #386 contribution graph) merged together.

@junhoyeo

junhoyeo commented Apr 2, 2026

Copy link
Copy Markdown
Owner

@hellosunghyun thanks!

@junhoyeo junhoyeo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great addition! Contribution graph looks solid. Review suggestions will be addressed in a follow-up PR.

@junhoyeo
junhoyeo merged commit 61602d5 into junhoyeo:main Apr 2, 2026
3 of 4 checks passed
junhoyeo added a commit that referenced this pull request Apr 2, 2026
fix(frontend): address review suggestions from embed card PRs (#384, #386)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants