From 6b1791a7574e9ce036bac25656a59066028d80b7 Mon Sep 17 00:00:00 2001 From: bae080311 Date: Wed, 15 Jul 2026 10:20:41 +0900 Subject: [PATCH 1/8] feat(contributions): add calendar -> panel-data pure transform Reshapes a user's real per-day contribution counts into a rendering-ready grid (Sun..Sat weeks, month labels, per-user quartile intensity levels, current streak). Framework-agnostic and fetch-free so it's trivially unit-tested on its own, ahead of wiring it into any UI. --- lib/contributions.ts | 111 ++++++++++++++++++++++++++++++++++++ tests/contributions.test.ts | 102 +++++++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 lib/contributions.ts create mode 100644 tests/contributions.test.ts 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/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 + }); +}); From d1df1c516ff762835cd3dbb3cc22df6dd94e6989 Mon Sep 17 00:00:00 2001 From: bae080311 Date: Wed, 15 Jul 2026 10:20:55 +0900 Subject: [PATCH 2/8] feat(github): thread the real per-day contribution calendar through the profile fetch The scout already requests contributionCalendar in the profile query, but only reduced it to a single recentActiveDays count. Add `date` to the existing field selection (no new API call) and flatten the calendar into RawPayload.contributionDays; recentActiveDays now derives from that same array instead of walking the tree twice. --- lib/github/client.ts | 20 +++++++++++++++----- scripts/distribution-runner.ts | 1 + tests/client.test.ts | 32 ++++++++++++++++++++++++++++++++ tests/signals.test.ts | 1 + 4 files changed, 49 insertions(+), 5 deletions(-) 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/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/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, }); From d13a2e970e1b15d960c9435fdef5d5ae1a293e27 Mon Sep 17 00:00:00 2001 From: bae080311 Date: Wed, 15 Jul 2026 10:21:22 +0900 Subject: [PATCH 3/8] feat(scout): attach the contribution calendar to the built card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Card gains an optional contributionDays field, attached alongside buildCard's output in buildFresh rather than folded into Signals/buildCard itself — it's display data for the profile page, not a scoring input. Bumps the Redis cache version since this changes Card's cached shape. --- lib/scoring/types.ts | 7 +++++++ lib/scout.ts | 5 +++-- tests/scout.test.ts | 23 +++++++++++++++++++++-- 3 files changed, 31 insertions(+), 4 deletions(-) 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/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); + }); +}); From c04c065fc81d68e914700c8dcdef9efaaf171226 Mon Sep 17 00:00:00 2001 From: bae080311 Date: Wed, 15 Jul 2026 10:21:37 +0900 Subject: [PATCH 4/8] feat(profile): show the real contribution graph on the scout result page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ContributionPanel — a real per-user grid rendered full-width below the scouting report, styled like the other report panels. Reuses the decorative Background motif's exact cell geometry and green (now exported as GRID_* constants) so the page never shows two different-looking grids, and suppresses that decorative strip only once a card actually has calendar data to replace it with. --- app/u/[username]/page.tsx | 5 +- components/Background.tsx | 38 ++++++++----- components/ContributionPanel.tsx | 91 ++++++++++++++++++++++++++++++++ components/ResultView.tsx | 11 ++++ 4 files changed, 132 insertions(+), 13 deletions(-) create mode 100644 components/ContributionPanel.tsx diff --git a/app/u/[username]/page.tsx b/app/u/[username]/page.tsx index 83f177b..289dd5d 100644 --- a/app/u/[username]/page.tsx +++ b/app/u/[username]/page.tsx @@ -79,7 +79,10 @@ export default async function Page({ } return (
- + {/* Only hide the decorative motif once there's REAL per-user data to take + its place — demo/sample cards and pre-v2-cache cards have no + contributionDays, and would otherwise leave neither grid showing. */} + {card ? ( ) : ( diff --git a/components/Background.tsx b/components/Background.tsx index 41ac3fb..4c94f70 100644 --- a/components/Background.tsx +++ b/components/Background.tsx @@ -5,6 +5,15 @@ 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). // +// Cell geometry + colors are exported so the REAL per-user contribution graph +// (ContributionPanel, on the scout result page) can match this exactly rather +// than drifting into a second, different-looking grid. +export const GRID_CELL = 12; +export const GRID_PITCH = 16; // cell + gap +export const GRID_RADIUS = 2.5; +export const GRID_GREEN = "#39d353"; +export const GRID_EMPTY = "#1b2530"; + // The 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 @@ -18,12 +27,12 @@ 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() { @@ -36,7 +45,7 @@ function ContribGrid() { ); } -export default function Background() { +export default function Background({ showContribGrid = true }: { showContribGrid?: boolean } = {}) { return (
{/* green ambient — the "action" color, top spotlight */} @@ -92,13 +101,18 @@ export default function Background() { {/* contribution-grid motif, faint along the bottom. Hidden below 980px: narrow layouts stack content much taller than one viewport, so this "floor" strip ends up floating behind mid-page content instead of - only at the true bottom. */} -
- -
+ only at the true bottom. Suppressed entirely on the scout result + page (showContribGrid=false) — it renders the user's REAL + contribution graph as content there, so this decorative stand-in + would otherwise double up as a second, fake-data grid. */} + {showContribGrid && ( +
+ +
+ )}
); diff --git a/components/ContributionPanel.tsx b/components/ContributionPanel.tsx new file mode 100644 index 0000000..5bc916f --- /dev/null +++ b/components/ContributionPanel.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useMemo, type CSSProperties } from "react"; +import type { Card } from "@/lib/scoring/types"; +import { buildContributionPanel, type ContributionCell } from "@/lib/contributions"; +import { GRID_CELL, GRID_PITCH, GRID_RADIUS, GRID_GREEN, GRID_EMPTY } from "./Background"; +import { resolveResultTheme } from "./finishTheme"; + +// Real per-user contribution graph — same cell geometry + green as the +// decorative motif in Background.tsx (see GRID_* there), so the result page +// never shows two different-looking grids. Levels 1-4 are quartiles of this +// user's OWN active days (see lib/contributions), rendered as the same green +// at increasing (but capped, muted) opacity over the same dim, unlit fill — +// deliberately softer than a literal github.com graph so it reads as ambient +// texture alongside the other panels, not a loud, high-contrast widget. +const LEVEL_OPACITY: Record = { 0: 0, 1: 0.22, 2: 0.4, 3: 0.58, 4: 0.8 }; +const LABEL_H = 16; // room for month labels above the grid + +export default function ContributionPanel({ card }: { card: Card }) { + const data = useMemo(() => buildContributionPanel(card.contributionDays ?? []), [card.contributionDays]); + const accent = resolveResultTheme(card).ink; + + if (data.weeks.length === 0) return null; + + const cols = data.weeks.length; + const width = cols * GRID_PITCH; + const height = LABEL_H + 7 * GRID_PITCH; + + return ( +
+
+
+ +

