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 61fbf7c..7218291 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";
@@ -134,6 +135,12 @@ export default function ResultView({
+ {card.hiddenActivity && (
+
{/* left — attributes + playstyles */}
diff --git a/lib/github/client.ts b/lib/github/client.ts
index 7fb6b84..f3ef8eb 100644
--- a/lib/github/client.ts
+++ b/lib/github/client.ts
@@ -72,6 +72,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;
}
const ENDPOINT = "https://api.github.com/graphql";
@@ -555,10 +564,10 @@ export async function fetchProfile(
now.toISOString(),
);
- return normalize(user, lifetimeContributions);
+ return normalize(user, lifetimeContributions, now);
}
-function normalize(user: UserNode, lifetimeContributions: number): RawPayload {
+function normalize(user: UserNode, lifetimeContributions: number, now: Date): RawPayload {
const repos: RawRepo[] = user.repositories.nodes.map((n) => ({
stars: n.stargazerCount ?? 0,
language: n.primaryLanguage?.name ?? null,
@@ -592,6 +601,29 @@ function normalize(user: UserNode, lifetimeContributions: number): 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,
@@ -609,5 +641,6 @@ function normalize(user: UserNode, lifetimeContributions: number): RawPayload {
recentRestricted: user.recent.restrictedContributionsCount,
recentActiveDays,
lifetimeContributions,
+ hiddenActivity,
};
}
diff --git a/lib/github/samples.ts b/lib/github/samples.ts
index 57b45a8..a4bad1c 100644
--- a/lib/github/samples.ts
+++ b/lib/github/samples.ts
@@ -31,6 +31,7 @@ const RAW: Signals[] = [
issues_closed: 2,
recent_commits: 3255,
recent_spike: false,
+ hidden_activity: false,
},
{
login: "ThePrimeagen",
@@ -55,6 +56,7 @@ const RAW: Signals[] = [
issues_closed: 15,
recent_commits: 2809,
recent_spike: true,
+ hidden_activity: false,
},
{
login: "pewdiepie-archdaemon",
@@ -79,6 +81,7 @@ const RAW: Signals[] = [
issues_closed: 3,
recent_commits: 566,
recent_spike: false,
+ hidden_activity: false,
},
{
login: "t3dotgg",
@@ -103,6 +106,7 @@ const RAW: Signals[] = [
issues_closed: 4,
recent_commits: 1488,
recent_spike: false,
+ hidden_activity: false,
},
];
diff --git a/lib/github/signals.ts b/lib/github/signals.ts
index 9dcacf8..6090bbf 100644
--- a/lib/github/signals.ts
+++ b/lib/github/signals.ts
@@ -62,5 +62,6 @@ 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,
};
}
diff --git a/lib/scoring/engine.ts b/lib/scoring/engine.ts
index 68ef9c5..a5e9b12 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,
legacy: { L },
report: {
skillMoves: skill.value,
diff --git a/lib/scoring/types.ts b/lib/scoring/types.ts
index 0efb058..bd5aced 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;
}
export type WorkRateLevel = "High" | "Med" | "Low";
@@ -102,5 +105,9 @@ 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;
report: Report;
}
diff --git a/scripts/distribution-runner.ts b/scripts/distribution-runner.ts
index ebfbbb4..23b78ee 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,
};
}
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 646d934..c6e48e6 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,
...over,
});