Skip to content
Open
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
11 changes: 10 additions & 1 deletion app/api/card/[username]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ function resolveCountry(card: Card, override: string | null): Card {
return { ...card, country: pickFlag(override, card.country) ?? "" };
}

// The full daily calendar only exists to feed the result page's contribution
// graph — this JSON API has no other reason to carry ~365 extra rows per
// response, so it's dropped here rather than riding along on every Card.
function omitContributionDays(card: Card): Omit<Card, "contributionDays"> {
const rest = { ...card };
delete rest.contributionDays;
return rest;
}

export async function GET(req: Request, { params }: { params: Promise<{ username: string }> }) {
const { username } = await params;
const override = new URL(req.url).searchParams.get("country");
Expand All @@ -19,7 +28,7 @@ export async function GET(req: Request, { params }: { params: Promise<{ username
try {
const card = await scoutCard(username);
after(() => recordScout());
return Response.json(resolveCountry(card, override));
return Response.json(omitContributionDays(resolveCountry(card, override)));
} catch (e) {
const err = e as GithubError;
const status =
Expand Down
2 changes: 1 addition & 1 deletion app/u/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export default async function Page({
}
return (
<div className="relative min-h-screen overflow-x-hidden text-ink">
<Background />
<Background contributionDays={card?.contributionDays} />
{card ? (
<ScoutRoute card={card} stars={stars} canonicalCountry={canonicalCountry} />
) : (
Expand Down
69 changes: 52 additions & 17 deletions components/Background.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,28 @@
import { buildContributionPanel, type ContributionDay, type ContributionLevel } from "@/lib/contributions";

const noiseSvg =
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120"><filter id="n"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2"/></filter><rect width="120" height="120" filter="url(#n)"/></svg>';
const NOISE = `url("data:image/svg+xml;utf8,${encodeURIComponent(noiseSvg)}")`;

// Faint GitHub-contribution-grid motif — a brand signature drawn into the
// backdrop. A few cells gently pulse green (see .gf-grid-cell in globals.css).
//
// The grid is fully deterministic, so we precompute it ONCE as a static SVG string and inject it via
// On the scout result page this renders the user's REAL calendar (see
// realContribGridSvg below) in this exact spot instead of the fake pattern —
// same geometry/colors either way, so the page never shows two
// different-looking grids, and a real per-user graph costs zero extra layout
// height (this strip is purely decorative, absolutely positioned).
const GRID_CELL = 12;
const GRID_PITCH = 16; // cell + gap
const GRID_RADIUS = 2.5;
const GRID_GREEN = "#39d353";
const GRID_EMPTY = "#1b2530";
const LEVEL_OPACITY: Record<ContributionLevel, number> = { 0: 0, 1: 0.35, 2: 0.55, 3: 0.78, 4: 1 };

// The fake grid is fully deterministic, so we precompute it ONCE as a static SVG string and inject it via
// dangerouslySetInnerHTML. This serializes as a single node in the RSC flight instead of 210 separate
// <rect> flight nodes (each ~90B escaped), shrinking the inline hydration payload — while preserving the
// exact rects, rounded corners, and per-cell pulse animations (class + --gf-dur inlined into the string).
const CONTRIB_GRID_SVG = (() => {
const FAKE_CONTRIB_GRID_SVG = (() => {
const cols = 30;
const rows = 7;
let rects = "";
Expand All @@ -18,25 +31,47 @@ const CONTRIB_GRID_SVG = (() => {
const seed = (r * 7 + c * 13) % 11;
const lit = seed < 3;
const attrs = lit
? ` fill="#39d353" class="gf-grid-cell" style="--gf-dur:${2.4 + seed * 0.4}s"`
: ` fill="#1b2530"`;
rects += `<rect x="${c * 16}" y="${r * 16}" width="12" height="12" rx="2.5"${attrs}/>`;
? ` fill="${GRID_GREEN}" class="gf-grid-cell" style="--gf-dur:${2.4 + seed * 0.4}s"`
: ` fill="${GRID_EMPTY}"`;
rects += `<rect x="${c * GRID_PITCH}" y="${r * GRID_PITCH}" width="${GRID_CELL}" height="${GRID_CELL}" rx="${GRID_RADIUS}"${attrs}/>`;
}
}
return `<svg width="${cols * 16}" height="${rows * 16}" viewBox="0 0 ${cols * 16} ${rows * 16}" style="width:100%;height:100%" aria-hidden="true">${rects}</svg>`;
return `<svg width="${cols * GRID_PITCH}" height="${rows * GRID_PITCH}" viewBox="0 0 ${cols * GRID_PITCH} ${rows * GRID_PITCH}" style="width:100%;height:100%" aria-hidden="true">${rects}</svg>`;
})();

function ContribGrid() {
return (
<div
aria-hidden
style={{ width: "100%", height: "100%" }}
dangerouslySetInnerHTML={{ __html: CONTRIB_GRID_SVG }}
/>
);
// Real per-user version of the same motif: same cell size/rounding/green,
// built from the actual calendar instead of a seeded pattern. Only today's
// cell (if active) pulses — animating all 300+ real cells would read as
// noisy rather than a subtle brand touch. Null when there's no real data, so
// the caller can fall back to the fake grid (demo cards, pre-v2 cache, errors).
function realContribGridSvg(days: ContributionDay[]): string | null {
const data = buildContributionPanel(days);
if (data.weeks.length === 0) return null;

const cols = data.weeks.length;
const rows = 7;
let rects = "";
data.weeks.forEach((week, c) => {
week.forEach((cell, r) => {
if (!cell) return; // outside the fetched range — leave blank, same as ContributionPanel would
const isToday = cell.date === data.latestDate;
const fill = cell.level === 0 ? GRID_EMPTY : GRID_GREEN;
const attrs =
isToday && cell.level > 0
? ` fill="${fill}" fill-opacity="${LEVEL_OPACITY[cell.level]}" class="gf-grid-cell" style="--gf-dur:2.4s"`
: ` fill="${fill}" fill-opacity="${LEVEL_OPACITY[cell.level]}"`;
rects += `<rect x="${c * GRID_PITCH}" y="${r * GRID_PITCH}" width="${GRID_CELL}" height="${GRID_CELL}" rx="${GRID_RADIUS}"${attrs}/>`;
});
});
return `<svg width="${cols * GRID_PITCH}" height="${rows * GRID_PITCH}" viewBox="0 0 ${cols * GRID_PITCH} ${rows * GRID_PITCH}" style="width:100%;height:100%" aria-hidden="true">${rects}</svg>`;
}

function ContribGrid({ contributionDays }: { contributionDays?: ContributionDay[] }) {
const svg = (contributionDays?.length ? realContribGridSvg(contributionDays) : null) ?? FAKE_CONTRIB_GRID_SVG;
return <div aria-hidden style={{ width: "100%", height: "100%" }} dangerouslySetInnerHTML={{ __html: svg }} />;
}

export default function Background() {
export default function Background({ contributionDays }: { contributionDays?: ContributionDay[] } = {}) {
return (
<div className="pointer-events-none absolute inset-0 overflow-hidden bg-bg">
{/* green ambient — the "action" color, top spotlight */}
Expand Down Expand Up @@ -97,7 +132,7 @@ export default function Background() {
className="absolute bottom-0 left-0 right-0 max-[980px]:hidden"
style={{ height: "16%", opacity: 0.5, maskImage: "linear-gradient(to top, #000, transparent)", WebkitMaskImage: "linear-gradient(to top, #000, transparent)" }}
>
<ContribGrid />
<ContribGrid contributionDays={contributionDays} />
</div>
<div className="absolute inset-0" style={{ opacity: 0.04, backgroundImage: NOISE, mixBlendMode: "overlay" }} />
</div>
Expand Down
111 changes: 111 additions & 0 deletions lib/contributions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Pure calendar -> contribution-panel transform. lib/github/client.ts already
// fetches the full per-day contributionCalendar (see RawPayload.contributionDays,
// which owns the ContributionDay shape); this just reshapes those real counts
// into what ContributionPanel renders. No React, no fetching — kept
// framework-agnostic so it's trivially unit-testable.
import type { ContributionDay } from "@/lib/github/client";

export type { ContributionDay };
export type ContributionLevel = 0 | 1 | 2 | 3 | 4;

export interface ContributionCell {
date: string;
count: number;
level: ContributionLevel; // 0 = none, 1-4 = quartiles of THIS user's own active days
}

export interface MonthLabel {
weekIndex: number; // column in `weeks` this label sits above
label: string; // e.g. "Mar"
}

export interface ContributionPanelData {
weeks: (ContributionCell | null)[][]; // columns of 7 (Sun..Sat); null = outside the fetched range
monthLabels: MonthLabel[];
total: number;
currentStreak: number; // trailing run of active days, ending at the last tracked day
latestDate: string | null; // most recent day in range — the panel's "today" cell
}

// A fresh object every call — never a shared instance a future caller could mutate.
const emptyPanel = (): ContributionPanelData => ({
weeks: [],
monthLabels: [],
total: 0,
currentStreak: 0,
latestDate: null,
});

const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const DAY_MS = 86_400_000;

const parseUTC = (date: string) => new Date(`${date}T00:00:00Z`);
const weekdayOf = (date: string) => parseUTC(date).getUTCDay(); // 0=Sun..6=Sat
const shiftDays = (date: string, delta: number) => new Date(parseUTC(date).getTime() + delta * DAY_MS).toISOString().slice(0, 10);

// GitHub buckets a cell's shade relative to the USER'S OWN active days (a
// 2-a-day tinkerer and a 50-a-day power user both get a readable 4-shade
// spread), not a fixed scale. Quartiles of non-zero counts, sorted ascending.
function levelBuckets(sortedDays: ContributionDay[]): [number, number, number] {
const active = sortedDays.map((d) => d.count).filter((c) => c > 0).sort((a, b) => a - b);
if (active.length === 0) return [0, 0, 0];
const quartile = (p: number) => active[Math.min(active.length - 1, Math.floor(p * active.length))];
return [quartile(0.25), quartile(0.5), quartile(0.75)];
}

export function buildContributionPanel(days: ContributionDay[]): ContributionPanelData {
if (days.length === 0) return emptyPanel();

const sorted = [...days].sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
const byDate = new Map(sorted.map((d) => [d.date, d.count]));
const firstDate = sorted[0].date;
const lastDate = sorted[sorted.length - 1].date;

const [q1, q2, q3] = levelBuckets(sorted);
const levelFor = (count: number): ContributionLevel =>
count <= 0 ? 0 : count <= q1 ? 1 : count <= q2 ? 2 : count <= q3 ? 3 : 4;

// Pad out to full weeks (Sun..Sat) so the grid always renders complete
// columns; cells outside [firstDate, lastDate] are `null` (nothing fetched
// for them, distinct from a real day with 0 contributions).
const gridStart = shiftDays(firstDate, -weekdayOf(firstDate));
const gridEnd = shiftDays(lastDate, 6 - weekdayOf(lastDate));

const weeks: (ContributionCell | null)[][] = [];
const monthLabels: MonthLabel[] = [];
let prevMonth = -1;
for (let weekStart = gridStart; weekStart <= gridEnd; weekStart = shiftDays(weekStart, 7)) {
const week: (ContributionCell | null)[] = [];
for (let i = 0; i < 7; i++) {
const date = shiftDays(weekStart, i);
if (date < firstDate || date > lastDate) {
week.push(null);
continue;
}
const count = byDate.get(date) ?? 0;
week.push({ date, count, level: levelFor(count) });
}
const firstReal = week.find((c): c is ContributionCell => c !== null);
if (firstReal) {
const month = parseUTC(firstReal.date).getUTCMonth();
if (month !== prevMonth) {
monthLabels.push({ weekIndex: weeks.length, label: MONTHS[month] });
prevMonth = month;
}
}
weeks.push(week);
}

// Total + current streak, walked chronologically over the real days only
// (padding is a rendering concern, not data). `running` IS the trailing
// streak: it resets to 0 the moment an inactive day is hit, so whatever it
// holds after the last day is exactly the current streak.
let total = 0;
let running = 0;
for (const day of sorted) {
total += day.count;
running = day.count > 0 ? running + 1 : 0;
}

return { weeks, monthLabels, total, currentStreak: running, latestDate: lastDate };
}
20 changes: 15 additions & 5 deletions lib/github/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ export interface RawRepoLanguage {
language: string | null;
}

// One day of the real per-user contribution calendar — the row shape behind
// RawPayload.contributionDays below, and the input to lib/contributions.ts's
// calendar -> panel transform.
export interface ContributionDay {
date: string; // "YYYY-MM-DD", the UTC calendar date GitHub reports
count: number;
}

// Flat, normalized profile — all fields below are real GitHub data.
export interface RawPayload {
login: string;
Expand All @@ -58,6 +66,7 @@ export interface RawPayload {
recentRestricted: number; // last-year private contributions (count only)
recentActiveDays: number;
lifetimeContributions: number; // all years, all types, incl. private
contributionDays: ContributionDay[]; // the full calendar recentActiveDays is reduced from — real per-day counts, for the profile's contribution graph
}

const ENDPOINT = "https://api.github.com/graphql";
Expand Down Expand Up @@ -120,7 +129,7 @@ interface UserNode {
primaryLanguage: { name: string } | null;
};
}[];
contributionCalendar: { weeks: { contributionDays: { contributionCount: number }[] }[] };
contributionCalendar: { weeks: { contributionDays: { contributionCount: number; date: string }[] }[] };
};
}

Expand Down Expand Up @@ -222,7 +231,7 @@ function profileQuery(): string {
contributions { totalCount }
repository { nameWithOwner isFork isPrivate primaryLanguage { name } }
}
contributionCalendar { weeks { contributionDays { contributionCount } } }
contributionCalendar { weeks { contributionDays { contributionCount date } } }
}
}
}`;
Expand Down Expand Up @@ -350,10 +359,10 @@ function normalize(user: UserNode, lifetimeContributions: number): RawPayload {
}
const languageRepos: RawRepoLanguage[] = [...languageByRepo.values()].map((language) => ({ language }));

const recentActiveDays = user.recent.contributionCalendar.weeks.reduce(
(days, w) => days + w.contributionDays.filter((d) => d.contributionCount > 0).length,
0,
const contributionDays: ContributionDay[] = user.recent.contributionCalendar.weeks.flatMap((w) =>
w.contributionDays.map((d) => ({ date: d.date, count: d.contributionCount })),
);
const recentActiveDays = contributionDays.filter((d) => d.count > 0).length;

return {
login: user.login,
Expand All @@ -372,5 +381,6 @@ function normalize(user: UserNode, lifetimeContributions: number): RawPayload {
recentRestricted: user.recent.restrictedContributionsCount,
recentActiveDays,
lifetimeContributions,
contributionDays,
};
}
7 changes: 7 additions & 0 deletions lib/scoring/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { ContributionDay } from "@/lib/contributions";

export type StatKey = "pac" | "sho" | "pas" | "dri" | "def" | "phy";
export type Stats = Record<StatKey, number>;
export type Profile = Record<StatKey, number>;
Expand Down Expand Up @@ -103,4 +105,9 @@ export interface Card {
// Optional so every other card (and previously serialized ones) stay valid.
founder?: FounderMeta;
report: Report;
// Full last-year daily calendar for the profile's contribution graph — from
// the scout fetch already made (lib/github/client.ts), not a new API call.
// Optional: absent on demo/sample cards and cards cached before this field
// existed.
contributionDays?: ContributionDay[];
}
5 changes: 3 additions & 2 deletions lib/scout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import type { Card } from "./scoring/types";
// Namespaced alongside gitfut:scouts:total. The version segment lets a deploy
// that changes buildCard's output shape or scoring invalidate every entry at
// once (bump it) instead of serving stale-shaped cards until their TTL lapses.
const CACHE_VERSION = "v1";
const CACHE_VERSION = "v2"; // v2: Card gained contributionDays
const CARD_TTL_SECONDS = 120 * 60; // 2h — GitHub stats move slowly; longer TTL = fewer refetches of hot profiles under load.

const normalizeLogin = (username: string) => username.trim().replace(/^@/, "").toLowerCase();
Expand Down Expand Up @@ -62,7 +62,8 @@ async function writeCache(login: string, card: Card): Promise<void> {
const inflight = new Map<string, Promise<Card>>();

async function buildFresh(username: string, login: string): Promise<Card> {
const card = buildCard(signalsFromPayload(await fetchProfile(username)));
const payload = await fetchProfile(username);
const card: Card = { ...buildCard(signalsFromPayload(payload)), contributionDays: payload.contributionDays };
await writeCache(login, card);
return card;
}
Expand Down
1 change: 1 addition & 0 deletions scripts/distribution-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ async function fetchPayload(login: string): Promise<RawPayload | null> {
recentRestricted: user.recent.restrictedContributionsCount,
recentActiveDays,
lifetimeContributions,
contributionDays: [], // unused by scoring; this script only feeds the distribution histogram
};
}

Expand Down
32 changes: 32 additions & 0 deletions tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,38 @@ describe("fetchProfile token pool", () => {
});
});

describe("fetchProfile contribution calendar", () => {
const USER_CALENDAR = {
...USER,
recent: {
...USER.recent,
contributionCalendar: {
weeks: [
{ contributionDays: [{ contributionCount: 0, date: "2026-06-29" }, { contributionCount: 3, date: "2026-06-30" }] },
{ contributionDays: [{ contributionCount: 0, date: "2026-07-01" }] },
],
},
},
};

it("requests each day's date alongside its count", async () => {
scriptFetch((_t, body) => okFor(body));
await fetchProfile(LOGIN, NOW);
expect(calls[0].body).toContain("contributionDays { contributionCount date }");
});

it("flattens the calendar into contributionDays and derives recentActiveDays from it", async () => {
scriptFetch((_t, body) => (body.includes("query Profile") ? ok({ data: { user: USER_CALENDAR } }) : ok({ data: { user: {} } })));
const payload = await fetchProfile(LOGIN, NOW);
expect(payload.contributionDays).toEqual([
{ date: "2026-06-29", count: 0 },
{ date: "2026-06-30", count: 3 },
{ date: "2026-07-01", count: 0 },
]);
expect(payload.recentActiveDays).toBe(1);
});
});

describe("fetchProfile username validation", () => {
it("accepts legacy usernames with a trailing hyphen (real GitHub accounts)", async () => {
scriptFetch((_t, body) => okFor(body));
Expand Down
Loading