Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions components/ActivityPrivacyNotice.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
role="status"
title={message}
className="flex items-center gap-[4px] text-[10.5px] leading-none text-gold-hi"
>
<EyeOff size={11} aria-hidden />
activity private
</div>
);
}

return (
<div
role="status"
className="mx-auto flex max-w-[560px] items-start gap-[9px] rounded-[10px] border border-gold/30 bg-gold/[0.08] px-[13px] py-[10px] text-[12.5px] leading-snug text-gold-hi"
>
<EyeOff size={15} className="mt-[1px] shrink-0" aria-hidden />
<span>{message}</span>
</div>
);
}
4 changes: 4 additions & 0 deletions components/DuelView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -270,6 +271,9 @@ export default function DuelView({
{card.archetype}
</span> · {card.report.style}
</div>
{card.hiddenActivity && (
<ActivityPrivacyNotice login={card.login} compact />
)}
{card.report.playstyles.length > 0 && (
<div className="flex flex-wrap items-center justify-center gap-[5px]">
{card.report.playstyles.map((p) => {
Expand Down
7 changes: 7 additions & 0 deletions components/ResultView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -134,6 +135,12 @@ export default function ResultView({
<ReportHeader card={card} />
</div>

{card.hiddenActivity && (
<div className="mt-[clamp(10px,1.6vh,16px)] shrink-0 px-[8px]">
<ActivityPrivacyNotice login={card.login} />
</div>
)}

<div className="mt-[clamp(14px,2.4vh,26px)] grid grid-cols-[1fr_auto_1fr] items-start gap-[clamp(16px,2.4vw,40px)] max-[980px]:mt-6 max-[980px]:flex max-[980px]:flex-col max-[980px]:items-center">
{/* left — attributes + playstyles */}
<div className="flex justify-end max-[980px]:order-2 max-[980px]:w-full max-[980px]:max-w-[420px] max-[980px]:justify-center">
Expand Down
37 changes: 35 additions & 2 deletions lib/github/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -609,5 +641,6 @@ function normalize(user: UserNode, lifetimeContributions: number): RawPayload {
recentRestricted: user.recent.restrictedContributionsCount,
recentActiveDays,
lifetimeContributions,
hiddenActivity,
};
}
4 changes: 4 additions & 0 deletions lib/github/samples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const RAW: Signals[] = [
issues_closed: 2,
recent_commits: 3255,
recent_spike: false,
hidden_activity: false,
},
{
login: "ThePrimeagen",
Expand All @@ -55,6 +56,7 @@ const RAW: Signals[] = [
issues_closed: 15,
recent_commits: 2809,
recent_spike: true,
hidden_activity: false,
},
{
login: "pewdiepie-archdaemon",
Expand All @@ -79,6 +81,7 @@ const RAW: Signals[] = [
issues_closed: 3,
recent_commits: 566,
recent_spike: false,
hidden_activity: false,
},
{
login: "t3dotgg",
Expand All @@ -103,6 +106,7 @@ const RAW: Signals[] = [
issues_closed: 4,
recent_commits: 1488,
recent_spike: false,
hidden_activity: false,
},
];

Expand Down
1 change: 1 addition & 0 deletions lib/github/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
1 change: 1 addition & 0 deletions lib/scoring/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions lib/scoring/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
16 changes: 16 additions & 0 deletions scripts/distribution-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,13 @@ async function fetchPayload(login: string): Promise<RawPayload | null> {
(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,
Expand All @@ -242,6 +249,15 @@ async function fetchPayload(login: string): Promise<RawPayload | null> {
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,
};
}

Expand Down
1 change: 1 addition & 0 deletions tests/attributes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const base: Signals = {
issues_closed: 4,
recent_commits: 280,
recent_spike: false,
hidden_activity: false,
};

const signals = (over: Partial<Signals> = {}): Signals => ({ ...base, ...over });
Expand Down
1 change: 1 addition & 0 deletions tests/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
1 change: 1 addition & 0 deletions tests/playstyles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const quiet: Signals = {
issues_closed: 0,
recent_commits: 18,
recent_spike: false,
hidden_activity: false,
};

const signals = (over: Partial<Signals> = {}): Signals => ({ ...quiet, ...over });
Expand Down
1 change: 1 addition & 0 deletions tests/signals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const payload = (over: Partial<RawPayload> = {}): RawPayload => ({
recentRestricted: 0,
recentActiveDays: 0,
lifetimeContributions: 0,
hiddenActivity: false,
...over,
});

Expand Down