-
Notifications
You must be signed in to change notification settings - Fork 441
feat(frontend): add optional contribution graph to SVG embed card #386
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
60651ec
2556d1e
c5e7c70
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||||||||||||||
|
|
@@ -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, | ||||||||||||||
|
|
@@ -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 }); | ||||||||||||||
|
|
@@ -64,11 +70,14 @@ export async function GET(request: NextRequest, { params }: RouteParams) { | |||||||||||||
| return createSvgResponse(svg, { status: 200 }); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const contributions = showGraph ? await getUserEmbedContributions(username) : null; | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
|
||||||||||||||
| const contributions = showGraph ? await getUserEmbedContributions(username) : null; | |
| const contributions = showGraph && !compact ? await getUserEmbedContributions(username) : null; |
| 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; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
AI
Apr 1, 2026
There was a problem hiding this comment.
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
AI
Apr 1, 2026
There was a problem hiding this comment.
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.
| .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, | |
| }; | |
| }); |
Uh oh!
There was an error while loading. Please reload this page.