diff --git a/components/ActivityPrivacyNotice.tsx b/components/ActivityPrivacyNotice.tsx new file mode 100644 index 0000000..792ff93 --- /dev/null +++ b/components/ActivityPrivacyNotice.tsx @@ -0,0 +1,40 @@ +import { EyeOff } from "lucide-react"; + +// GitHub zeroes out contributionsCollection for every viewer but the owner when +// an account's activity is set to private (see Card.hiddenActivity) — this +// stats card is likely underscored for a reason that has nothing to do with +// the person's real output. Shown on the scout report and duel pages, never +// baked into the capturable card art (share images shouldn't carry a caveat +// about the very numbers printed on them). +export default function ActivityPrivacyNotice({ + login, + compact = false, +}: { + login: string; + compact?: boolean; +}) { + const message = `GitHub shows zero contribution activity for @${login} - the profile might be private. Stats below might run low.`; + + if (compact) { + return ( +
+ + activity private +
+ ); + } + + return ( +
+ + {message} +
+ ); +} diff --git a/components/DuelView.tsx b/components/DuelView.tsx index 78a5887..8e64006 100644 --- a/components/DuelView.tsx +++ b/components/DuelView.tsx @@ -12,6 +12,7 @@ import VsBurst from "./VsBurst"; import Mascot from "./Mascot"; import FooterCredit from "./FooterCredit"; import GithubStar from "./GithubStar"; +import ActivityPrivacyNotice from "./ActivityPrivacyNotice"; import BuyMeACoffee from "./BuyMeACoffee"; import SupportProductHunt from "./SupportProductHunt"; import { XLogo } from "./BrandIcons"; @@ -318,6 +319,9 @@ export default function DuelView({ {card.archetype} · {card.report.style} + {card.hiddenActivity && ( + + )} {card.report.playstyles.length > 0 && (
{card.report.playstyles.map((p) => { diff --git a/components/ResultView.tsx b/components/ResultView.tsx index 5261363..553c526 100644 --- a/components/ResultView.tsx +++ b/components/ResultView.tsx @@ -14,6 +14,7 @@ import FooterCredit from "./FooterCredit"; import BuyMeACoffee from "./BuyMeACoffee"; import SupportProductHunt from "./SupportProductHunt"; import GithubStar from "./GithubStar"; +import ActivityPrivacyNotice from "./ActivityPrivacyNotice"; import dynamic from "next/dynamic"; import { AttributesPanel, MetricsPanel, ReportHeader } from "./ScoutReport"; import DistributionPanel from "./DistributionPanel"; @@ -147,6 +148,12 @@ export default function ResultView({
+ {card.hiddenActivity && ( +
+ +
+ )} +
{/* left — attributes + playstyles */}
diff --git a/lib/github/client.ts b/lib/github/client.ts index f061a38..c97f3d6 100644 --- a/lib/github/client.ts +++ b/lib/github/client.ts @@ -73,6 +73,15 @@ export interface RawPayload { recentRestricted: number; // last-year private contributions (count only) recentActiveDays: number; lifetimeContributions: number; // all years, all types, incl. private + // GitHub's "private contributions" account setting zeroes out + // contributionsCollection entirely for every viewer but the owner — recent AND + // every lifetime year — even when the account has real, public, recent commits. + // No API field says so directly, and the all-zero signature alone is + // ambiguous: a genuinely new/inactive account produces identical zeros. What + // tells them apart is repos: contributionsCollection is the only thing the + // privacy setting touches, so an owned public repo pushed within the last + // year is real activity that survives it — see hasRecentRepoActivity below. + hiddenActivity: boolean; years: YearBreakdown[]; // per-year breakdown behind it, oldest first (failed years omitted) } @@ -617,6 +626,10 @@ export async function fetchProfile( now.toISOString(), ); + return normalize(user, lifetimeContributions, now); +} + +function normalize(user: UserNode, lifetimeContributions: number, now: Date): RawPayload { return normalize(user, years); } @@ -655,6 +668,29 @@ function normalize(user: UserNode, years: YearBreakdown[]): RawPayload { 0, ); + // A repo push isn't part of contributionsCollection, so the privacy setting + // can't touch it — an owned public repo pushed in the last year is real, + // dated evidence of work that the all-zero contribution figures contradict. + // A genuinely new/inactive account has no such repo to point to. + const hasRecentRepoActivity = repos.some( + (r) => now.getTime() - Date.parse(r.pushedAt) <= RECENT_YEAR_MS, + ); + + // Every contribution figure (recent AND lifetime) is zero — the signature a + // "private activity" account leaves across every window, GitHub's usual grid + // included — but only counts as *hidden* (vs. a real zero-contribution + // account, which produces identical zeros) when there's also a recently + // pushed public repo contradicting it. + const hiddenActivity = + user.recent.totalCommitContributions === 0 && + user.recent.totalPullRequestContributions === 0 && + user.recent.totalPullRequestReviewContributions === 0 && + user.recent.totalIssueContributions === 0 && + user.recent.restrictedContributionsCount === 0 && + recentActiveDays === 0 && + lifetimeContributions === 0 && + hasRecentRepoActivity; + return { login: user.login, name: user.name, @@ -672,6 +708,7 @@ function normalize(user: UserNode, years: YearBreakdown[]): RawPayload { recentRestricted: user.recent.restrictedContributionsCount, recentActiveDays, lifetimeContributions, + hiddenActivity, years, }; } diff --git a/lib/github/samples.ts b/lib/github/samples.ts index 0e749ce..eec3353 100644 --- a/lib/github/samples.ts +++ b/lib/github/samples.ts @@ -43,6 +43,7 @@ const RAW: Signals[] = [ issues_closed: 2, recent_commits: 3255, recent_spike: false, + hidden_activity: false, years: [ yr(2011, 2085, 0, 0, 0, 0), yr(2012, 2289, 0, 0, 0, 0), yr(2013, 2046, 0, 0, 0, 0), yr(2014, 2086, 0, 0, 0, 0), yr(2015, 2009, 0, 0, 1, 0), yr(2016, 2275, 0, 0, 0, 0), @@ -75,6 +76,7 @@ const RAW: Signals[] = [ issues_closed: 15, recent_commits: 2809, recent_spike: true, + hidden_activity: false, years: [ yr(2013, 73, 1, 0, 0, 0), yr(2014, 53, 4, 0, 2, 43), yr(2015, 1724, 224, 0, 77, 88), yr(2016, 269, 33, 0, 10, 164), yr(2017, 9, 1, 0, 1, 666), yr(2018, 5, 2, 1, 2, 148), @@ -106,6 +108,7 @@ const RAW: Signals[] = [ issues_closed: 3, recent_commits: 566, recent_spike: false, + hidden_activity: false, years: [yr(2025, 127, 0, 0, 0, 0), yr(2026, 575, 9, 233, 3, 0)], }, { @@ -131,6 +134,7 @@ const RAW: Signals[] = [ issues_closed: 4, recent_commits: 1488, recent_spike: false, + hidden_activity: false, years: [ yr(2014, 20, 0, 0, 0, 0), yr(2015, 135, 1, 0, 23, 136), yr(2016, 189, 20, 6, 34, 110), yr(2017, 17, 3, 4, 4, 29), yr(2018, 1, 1, 0, 0, 11), yr(2019, 16, 2, 0, 0, 103), diff --git a/lib/github/signals.ts b/lib/github/signals.ts index 2cab6dd..4fcf7df 100644 --- a/lib/github/signals.ts +++ b/lib/github/signals.ts @@ -62,6 +62,7 @@ export function signalsFromPayload(p: RawPayload, now = Date.now()): Signals { // so this is the closest "public + private" figure available. recent_commits: p.recentCommits + p.recentRestricted, recent_spike, + hidden_activity: p.hiddenActivity, years: p.years, }; } diff --git a/lib/scoring/engine.ts b/lib/scoring/engine.ts index 6d06244..750aea8 100644 --- a/lib/scoring/engine.ts +++ b/lib/scoring/engine.ts @@ -211,6 +211,7 @@ export function buildCard(s: Signals): Card { topLanguage: s.topLanguage ?? null, languageLogo, ...(founder ? { founder } : null), + hiddenActivity: s.hidden_activity, ...(s.years ? { years: s.years } : null), legacy: { L }, report: { diff --git a/lib/scoring/types.ts b/lib/scoring/types.ts index c81b733..11ec0ac 100644 --- a/lib/scoring/types.ts +++ b/lib/scoring/types.ts @@ -30,6 +30,9 @@ export interface Signals { issues_closed: number; recent_commits: number; recent_spike: boolean; + // See RawPayload.hiddenActivity (lib/github/client.ts) — GitHub's all-zero + // signature for an account with contribution activity hidden from viewers. + hidden_activity: boolean; // Per-year history, oldest first. Optional: hand-authored sample Signals and // previously serialized data predate it. years?: YearBreakdown[]; @@ -128,6 +131,10 @@ export interface Card { // Set only for gitfut founders — their bespoke card art/accent + hint metadata. // Optional so every other card (and previously serialized ones) stay valid. founder?: FounderMeta; + // Flags that GitHub's own numbers for this login look hidden (see + // Signals.hidden_activity) — optional so previously serialized cards + // (localStorage, Redis cache) without it still parse as valid. + hiddenActivity?: boolean; // Per-year history behind the yearly awards (Ballon d'Or etc.). Optional so // cached/serialized cards from before the awards system stay valid. years?: YearBreakdown[]; diff --git a/scripts/distribution-runner.ts b/scripts/distribution-runner.ts index b5e591f..2899eda 100644 --- a/scripts/distribution-runner.ts +++ b/scripts/distribution-runner.ts @@ -225,6 +225,13 @@ async function fetchPayload(login: string): Promise { (days, w) => days + w.contributionDays.filter((d) => d.contributionCount > 0).length, 0, ); + // Mirrors normalize() in lib/github/client.ts: contributionsCollection is + // what the privacy setting zeroes, not repo pushedAt, so a recently pushed + // owned repo is the evidence that tells "hidden" apart from "really inactive". + const RECENT_YEAR_MS = 365 * 86_400_000; + const hasRecentRepoActivity = repos.some( + (r) => Date.now() - Date.parse(r.pushedAt) <= RECENT_YEAR_MS, + ); return { login: user.login, name: user.name, @@ -242,6 +249,15 @@ async function fetchPayload(login: string): Promise { recentRestricted: user.recent.restrictedContributionsCount, recentActiveDays, lifetimeContributions, + hiddenActivity: + user.recent.totalCommitContributions === 0 && + user.recent.totalPullRequestContributions === 0 && + user.recent.totalPullRequestReviewContributions === 0 && + user.recent.totalIssueContributions === 0 && + user.recent.restrictedContributionsCount === 0 && + recentActiveDays === 0 && + lifetimeContributions === 0 && + hasRecentRepoActivity, years: [], // per-year breakdown feeds awards, not scoring — the runner only scores }; } diff --git a/tests/attributes.test.ts b/tests/attributes.test.ts index af113e2..e00b2fc 100644 --- a/tests/attributes.test.ts +++ b/tests/attributes.test.ts @@ -36,6 +36,7 @@ const base: Signals = { issues_closed: 4, recent_commits: 280, recent_spike: false, + hidden_activity: false, }; const signals = (over: Partial = {}): Signals => ({ ...base, ...over }); diff --git a/tests/engine.test.ts b/tests/engine.test.ts index 8c54101..db6fbb3 100644 --- a/tests/engine.test.ts +++ b/tests/engine.test.ts @@ -25,6 +25,7 @@ const base: Signals = { issues_closed: 4, recent_commits: 280, recent_spike: false, + hidden_activity: false, }; const withLogin = (login: string): Signals => ({ ...base, login }); diff --git a/tests/playstyles.test.ts b/tests/playstyles.test.ts index 95baf6f..2405a7a 100644 --- a/tests/playstyles.test.ts +++ b/tests/playstyles.test.ts @@ -28,6 +28,7 @@ const quiet: Signals = { issues_closed: 0, recent_commits: 18, recent_spike: false, + hidden_activity: false, }; const signals = (over: Partial = {}): Signals => ({ ...quiet, ...over }); diff --git a/tests/signals.test.ts b/tests/signals.test.ts index cd1591d..7e957c5 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, + hiddenActivity: false, years: [], ...over, });