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 fe8dbbe..295e3f8 100644 --- a/components/DuelView.tsx +++ b/components/DuelView.tsx @@ -11,6 +11,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"; @@ -270,6 +271,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 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, });