diff --git a/app/api/card/[username]/route.ts b/app/api/card/[username]/route.ts index 983d656..7328705 100644 --- a/app/api/card/[username]/route.ts +++ b/app/api/card/[username]/route.ts @@ -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 { + 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"); @@ -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 = diff --git a/app/u/[username]/page.tsx b/app/u/[username]/page.tsx index 83f177b..6f47174 100644 --- a/app/u/[username]/page.tsx +++ b/app/u/[username]/page.tsx @@ -79,7 +79,7 @@ export default async function Page({ } return (
- + {card ? ( ) : ( diff --git a/components/Background.tsx b/components/Background.tsx index 41ac3fb..631b32a 100644 --- a/components/Background.tsx +++ b/components/Background.tsx @@ -1,15 +1,28 @@ +import { buildContributionPanel, type ContributionDay, type ContributionLevel } from "@/lib/contributions"; + const noiseSvg = ''; 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 = { 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 // 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 = ""; @@ -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 += ``; + ? ` fill="${GRID_GREEN}" class="gf-grid-cell" style="--gf-dur:${2.4 + seed * 0.4}s"` + : ` fill="${GRID_EMPTY}"`; + rects += ``; } } - return ``; + return ``; })(); -function ContribGrid() { - return ( -
- ); +// 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 += ``; + }); + }); + return ``; +} + +function ContribGrid({ contributionDays }: { contributionDays?: ContributionDay[] }) { + const svg = (contributionDays?.length ? realContribGridSvg(contributionDays) : null) ?? FAKE_CONTRIB_GRID_SVG; + return
; } -export default function Background() { +export default function Background({ contributionDays }: { contributionDays?: ContributionDay[] } = {}) { return (
{/* green ambient — the "action" color, top spotlight */} @@ -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)" }} > - +
diff --git a/lib/contributions.ts b/lib/contributions.ts new file mode 100644 index 0000000..0d0fa75 --- /dev/null +++ b/lib/contributions.ts @@ -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 }; +} diff --git a/lib/github/client.ts b/lib/github/client.ts index ee6100a..86f10b8 100644 --- a/lib/github/client.ts +++ b/lib/github/client.ts @@ -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; @@ -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"; @@ -120,7 +129,7 @@ interface UserNode { primaryLanguage: { name: string } | null; }; }[]; - contributionCalendar: { weeks: { contributionDays: { contributionCount: number }[] }[] }; + contributionCalendar: { weeks: { contributionDays: { contributionCount: number; date: string }[] }[] }; }; } @@ -222,7 +231,7 @@ function profileQuery(): string { contributions { totalCount } repository { nameWithOwner isFork isPrivate primaryLanguage { name } } } - contributionCalendar { weeks { contributionDays { contributionCount } } } + contributionCalendar { weeks { contributionDays { contributionCount date } } } } } }`; @@ -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, @@ -372,5 +381,6 @@ function normalize(user: UserNode, lifetimeContributions: number): RawPayload { recentRestricted: user.recent.restrictedContributionsCount, recentActiveDays, lifetimeContributions, + contributionDays, }; } diff --git a/lib/scoring/types.ts b/lib/scoring/types.ts index 0efb058..b635102 100644 --- a/lib/scoring/types.ts +++ b/lib/scoring/types.ts @@ -1,3 +1,5 @@ +import type { ContributionDay } from "@/lib/contributions"; + export type StatKey = "pac" | "sho" | "pas" | "dri" | "def" | "phy"; export type Stats = Record; export type Profile = Record; @@ -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[]; } diff --git a/lib/scout.ts b/lib/scout.ts index 9b631f6..96df660 100644 --- a/lib/scout.ts +++ b/lib/scout.ts @@ -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(); @@ -62,7 +62,8 @@ async function writeCache(login: string, card: Card): Promise { const inflight = new Map>(); async function buildFresh(username: string, login: string): Promise { - 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; } diff --git a/scripts/distribution-runner.ts b/scripts/distribution-runner.ts index ebfbbb4..aa7bef7 100644 --- a/scripts/distribution-runner.ts +++ b/scripts/distribution-runner.ts @@ -242,6 +242,7 @@ async function fetchPayload(login: string): Promise { recentRestricted: user.recent.restrictedContributionsCount, recentActiveDays, lifetimeContributions, + contributionDays: [], // unused by scoring; this script only feeds the distribution histogram }; } diff --git a/tests/client.test.ts b/tests/client.test.ts index 98d43ef..b98ff54 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -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)); diff --git a/tests/contributions.test.ts b/tests/contributions.test.ts new file mode 100644 index 0000000..6ce5c40 --- /dev/null +++ b/tests/contributions.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { buildContributionPanel, type ContributionDay } from "@/lib/contributions"; + +// buildContributionPanel is the pure calendar -> panel-data transform behind +// ContributionPanel: real per-day counts in, a rendering-ready grid + summary +// stats out. No React, no fetching — so every case below is pinned exactly. + +const day = (date: string, count: number): ContributionDay => ({ date, count }); + +describe("buildContributionPanel — empty input", () => { + it("returns the empty sentinel for no days at all", () => { + expect(buildContributionPanel([])).toEqual({ + weeks: [], + monthLabels: [], + total: 0, + currentStreak: 0, + latestDate: null, + }); + }); + + it("returns a fresh object each call, not a shared reference a caller could mutate", () => { + expect(buildContributionPanel([])).not.toBe(buildContributionPanel([])); + }); + + it("still produces a real (non-empty) grid when every day is 0 — distinct from no data at all", () => { + const data = buildContributionPanel([day("2026-03-02", 0), day("2026-03-03", 0)]); + expect(data.weeks.length).toBeGreaterThan(0); + expect(data.total).toBe(0); + expect(data.currentStreak).toBe(0); + }); +}); + +describe("buildContributionPanel — grid padding + weekday alignment", () => { + it("pads a single day out to a full Sun..Sat week, placed at its real weekday", () => { + // 2026-06-17 is a Wednesday -> index 3 of a Sun-first week. + const data = buildContributionPanel([day("2026-06-17", 5)]); + expect(data.weeks).toHaveLength(1); + const week = data.weeks[0]; + expect(week.map((c) => c?.date ?? null)).toEqual([null, null, null, "2026-06-17", null, null, null]); + expect(week[3]).toMatchObject({ date: "2026-06-17", count: 5, level: 1 }); + expect(data.latestDate).toBe("2026-06-17"); + }); +}); + +describe("buildContributionPanel — level bucketing (quartiles of the user's own active days)", () => { + it("bands levels 1-3 at the exact quartile boundaries, and pins that the max value lands in level 3 (not 4)", () => { + // Active counts [1,2,3,4] -> quartiles q1=2, q2=3, q3=4 (25/50/75th of 4 + // sorted values). Because q3 equals the max itself here, no count is + // ever "> q3" — level 4 is reachable only with more active days spread + // further above the 75th percentile. Surprising but correct, so pin it. + const days = [day("2026-01-01", 1), day("2026-01-02", 2), day("2026-01-03", 3), day("2026-01-04", 4)]; + const data = buildContributionPanel(days); + const levels = data.weeks.flat().filter((c): c is NonNullable => c !== null && c.count > 0); + expect(levels.map((c) => [c.date, c.level])).toEqual([ + ["2026-01-01", 1], + ["2026-01-02", 1], + ["2026-01-03", 2], + ["2026-01-04", 3], + ]); + }); + + it("gives every active day level 1 when they're all equal (no spread to band)", () => { + const days = [day("2026-01-01", 3), day("2026-01-02", 0), day("2026-01-03", 3)]; + const data = buildContributionPanel(days); + const active = data.weeks.flat().filter((c) => c && c.count > 0); + expect(active.every((c) => c!.level === 1)).toBe(true); + }); +}); + +describe("buildContributionPanel — month labels", () => { + it("emits one label per month, at the week column where that month first appears", () => { + const days: ContributionDay[] = []; + for (let d = 28; d <= 31; d++) days.push(day(`2026-01-${d}`, 1)); // Wed..Sat + for (let d = 1; d <= 6; d++) days.push(day(`2026-02-0${d}`, 1)); // Sun..Fri + const data = buildContributionPanel(days); + expect(data.weeks).toHaveLength(2); + expect(data.monthLabels).toEqual([ + { weekIndex: 0, label: "Jan" }, + { weekIndex: 1, label: "Feb" }, + ]); + }); +}); + +describe("buildContributionPanel — streaks and totals", () => { + const RUN = ["2026-03-02", "2026-03-03", "2026-03-04", "2026-03-05", "2026-03-06", "2026-03-07"]; + + it("sums every count, ignoring zero days", () => { + const data = buildContributionPanel(RUN.map((d, i) => day(d, [1, 2, 0, 3, 0, 1][i]))); + expect(data.total).toBe(7); + }); + + it("counts the current streak as the trailing run of active days ending at the last tracked day", () => { + // 1,1,0,1,1,1 -> the trailing 1,1,1 (length 3) is the current streak. + const data = buildContributionPanel(RUN.map((d, i) => day(d, [1, 1, 0, 1, 1, 1][i]))); + expect(data.currentStreak).toBe(3); + }); + + it("zeroes the current streak the moment the most recent day is inactive, even mid-run elsewhere", () => { + const data = buildContributionPanel(RUN.map((d, i) => day(d, [1, 1, 1, 1, 1, 0][i]))); + expect(data.currentStreak).toBe(0); // broken as of the last day, despite the earlier 5-day run + }); +}); diff --git a/tests/scout.test.ts b/tests/scout.test.ts index e4da39c..9acbe19 100644 --- a/tests/scout.test.ts +++ b/tests/scout.test.ts @@ -8,7 +8,18 @@ vi.mock("server-only", () => ({})); const fetchProfile = vi.fn(); vi.mock("@/lib/github/client", () => ({ fetchProfile: (u: string) => fetchProfile(u) })); vi.mock("@/lib/github/signals", () => ({ signalsFromPayload: (p: unknown) => p })); -vi.mock("@/lib/scoring/engine", () => ({ buildCard: (s: unknown) => s })); +// The real buildCard only forwards known Signals-derived fields — it does NOT +// carry an incidental contributionDays prop through. Stripping it here (rather +// than a plain identity pass-through) is what makes the assertions below +// actually exercise buildFresh's explicit `contributionDays: payload.contributionDays` +// re-attachment, instead of it passing by accident via the mock. +vi.mock("@/lib/scoring/engine", () => ({ + buildCard: (s: { contributionDays?: unknown }) => { + const rest = { ...s }; + delete rest.contributionDays; + return rest; + }, +})); // In-memory Redis stand-in so read-through caching is exercised without a server. const store = new Map(); @@ -35,7 +46,7 @@ function deferred() { } const flush = () => new Promise((r) => setTimeout(r, 0)); -const payload = (login: string) => ({ login, name: login }); +const payload = (login: string) => ({ login, name: login, contributionDays: [{ date: "2026-01-01", count: 3 }] }); beforeEach(() => { store.clear(); @@ -101,3 +112,11 @@ describe("scoutCard single-flight", () => { expect(fetchProfile).toHaveBeenCalledTimes(2); }); }); + +describe("scoutCard contribution calendar", () => { + it("carries contributionDays from the fetch onto the built card, even though buildCard itself drops it", async () => { + fetchProfile.mockResolvedValueOnce(payload("torvalds")); + const card = await scoutCard("torvalds"); + expect(card.contributionDays).toEqual(payload("torvalds").contributionDays); + }); +}); diff --git a/tests/signals.test.ts b/tests/signals.test.ts index 646d934..b2281de 100644 --- a/tests/signals.test.ts +++ b/tests/signals.test.ts @@ -32,6 +32,7 @@ const payload = (over: Partial = {}): RawPayload => ({ recentRestricted: 0, recentActiveDays: 0, lifetimeContributions: 0, + contributionDays: [], ...over, });