Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 94 additions & 18 deletions packages/frontend/__tests__/lib/renderProfileEmbedSvg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ describe("renderProfileEmbedSvg", () => {

expect(svg).toContain("<svg");
expect(svg).toContain("Tokscale Stats");
expect(svg).toContain("README EMBED");
expect(svg).toContain("@octocat");
expect(svg).toContain("1,234,567");
expect(svg).toContain("$42.42");
Expand All @@ -47,9 +46,9 @@ describe("renderProfileEmbedSvg", () => {

expect(svg).toContain('width="460"');
expect(svg).toContain('height="162"');
expect(svg).toContain("README EMBED");
expect(svg).toContain("Tokscale Stats");
expect(svg).toContain("@octocat");
expect(svg).toContain('stop-color="#F6FAFF"');
expect(svg).toContain('stop-color="#FFFFFF"');
expect(svg).not.toContain("Submissions");
});

Expand All @@ -69,31 +68,33 @@ describe("renderProfileEmbedSvg", () => {
expect(costSvg).toContain("RANK · COST");
});

it("uses gradient tokens, green cost, and medal rank colors", () => {
it("uses gradient tokens, green cost, and rank-specific colors", () => {
const svg = renderProfileEmbedSvg(mockStats);

expect(svg).toContain('id="tokens-gradient"');
expect(svg).toContain('fill="url(#tokens-gradient)"');
expect(svg).toContain('fill="#53D18C"');
expect(svg).toContain('fill="#D97706"');
expect(svg).toContain('id="token-grad"');
expect(svg).toContain('fill="url(#token-grad)"');
expect(svg).toContain('fill="#3FB950"');
expect(svg).toContain('fill="#DA7E1A"');
});

it("uses gold color for rank #1", () => {
const svg = renderProfileEmbedSvg({
...mockStats,
stats: { ...mockStats.stats, rank: 1 },
});
expect(svg).toContain('fill="#EAB308"');
expect(svg).toContain('fill="#E3B341"');
});

it("renders branded gradient surfaces for the refreshed card", () => {
it("renders redesigned card structure with brand icon and accent bars", () => {
const svg = renderProfileEmbedSvg(mockStats);

expect(svg).toContain('id="card-bg"');
expect(svg).toContain('id="shell-bg"');
expect(svg).toContain('id="header-bg"');
expect(svg).toContain('id="metric-sheen"');
expect(svg).toContain('filter="url(#soft-glow)"');
expect(svg).toContain('id="bg"');
expect(svg).toContain('id="glow"');
expect(svg).toContain('id="divider-grad"');
expect(svg).toContain('id="acc-tokens"');
expect(svg).toContain('id="acc-cost"');
expect(svg).toContain('id="acc-rank"');
expect(svg).toContain('clip-path="url(#card-clip)"');
});

it("escapes XML in user-provided text", () => {
Expand Down Expand Up @@ -122,7 +123,7 @@ describe("renderProfileEmbedSvg", () => {
const displayNameTag = svg.match(/<text x="(\d+(?:\.\d+)?)"[^>]*>The Octocat<\/text>/);
expect(displayNameTag).toBeTruthy();
const x = Number(displayNameTag![1]);
expect(x).toBeGreaterThanOrEqual(20 + 18 + 8 * 9 + 8);
expect(x).toBeGreaterThanOrEqual(24 + 8 * 17 * 0.6 + 8);
});

it("hides display name when username is too long to leave room", () => {
Expand Down Expand Up @@ -170,17 +171,92 @@ describe("renderProfileEmbedSvg", () => {
expect(compactSvg).toContain(expectedDisplayName);
expect(defaultSvg).toContain(expectedDisplayName);
});

it("auto-scales font size for very long token values", () => {
const svg = renderProfileEmbedSvg({
...mockStats,
stats: { ...mockStats.stats, totalTokens: 15726314363 },
});

expect(svg).toContain("15,726,314,363");
const valueTag = svg.match(/font-size="(\d+)"[^>]*font-weight="800"[^>]*>15,726,314,363/);
expect(valueTag).toBeTruthy();
const fontSize = Number(valueTag![1]);
expect(fontSize).toBeLessThan(28);
expect(fontSize).toBeGreaterThanOrEqual(14);
});
});

describe("renderProfileEmbedSvg with contributions graph", () => {
const mockContributions = [
{ date: "2026-01-15", intensity: 0 as const },
{ date: "2026-02-10", intensity: 2 as const },
{ date: "2026-02-20", intensity: 4 as const },
];

it("extends card height when contributions provided", () => {
const withoutGraph = renderProfileEmbedSvg(mockStats);
const withGraph = renderProfileEmbedSvg(mockStats, { contributions: mockContributions });

expect(withoutGraph).toContain('height="186"');
const heightMatch = withGraph.match(/height="(\d+)"/);
expect(heightMatch).toBeTruthy();
expect(Number(heightMatch![1])).toBeGreaterThan(186);
});

it("renders GitHub-style contribution grid cells", () => {
const svg = renderProfileEmbedSvg(mockStats, { contributions: mockContributions });

expect(svg).toContain('rx="2"');
expect(svg).toContain('fill="#161B22"');
expect(svg).toContain("Less");
expect(svg).toContain("More");
});

it("renders day labels (Mon, Wed, Fri)", () => {
const svg = renderProfileEmbedSvg(mockStats, { contributions: mockContributions });

expect(svg).toContain(">Mon<");
expect(svg).toContain(">Wed<");
expect(svg).toContain(">Fri<");
});

it("renders month labels", () => {
const svg = renderProfileEmbedSvg(mockStats, { contributions: mockContributions });

expect(svg).toContain(">Jan<");
});

it("ignores contributions in compact mode", () => {
const svg = renderProfileEmbedSvg(mockStats, { compact: true, contributions: mockContributions });

expect(svg).toContain('height="162"');
expect(svg).not.toContain("Less");
expect(svg).not.toContain("More");
});

it("uses light theme graph colors", () => {
const svg = renderProfileEmbedSvg(mockStats, { theme: "light", contributions: mockContributions });

expect(svg).toContain('fill="#EBEDF0"');
});

it("does not render graph when contributions is null", () => {
const svg = renderProfileEmbedSvg(mockStats, { contributions: null });

expect(svg).toContain('height="186"');
expect(svg).not.toContain("Less");
});
});

describe("renderProfileEmbedErrorSvg", () => {
it("renders safe fallback SVG", () => {
const svg = renderProfileEmbedErrorSvg("User <unknown>", { theme: "light" });

expect(svg).toContain("Tokscale Stats");
expect(svg).toContain("README EMBED");
expect(svg).toContain("User &lt;unknown&gt;");
expect(svg).not.toContain("User <unknown>");
expect(svg).toContain("family=Figtree");
expect(svg).toContain('id="error-bg"');
expect(svg).toContain('id="err-bg"');
});
});
12 changes: 11 additions & 1 deletion packages/frontend/src/app/api/embed/[username]/svg/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { getUserEmbedStats, type EmbedSortBy } from "@/lib/embed/getUserEmbedStats";
import { getUserEmbedStats, getUserEmbedContributions, type EmbedSortBy } from "@/lib/embed/getUserEmbedStats";
import {
renderProfileEmbedErrorSvg,
renderProfileEmbedSvg,
Expand All @@ -23,6 +23,11 @@ function parseSort(searchParams: URLSearchParams): EmbedSortBy {
return value === "cost" ? "cost" : "tokens";
}

function parseGraph(searchParams: URLSearchParams): boolean {
const value = searchParams.get("graph");
return value === "1" || value === "true";
}

function createSvgResponse(svg: string, init?: { status?: number; cacheControl?: string }) {
return new NextResponse(svg, {
status: init?.status ?? 200,
Expand All @@ -47,6 +52,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const theme = parseTheme(searchParams);
const compact = parseCompact(searchParams);
const sortBy = parseSort(searchParams);
const showGraph = parseGraph(searchParams);

if (!isValidGitHubUsername(username)) {
const svg = renderProfileEmbedErrorSvg("Invalid username format", { theme, compact: true });
Expand All @@ -64,11 +70,14 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
return createSvgResponse(svg, { status: 200 });
}

const contributions = showGraph ? await getUserEmbedContributions(username) : null;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

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

const svg = renderProfileEmbedSvg(data, {
theme,
compact,
compactNumbers: compact,
sortBy,
contributions,
});

console.info("[embed-svg] success", {
Expand All @@ -78,6 +87,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
compact,
sortBy,
theme,
graph: showGraph,
});

return createSvgResponse(svg);
Expand Down
58 changes: 56 additions & 2 deletions packages/frontend/src/lib/embed/getUserEmbedStats.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { unstable_cache } from "next/cache";
import { db, users, submissions } from "@/lib/db";
import { eq, sql } from "drizzle-orm";
import { db, users, submissions, dailyBreakdown } from "@/lib/db";
import { eq, sql, and, gte } from "drizzle-orm";

export type EmbedSortBy = "tokens" | "cost";

export interface EmbedContributionDay {
date: string;
intensity: 0 | 1 | 2 | 3 | 4;
}

export interface UserEmbedStats {
user: {
id: string;
Expand Down Expand Up @@ -91,3 +96,52 @@ export function getUserEmbedStats(username: string, sortBy: EmbedSortBy = "token
}
)();
}

async function fetchUserEmbedContributions(username: string): Promise<EmbedContributionDay[] | null> {
const [user] = await db
.select({ id: users.id })
.from(users)
.where(eq(users.username, username))
.limit(1);

if (!user) return null;

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

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.

const rows = await db
.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,
Comment on lines +127 to +134

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.
}));
Comment on lines +114 to +135

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

export function getUserEmbedContributions(username: string): Promise<EmbedContributionDay[] | null> {
return unstable_cache(
() => fetchUserEmbedContributions(username),
[`embed-contrib:${username}`],
{
tags: [`user:${username}`, `embed-contrib:${username}`],
revalidate: 60,
}
)();
}
Loading