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 (
+
{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,
});