CONTRIBUTIONS

+
+

+ {data.total.toLocaleString()} in + the last year + {data.currentStreak > 1 && ( + <> + {" "} + · {data.currentStreak}-day streak + + )} +

+
+ + {data.monthLabels.map(({ weekIndex, label }) => ( + + {label} + + ))} + {data.weeks.map((week, w) => + week.map((cell, d) => { + if (!cell) return null; + const isLatest = cell.date === data.latestDate; + return ( + 0 ? "gf-grid-cell" : undefined} + style={isLatest && cell.level > 0 ? ({ "--gf-dur": "2.4s" } as CSSProperties) : undefined} + > + {`${cell.count} contribution${cell.count === 1 ? "" : "s"} on ${cell.date}`} + + ); + }), + )} + +
+ ); +} diff --git a/components/ResultView.tsx b/components/ResultView.tsx index c2aeaa6..a9c7eb1 100644 --- a/components/ResultView.tsx +++ b/components/ResultView.tsx @@ -16,6 +16,7 @@ import GithubStar from "./GithubStar"; import dynamic from "next/dynamic"; import { AttributesPanel, MetricsPanel, ReportHeader } from "./ScoutReport"; import DistributionPanel from "./DistributionPanel"; +import ContributionPanel from "./ContributionPanel"; import { confettiPalette, resolveResultTheme } from "./finishTheme"; import { useReveal } from "@/hooks/useReveal"; import { burstConfetti } from "@/lib/confetti"; @@ -192,6 +193,16 @@ export default function ResultView({
+ {/* The center column's own mb-14 (breathing room before the footer when + nothing else follows it) makes the grid row taller than the other two + columns, so this negative margin cancels that out on desktop where the + grid is active — same landing spot regardless of which column is + tallest. Below 980px the columns stack (center isn't last anymore), + so the plain positive margin applies there instead. */} +
+ +
+
From bacf52e7c5ec41c4c446929ff0565481c92d3276 Mon Sep 17 00:00:00 2001 From: bae080311 Date: Wed, 15 Jul 2026 10:25:05 +0900 Subject: [PATCH 5/8] fix(api): drop the per-day calendar from the public card JSON endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public /api/card/:username response has no reason to carry ~365 extra {date,count} rows per request — that data only feeds the profile page's own ContributionPanel render, which already has it via the server-rendered Card. --- app/api/card/[username]/route.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 = From fe68e20deed0c9b01a195576e42a22806b5370dd Mon Sep 17 00:00:00 2001 From: bae080311 Date: Wed, 15 Jul 2026 11:14:47 +0900 Subject: [PATCH 6/8] style(profile): tighten desktop layout so the report fits one viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a fourth section (the contribution graph) pushed the result page past typical laptop heights. Scale several previously width-only or fixed values (card size, grade stamp, name, vertical margins) with viewport height too, so the whole report — card, attributes, metrics, distribution, contributions — fits without scrolling on common desktop sizes (~1512x980+) and comes close on smaller ones (~1440x900), without touching the mobile-stacked layout (still governed by the max-[980px] breakpoint). Raised the card's width floor from 220 to 260 — CardActions' Download button needs that much room before its label starts truncating, which the more aggressive height-based shrinking would otherwise have hit. --- components/ResultView.tsx | 30 ++++++++++++++++++------------ components/ScoutReport.tsx | 6 +++--- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/components/ResultView.tsx b/components/ResultView.tsx index a9c7eb1..b84d731 100644 --- a/components/ResultView.tsx +++ b/components/ResultView.tsx @@ -36,7 +36,13 @@ interface Props { // Card width scales with the viewport but is bounded by BOTH width and height // (and a hard min/max) so it never overflows a narrow phone or a short laptop. -const CARD_WIDTH = "clamp(220px, min(80vw, 40vh), 332px)"; +// The height term also has to leave room for everything below the card on a +// short desktop viewport (scouting metrics, and now the contribution graph), +// so it starts shrinking the card well before the viewport gets phone-short. +// Floor is 260, not lower — CardActions' Download button (icon + label + +// caret, alongside the two share icons) needs that much width before its +// label starts truncating. +const CARD_WIDTH = "clamp(260px, min(76vw, 29vh), 300px)"; export default function ResultView({ card, @@ -94,7 +100,7 @@ export default function ResultView({ /> {/* top bar: BACK button + mascot on the left, "how it works" on the right */} -
+
-
+
{/* left — attributes + playstyles */}
@@ -143,7 +149,7 @@ export default function ResultView({
{/* center — the card + actions (the walkout happens here) */} -
+
{/* spotlight wash — a soft, diffuse glow from above as the card rises. Reduced + blurred so it reads as ambient light, not a hard beam. */}
- {/* The center column's own mb-14 (breathing room before the footer when - nothing else follows it) makes the grid row taller than the other two - columns, so this negative margin cancels that out on desktop where the - grid is active — same landing spot regardless of which column is - tallest. Below 980px the columns stack (center isn't last anymore), - so the plain positive margin applies there instead. */} -
+ {/* The center column's own trailing margin (breathing room when nothing + else follows it) makes the grid row taller than the other two + columns, so this small negative margin cancels most of that out on + desktop where the grid is active — same landing spot regardless of + which column is tallest. Below 980px the columns stack (center isn't + last anymore), so the plain positive margin applies there instead. */} +
-