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;

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

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

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