From 93ad9f1a1bcaab4c9286b0775e4de51be1a6efaf Mon Sep 17 00:00:00 2001 From: Mathijs Rutgers Date: Sun, 29 Mar 2026 14:24:19 +0200 Subject: [PATCH 1/3] Allow unauthenticated calls --- src/App.tsx | 1 + src/components/ContributionCard.tsx | 24 +- src/components/SettingsDrawer.tsx | 245 ++++++++--------- ...utions.ts => fetchContributionsGraphQL.ts} | 2 +- src/lib/fetchContributionsPublic.ts | 194 +++++++++++++ src/lib/{github.ts => githubGraphQL.ts} | 0 src/lib/githubRest.ts | 79 ++++++ src/lib/types.ts | 2 + src/lib/useContributions.ts | 257 ++++++++++++------ src/lib/useExport.ts | 2 + 10 files changed, 600 insertions(+), 206 deletions(-) rename src/lib/{fetchContributions.ts => fetchContributionsGraphQL.ts} (99%) create mode 100644 src/lib/fetchContributionsPublic.ts rename src/lib/{github.ts => githubGraphQL.ts} (100%) create mode 100644 src/lib/githubRest.ts diff --git a/src/App.tsx b/src/App.tsx index afb7df2..5d7ff3c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -169,6 +169,7 @@ export default function App() { badges={badges[u] ?? []} visibleStats={settings.visibleStats} onSelect={(rect) => handleSelectUser(u, rect)} + onSignIn={auth.method === "none" ? auth.signIn : undefined} /> ))} diff --git a/src/components/ContributionCard.tsx b/src/components/ContributionCard.tsx index 548f488..10a2ca4 100644 --- a/src/components/ContributionCard.tsx +++ b/src/components/ContributionCard.tsx @@ -16,6 +16,7 @@ interface ContributionCardProps { badges: Badge[]; visibleStats: string[]; onSelect?: (rect: DOMRect) => void; + onSignIn?: () => void; } export default function ContributionCard({ @@ -24,6 +25,7 @@ export default function ContributionCard({ badges, visibleStats, onSelect, + onSignIn, }: ContributionCardProps) { const cardRef = useRef(null); const [avatarLoaded, setAvatarLoaded] = useState(false); @@ -35,6 +37,12 @@ export default function ContributionCard({ ? computeStreak(collection.contributionCalendar.weeks).current : 0; const hasStreak = currentStreak > 2; + const avatarUrl = result.data?.avatarUrl; + + function handleSignIn(e: React.MouseEvent) { + e.stopPropagation(); + onSignIn?.(); + } function handleSelect() { if (cardRef.current && onSelect) { @@ -59,12 +67,12 @@ export default function ContributionCard({ {/* Header */}
- {(!result.data || !avatarLoaded) && ( + {(!avatarUrl || !avatarLoaded) && (
)} - {result.data && ( + {avatarUrl && ( {`${username}'s setAvatarLoaded(true)} @@ -161,6 +169,16 @@ export default function ContributionCard({ <> + {result.needsAuth && onSignIn && ( + + )} )} diff --git a/src/components/SettingsDrawer.tsx b/src/components/SettingsDrawer.tsx index 549002b..dd85bef 100644 --- a/src/components/SettingsDrawer.tsx +++ b/src/components/SettingsDrawer.tsx @@ -1,7 +1,7 @@ import { usePostHog } from "@posthog/react"; import { useState } from "react"; import { analyticsEvents, captureAnalyticsEvent } from "../lib/analytics"; -import { fetchOrgMembers } from "../lib/github"; +import { fetchOrgMembers } from "../lib/githubGraphQL"; import { ALL_STATS } from "../lib/stats"; import { useToast } from "../lib/ToastContext"; import type { FetchAllOptions } from "../lib/useContributions"; @@ -109,6 +109,24 @@ export default function SettingsDrawer({ } } + function toggleStat(id: string) { + setVisibleStats( + visibleStats.includes(id) ? visibleStats.filter((s) => s !== id) : [...visibleStats, id], + ); + } + + function handleDatePresetSelect(from: string, to: string) { + onFetch({ from, to, trigger: "date-preset" }); + } + + function handleUserInputChange(e: React.ChangeEvent) { + setUserInput(e.target.value); + } + + function handleUserInputKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter") addUser(); + } + return ( <> {/* Backdrop */} @@ -158,7 +176,6 @@ export default function SettingsDrawer({
- {/* --- Group 1: Authentication --- */}
Authentication
- {!token && ( -

- Sign in to configure your dashboard. -

- )} +
+ +
+ + +
- {token && ( - <> - {/* --- Group 2: Users (dominant section) --- */} -
- -
- setUserInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") addUser(); - }} - placeholder="Add username..." - className={`${inputClass} flex-1`} - /> - -
+ {users.length > 0 && ( +
+ {users.map((u) => ( + removeUser(u)} /> + ))} +
+ )} + {users.length === 0 && ( +

+ No users added yet. Add GitHub usernames above. +

+ )} - {users.length > 0 && ( -
- {users.map((u) => ( - removeUser(u)} /> - ))} -
- )} - {users.length === 0 && ( -

- No users added yet. Add GitHub usernames above. -

- )} +
+ + setOrg(e.target.value)} + placeholder={token ? "Optional — filter by org" : "Sign in to filter by org"} + disabled={!token} + className={`${inputClass} w-full ${!token ? "opacity-50" : ""}`} + /> +
- {/* Organization */} -
- - setOrg(e.target.value)} - placeholder="Optional — filter by org" - className={`${inputClass} w-full`} - /> -
+ {org && token && ( + + )} +
- {org && token && ( - - )} -
+
+ Date Range + +
- {/* --- Group 3: Date range --- */} -
- Date Range - onFetch({ from, to, trigger: "date-preset" })} - /> +
+ Display +
+ Visible Stats +
+ {ALL_STATS.map((stat) => ( + toggleStat(stat.id)} + > + {stat.label} + + ))}
+
- {/* --- Group 4: Display preferences (secondary) --- */} -
- Display -
- Visible Stats -
- {ALL_STATS.map((stat) => { - const active = visibleStats.includes(stat.id); - return ( - { - if (active) { - setVisibleStats(visibleStats.filter((s) => s !== stat.id)); - } else { - setVisibleStats([...visibleStats, stat.id]); - } - }} - > - {stat.label} - - ); - })} -
-
- -
- Auto Refresh -
- {REFRESH_OPTIONS.map((opt) => ( - setRefreshInterval(opt.value)} - > - {opt.label} - - ))} -
-
+
+ Auto Refresh +
+ {REFRESH_OPTIONS.map((opt) => ( + setRefreshInterval(opt.value)} + > + {opt.label} + + ))}
- - )} +
+
diff --git a/src/lib/fetchContributions.ts b/src/lib/fetchContributionsGraphQL.ts similarity index 99% rename from src/lib/fetchContributions.ts rename to src/lib/fetchContributionsGraphQL.ts index 0b98dd0..25bc1ef 100644 --- a/src/lib/fetchContributions.ts +++ b/src/lib/fetchContributionsGraphQL.ts @@ -1,4 +1,4 @@ -import { gql, QUERY_ORG, QUERY_USER, QUERY_USER_TOTAL } from "./github"; +import { gql, QUERY_ORG, QUERY_USER, QUERY_USER_TOTAL } from "./githubGraphQL"; import type { GitHubUser } from "./types"; export async function resolveOrgId( diff --git a/src/lib/fetchContributionsPublic.ts b/src/lib/fetchContributionsPublic.ts new file mode 100644 index 0000000..30c7f3c --- /dev/null +++ b/src/lib/fetchContributionsPublic.ts @@ -0,0 +1,194 @@ +import { fetchPublicEvents, fetchUserRest, type GitHubEvent } from "./githubRest"; +import type { + ContributionDay, + ContributionLevel, + ContributionsCollection, + ContributionWeek, + GitHubUser, + RepoContribution, +} from "./types"; + +/** Format a Date as YYYY-MM-DD in local timezone (matching the heatmap's date parsing). */ +function toLocalDateStr(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +/** Convert a UTC ISO timestamp to a local-timezone YYYY-MM-DD string. */ +function utcToLocalDateStr(iso: string): string { + return toLocalDateStr(new Date(iso)); +} + +function assignLevel(count: number, thresholds: number[]): ContributionLevel { + if (count === 0) return "NONE"; + if (count <= thresholds[0]) return "FIRST_QUARTILE"; + if (count <= thresholds[1]) return "SECOND_QUARTILE"; + if (count <= thresholds[2]) return "THIRD_QUARTILE"; + return "FOURTH_QUARTILE"; +} + +function buildContributions( + events: GitHubEvent[], + from: string, + to: string, +): { + calendar: ContributionsCollection["contributionCalendar"]; + stats: Pick< + ContributionsCollection, + | "totalCommitContributions" + | "totalPullRequestContributions" + | "totalPullRequestReviewContributions" + | "totalIssueContributions" + | "totalRepositoryContributions" + >; + commitContributionsByRepository: RepoContribution[]; +} { + // Count contributions per day and by type + const dayCounts = new Map(); + // Track commits per repo (owner/name → count) + const repoCommits = new Map(); + let commits = 0; + let prs = 0; + let reviews = 0; + let issues = 0; + let repos = 0; + + for (const event of events) { + const date = utcToLocalDateStr(event.created_at); + switch (event.type) { + case "PushEvent": { + const size = event.payload.size ?? 1; + dayCounts.set(date, (dayCounts.get(date) ?? 0) + size); + commits += size; + repoCommits.set(event.repo.name, (repoCommits.get(event.repo.name) ?? 0) + size); + break; + } + case "PullRequestEvent": + if (event.payload.action === "opened") { + dayCounts.set(date, (dayCounts.get(date) ?? 0) + 1); + prs++; + } + break; + case "PullRequestReviewEvent": + dayCounts.set(date, (dayCounts.get(date) ?? 0) + 1); + reviews++; + break; + case "IssuesEvent": + if (event.payload.action === "opened") { + dayCounts.set(date, (dayCounts.get(date) ?? 0) + 1); + issues++; + } + break; + case "CreateEvent": + if (event.payload.ref_type === "repository") { + dayCounts.set(date, (dayCounts.get(date) ?? 0) + 1); + repos++; + } + break; + } + } + + // Build top repos sorted by commit count (top 5, matching GraphQL maxRepositories) + const commitContributionsByRepository: RepoContribution[] = [...repoCommits.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([nameWithOwner, count]) => ({ + repository: { + name: nameWithOwner.split("/")[1] ?? nameWithOwner, + nameWithOwner, + url: `https://github.com/${nameWithOwner}`, + }, + contributions: { totalCount: count }, + })); + + // Compute quartile thresholds from non-zero days + const nonZero = [...dayCounts.values()].sort((a, b) => a - b); + const thresholds = + nonZero.length > 0 + ? [ + nonZero[Math.floor(nonZero.length * 0.25)] ?? 1, + nonZero[Math.floor(nonZero.length * 0.5)] ?? 2, + nonZero[Math.floor(nonZero.length * 0.75)] ?? 4, + ] + : [1, 2, 4]; + + // Build the calendar for the date range, aligned to weeks (Sunday start). + // Use local-timezone dates so they match the heatmap's `new Date("YYYY-MM-DDT00:00:00")`. + const fromDate = new Date(`${from.slice(0, 10)}T00:00:00`); + const toDate = new Date(`${to.slice(0, 10)}T00:00:00`); + + // Align to Sunday of the first week + const startDay = new Date(fromDate); + startDay.setDate(startDay.getDate() - startDay.getDay()); + + const weeks: ContributionWeek[] = []; + let totalContributions = 0; + const current = new Date(startDay); + + while (current <= toDate) { + const days: ContributionDay[] = []; + for (let wd = 0; wd < 7; wd++) { + const dateStr = toLocalDateStr(current); + const count = dayCounts.get(dateStr) ?? 0; + const inRange = current >= fromDate && current <= toDate; + const effectiveCount = inRange ? count : 0; + totalContributions += effectiveCount; + days.push({ + date: dateStr, + contributionCount: effectiveCount, + contributionLevel: inRange ? assignLevel(effectiveCount, thresholds) : "NONE", + weekday: wd, + }); + current.setDate(current.getDate() + 1); + } + weeks.push({ contributionDays: days }); + } + + return { + calendar: { totalContributions, weeks }, + stats: { + totalCommitContributions: commits, + totalPullRequestContributions: prs, + totalPullRequestReviewContributions: reviews, + totalIssueContributions: issues, + totalRepositoryContributions: repos, + }, + commitContributionsByRepository, + }; +} + +export async function fetchUserPublic( + username: string, + opts: { from: string; to: string }, + signal?: AbortSignal, +): Promise { + const [rest, events] = await Promise.all([ + fetchUserRest(username, undefined, signal), + fetchPublicEvents(username, undefined, signal), + ]); + + const { calendar, stats, commitContributionsByRepository } = buildContributions( + events, + opts.from, + opts.to, + ); + + return { + avatarUrl: rest.avatar_url, + bio: rest.bio, + company: rest.company, + location: rest.location, + websiteUrl: rest.blog || null, + createdAt: rest.created_at, + followers: { totalCount: rest.followers }, + following: { totalCount: rest.following }, + repositories: { nodes: [] }, + contributionsCollection: { + ...stats, + commitContributionsByRepository, + contributionCalendar: calendar, + }, + }; +} diff --git a/src/lib/github.ts b/src/lib/githubGraphQL.ts similarity index 100% rename from src/lib/github.ts rename to src/lib/githubGraphQL.ts diff --git a/src/lib/githubRest.ts b/src/lib/githubRest.ts new file mode 100644 index 0000000..0c93b1c --- /dev/null +++ b/src/lib/githubRest.ts @@ -0,0 +1,79 @@ +export interface RestUserProfile { + login: string; + avatar_url: string; + bio: string | null; + company: string | null; + location: string | null; + blog: string | null; + created_at: string; + public_repos: number; + public_gists: number; + followers: number; + following: number; +} + +export async function fetchUserRest( + username: string, + token?: string, + signal?: AbortSignal, +): Promise { + const headers: Record = { + Accept: "application/vnd.github+json", + }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + const res = await fetch(`https://api.github.com/users/${encodeURIComponent(username)}`, { + headers, + signal, + }); + if (res.status === 404) { + throw new Error(`User "${username}" not found`); + } + if (res.status === 403) { + throw new Error("Rate limited. Sign in for higher limits."); + } + if (!res.ok) { + throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`); + } + return res.json(); +} + +export interface GitHubEvent { + type: string; + created_at: string; + repo: { name: string }; + payload: { + size?: number; + action?: string; + ref_type?: string; + }; +} + +export async function fetchPublicEvents( + username: string, + token?: string, + signal?: AbortSignal, +): Promise { + const headers: Record = { + Accept: "application/vnd.github+json", + }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const allEvents: GitHubEvent[] = []; + // Fetch up to 3 pages (300 events max — GitHub's hard limit) + for (let page = 1; page <= 3; page++) { + const res = await fetch( + `https://api.github.com/users/${encodeURIComponent(username)}/events/public?per_page=100&page=${page}`, + { headers, signal }, + ); + if (!res.ok) break; + const events: GitHubEvent[] = await res.json(); + if (events.length === 0) break; + allEvents.push(...events); + if (events.length < 100) break; + } + return allEvents; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 2e12fec..33d832e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -58,6 +58,8 @@ export interface UserResult { loading?: boolean; error?: string; data?: GitHubUser; + /** True when data was fetched from public REST API (limited to public activity). */ + needsAuth?: boolean; /** Total contributions in the equally-sized period before the selected range. */ previousPeriodTotal?: number; /** Length of the selected period in days. */ diff --git a/src/lib/useContributions.ts b/src/lib/useContributions.ts index 61c4956..6654cbb 100644 --- a/src/lib/useContributions.ts +++ b/src/lib/useContributions.ts @@ -5,7 +5,8 @@ import { fetchPreviousPeriodTotal, fetchUserContributions, resolveOrgId, -} from "./fetchContributions"; +} from "./fetchContributionsGraphQL"; +import { fetchUserPublic } from "./fetchContributionsPublic"; import { useToast } from "./ToastContext"; import type { UserResult } from "./types"; import { useSortedUsers } from "./useSortedUsers"; @@ -20,7 +21,7 @@ interface UseContributionsParams { hasInitialUrlState: boolean; } -export type FetchValidationError = "missing-pat" | "missing-users"; +export type FetchValidationError = "missing-users"; export type FetchTrigger = "auto-refresh" | "date-preset" | "initial-url" | "manual" | "shortcut"; export interface FetchAllOptions { @@ -56,13 +57,155 @@ export function useContributions({ const fetchAll = useCallback( async (options?: FetchAllOptions) => { - if (!pat) { - addToast( - "error", - "No authentication configured. Sign in with GitHub or add a Personal Access Token in settings.", + interface FetchContext { + users: string[]; + from: string; + to: string; + periodDays: number; + prevFrom: string; + prevTo: string; + signal: AbortSignal; + trigger: FetchTrigger; + } + + async function fetchAllUnauthenticated(ctx: FetchContext) { + let errorCount = 0; + await Promise.all( + ctx.users.map(async (user) => { + try { + const data = await fetchUserPublic(user, { from: ctx.from, to: ctx.to }, ctx.signal); + if (ctx.signal.aborted) return; + setResults((r) => ({ + ...r, + [user]: { data, needsAuth: true, periodDays: ctx.periodDays }, + })); + } catch (e) { + if (ctx.signal.aborted) return; + errorCount++; + setResults((prev) => ({ + ...prev, + [user]: { error: (e as Error).message }, + })); + } + }), + ); + + if (ctx.signal.aborted) return; + setIsFetching(false); + + requestAnimationFrame(() => { + if (ctx.signal.aborted) return; + if (errorCount > 0) { + captureAnalyticsEvent(posthog, analyticsEvents.dashboardFetchFailed, { + authenticated: false, + error_count: errorCount, + period_days: ctx.periodDays, + success_count: ctx.users.length - errorCount, + trigger: ctx.trigger, + user_count: ctx.users.length, + }); + addToast( + "error", + `Failed to fetch data for ${errorCount} user${errorCount > 1 ? "s" : ""}. Check the cards for details.`, + ); + } else { + captureAnalyticsEvent(posthog, analyticsEvents.dashboardFetchSucceeded, { + authenticated: false, + period_days: ctx.periodDays, + trigger: ctx.trigger, + user_count: ctx.users.length, + }); + addToast( + "success", + `Showing public activity for ${ctx.users.length} user${ctx.users.length > 1 ? "s" : ""}. Sign in for full data.`, + ); + } + }); + } + + async function fetchAllAuthenticated(ctx: FetchContext) { + // Resolve org ID + let orgId: string | null = null; + if (org) { + orgId = await resolveOrgId(pat, org, ctx.signal); + if (ctx.signal.aborted) return; + if (!orgId) { + addToast("warning", `Could not resolve org "${org}". Fetching without org filter.`); + } + } + + // Fetch all users in parallel, then apply results in a single batch + // so cards and badges transition from skeleton to data simultaneously + let errorCount = 0; + const settled = await Promise.allSettled( + ctx.users.map(async (user) => { + const [data, previousPeriodTotal] = await Promise.all([ + fetchUserContributions( + pat, + user, + { orgId, from: ctx.from, to: ctx.to }, + ctx.signal, + ), + fetchPreviousPeriodTotal( + pat, + user, + { orgId, from: ctx.prevFrom, to: ctx.prevTo }, + ctx.signal, + ), + ]); + return { user, data, previousPeriodTotal }; + }), ); - return "missing-pat" as const; + + if (ctx.signal.aborted) return; + + const batch: Record = {}; + for (let i = 0; i < ctx.users.length; i++) { + const result = settled[i]; + if (result.status === "fulfilled") { + const { data, previousPeriodTotal } = result.value; + batch[ctx.users[i]] = { data, previousPeriodTotal, periodDays: ctx.periodDays }; + } else { + errorCount++; + batch[ctx.users[i]] = { error: result.reason?.message ?? String(result.reason) }; + } + } + setResults((prev) => ({ ...prev, ...batch })); + setIsFetching(false); + + // Defer toast so the card transitions settle before triggering another render + requestAnimationFrame(() => { + if (ctx.signal.aborted) return; + if (errorCount > 0) { + captureAnalyticsEvent(posthog, analyticsEvents.dashboardFetchFailed, { + authenticated: true, + error_count: errorCount, + has_org: Boolean(org), + period_days: ctx.periodDays, + success_count: ctx.users.length - errorCount, + trigger: ctx.trigger, + user_count: ctx.users.length, + }); + addToast( + "error", + `Failed to fetch data for ${errorCount} user${errorCount > 1 ? "s" : ""}. Check the cards for details.`, + ); + } else { + captureAnalyticsEvent(posthog, analyticsEvents.dashboardFetchSucceeded, { + authenticated: true, + has_org: Boolean(org), + period_days: ctx.periodDays, + trigger: ctx.trigger, + user_count: ctx.users.length, + }); + addToast( + "success", + `Fetched contributions for ${ctx.users.length} user${ctx.users.length > 1 ? "s" : ""}.`, + ); + } + }); } + if (!users.length) { addToast("error", "No users configured. Open settings to add usernames."); return "missing-users" as const; @@ -97,87 +240,49 @@ export function useContributions({ return next; }); - // Resolve org ID - let orgId: string | null = null; - if (org) { - orgId = await resolveOrgId(pat, org, signal); - if (signal.aborted) return; - if (!orgId) { - addToast("warning", `Could not resolve org "${org}". Fetching without org filter.`); - } - } + const ctx: FetchContext = { + users, + from, + to, + periodDays, + prevFrom, + prevTo, + signal, + trigger, + }; - // Fetch all users in parallel, then apply results in a single batch - // so cards and badges transition from skeleton to data simultaneously - let errorCount = 0; - const settled = await Promise.allSettled( - users.map(async (user) => { - const [data, previousPeriodTotal] = await Promise.all([ - fetchUserContributions(pat, user, { orgId, from, to }, signal), - fetchPreviousPeriodTotal(pat, user, { orgId, from: prevFrom, to: prevTo }, signal), - ]); - return { user, data, previousPeriodTotal }; - }), - ); - - if (signal.aborted) return; - - const batch: Record = {}; - for (let i = 0; i < users.length; i++) { - const result = settled[i]; - if (result.status === "fulfilled") { - const { data, previousPeriodTotal } = result.value; - batch[users[i]] = { data, previousPeriodTotal, periodDays }; - } else { - errorCount++; - batch[users[i]] = { error: result.reason?.message ?? String(result.reason) }; - } + if (pat) { + await fetchAllAuthenticated(ctx); + } else { + await fetchAllUnauthenticated(ctx); } - setResults((prev) => ({ ...prev, ...batch })); - setIsFetching(false); - - // Defer toast so the card transitions settle before triggering another render - requestAnimationFrame(() => { - if (signal.aborted) return; - if (errorCount > 0) { - captureAnalyticsEvent(posthog, analyticsEvents.dashboardFetchFailed, { - error_count: errorCount, - has_org: Boolean(org), - period_days: periodDays, - success_count: users.length - errorCount, - trigger, - user_count: users.length, - }); - addToast( - "error", - `Failed to fetch data for ${errorCount} user${errorCount > 1 ? "s" : ""}. Check the cards for details.`, - ); - } else { - captureAnalyticsEvent(posthog, analyticsEvents.dashboardFetchSucceeded, { - has_org: Boolean(org), - period_days: periodDays, - trigger, - user_count: users.length, - }); - addToast( - "success", - `Fetched contributions for ${users.length} user${users.length > 1 ? "s" : ""}.`, - ); - } - }); }, [addToast, fromDate, org, pat, posthog, toDate, users], ); const fetchUser = useCallback( async (username: string) => { - if (!pat) return; + setResults((prev) => ({ ...prev, [username]: { ...prev[username], loading: true } })); + + if (!pat) { + // Unauthenticated: fetch public profile + events via REST + try { + const from = new Date(fromDate).toISOString(); + const to = new Date(toDate).toISOString(); + const data = await fetchUserPublic(username, { from, to }); + setResults((prev) => ({ ...prev, [username]: { data, needsAuth: true } })); + } catch (e) { + setResults((prev) => ({ + ...prev, + [username]: { error: (e as Error).message }, + })); + } + return; + } const from = new Date(fromDate).toISOString(); const to = new Date(toDate).toISOString(); - setResults((prev) => ({ ...prev, [username]: { ...prev[username], loading: true } })); - const orgId = org ? await resolveOrgId(pat, org) : null; try { diff --git a/src/lib/useExport.ts b/src/lib/useExport.ts index c2038d3..f9e4711 100644 --- a/src/lib/useExport.ts +++ b/src/lib/useExport.ts @@ -36,6 +36,8 @@ export function useExport(elementSelector: string, userCount: number) { pixelRatio: 2, backgroundColor: bg, width: snugWidth, + filter: (node) => + !(node instanceof HTMLElement && node.hasAttribute("data-export-hidden")), }); } finally { // no DOM cleanup needed — we only read measurements From 7fb127d53e488ceac85e94a23a7b723e6669d01c Mon Sep 17 00:00:00 2001 From: Mathijs Rutgers Date: Mon, 6 Apr 2026 14:36:21 +0200 Subject: [PATCH 2/3] Clean up organisation inputs --- src/components/ContributionCard.tsx | 12 +++-- .../SettingsDrawer/OrganisationInput.tsx | 50 +++++++++++++++++++ .../{ => SettingsDrawer}/SettingsDrawer.tsx | 37 +++++++------- src/components/SettingsDrawer/index.ts | 1 + 4 files changed, 77 insertions(+), 23 deletions(-) create mode 100644 src/components/SettingsDrawer/OrganisationInput.tsx rename src/components/{ => SettingsDrawer}/SettingsDrawer.tsx (91%) create mode 100644 src/components/SettingsDrawer/index.ts diff --git a/src/components/ContributionCard.tsx b/src/components/ContributionCard.tsx index 10a2ca4..e3268d2 100644 --- a/src/components/ContributionCard.tsx +++ b/src/components/ContributionCard.tsx @@ -170,14 +170,16 @@ export default function ContributionCard({ {result.needsAuth && onSignIn && ( - + Public activity only — sign in for full data + )} )} diff --git a/src/components/SettingsDrawer/OrganisationInput.tsx b/src/components/SettingsDrawer/OrganisationInput.tsx new file mode 100644 index 0000000..cd608aa --- /dev/null +++ b/src/components/SettingsDrawer/OrganisationInput.tsx @@ -0,0 +1,50 @@ +const signInLink = + "text-gh-accent hover:text-gh-accent-hover bg-transparent border-none p-0 cursor-pointer text-sm"; + +interface OrganisationInputProps { + organisation: string; + onChange: (organisation: string) => void; + token: string; + onSignIn: () => void; + className?: string; +} + +export default function OrganisationInput({ + organisation, + onChange, + token, + onSignIn, + className, +}: OrganisationInputProps) { + if (token) { + return ( + onChange(e.target.value)} + placeholder="Optional — filter by organization" + className={`${className} w-full`} + /> + ); + } + + if (organisation) { + return ( +

+ {organisation} —{" "} + +

+ ); + } + + return ( +

+ {" "} + to filter by organization +

+ ); +} diff --git a/src/components/SettingsDrawer.tsx b/src/components/SettingsDrawer/SettingsDrawer.tsx similarity index 91% rename from src/components/SettingsDrawer.tsx rename to src/components/SettingsDrawer/SettingsDrawer.tsx index dd85bef..3e8d8a3 100644 --- a/src/components/SettingsDrawer.tsx +++ b/src/components/SettingsDrawer/SettingsDrawer.tsx @@ -1,21 +1,23 @@ import { usePostHog } from "@posthog/react"; import { useState } from "react"; -import { analyticsEvents, captureAnalyticsEvent } from "../lib/analytics"; -import { fetchOrgMembers } from "../lib/githubGraphQL"; -import { ALL_STATS } from "../lib/stats"; -import { useToast } from "../lib/ToastContext"; -import type { FetchAllOptions } from "../lib/useContributions"; -import { useDialogBehavior } from "../lib/useDialogBehavior"; -import type { UseSettingsReturn } from "../lib/useSettings"; -import AuthSection from "./AuthSection"; -import DatePresets from "./DatePresets"; -import PillButton from "./PillButton"; -import UserChip from "./UserChip"; +import { analyticsEvents, captureAnalyticsEvent } from "../../lib/analytics"; +import { fetchOrgMembers } from "../../lib/githubGraphQL"; +import { ALL_STATS } from "../../lib/stats"; +import { useToast } from "../../lib/ToastContext"; +import type { FetchAllOptions } from "../../lib/useContributions"; +import { useDialogBehavior } from "../../lib/useDialogBehavior"; +import type { UseSettingsReturn } from "../../lib/useSettings"; +import AuthSection from "../AuthSection"; +import DatePresets from "../DatePresets"; +import PillButton from "../PillButton"; +import UserChip from "../UserChip"; +import OrganisationInput from "./OrganisationInput"; const inputClass = "px-3 py-2 rounded-lg border border-gh-border bg-gh-card text-gh-text-primary text-base outline-none focus:border-gh-accent focus-visible:ring-2 focus-visible:ring-gh-accent focus-visible:ring-offset-1 focus-visible:ring-offset-gh-bg"; const sectionLabel = "text-xs text-gh-text-secondary font-medium uppercase tracking-wider"; + const sectionDivider = "border-t border-gh-border"; const REFRESH_OPTIONS = [ @@ -228,13 +230,12 @@ export default function SettingsDrawer({ - setOrg(e.target.value)} - placeholder={token ? "Optional — filter by org" : "Sign in to filter by org"} - disabled={!token} - className={`${inputClass} w-full ${!token ? "opacity-50" : ""}`} +
diff --git a/src/components/SettingsDrawer/index.ts b/src/components/SettingsDrawer/index.ts new file mode 100644 index 0000000..055463c --- /dev/null +++ b/src/components/SettingsDrawer/index.ts @@ -0,0 +1 @@ +export { default } from "./SettingsDrawer.tsx"; From 7246d43077e175a1e5765820c4f7f752c2912bc8 Mon Sep 17 00:00:00 2001 From: Mathijs Rutgers Date: Mon, 6 Apr 2026 16:14:25 +0200 Subject: [PATCH 3/3] Move signin button outside ContributionCard --- src/components/ContributionCard.tsx | 232 ++++++++++++++-------------- src/lib/useContributions.ts | 7 +- 2 files changed, 117 insertions(+), 122 deletions(-) diff --git a/src/components/ContributionCard.tsx b/src/components/ContributionCard.tsx index e3268d2..d0180cc 100644 --- a/src/components/ContributionCard.tsx +++ b/src/components/ContributionCard.tsx @@ -57,133 +57,133 @@ export default function ContributionCard({ const Wrapper = isClickable ? "button" : "div"; return ( - } - type={isClickable ? "button" : undefined} - aria-label={isClickable ? `View details for ${username}` : undefined} - className={`${sharedClass} ${isClickable ? "cursor-pointer hover:border-gh-accent/50" : ""}`} - onClick={isClickable ? handleSelect : undefined} - > - {/* Header */} -
-
- {(!avatarUrl || !avatarLoaded) && ( -
- )} - {avatarUrl && ( - {`${username}'s setAvatarLoaded(true)} - /> - )} -
-
- - {username} - {hasStreak && ( - - 🔥 - +
+ } + type={isClickable ? "button" : undefined} + aria-label={isClickable ? `View details for ${username}` : undefined} + className={`${sharedClass} ${isClickable ? "cursor-pointer hover:border-gh-accent/50" : ""}`} + onClick={isClickable ? handleSelect : undefined} + > + {/* Header */} +
+
+ {(!avatarUrl || !avatarLoaded) && ( +
)} - - {totalContributions != null ? ( -
- - - {totalContributions} contributions + {avatarUrl && ( + {`${username}'s setAvatarLoaded(true)} + /> + )} +
+
+ + {username} + {hasStreak && ( + + 🔥 - {totalContributions} - - {velocity && velocity.percentage !== 0 && ( - )} -
- ) : result.loading ? ( -
- ) : null} + + {totalContributions != null ? ( +
+ + + {totalContributions} contributions + + {totalContributions} + + {velocity && velocity.percentage !== 0 && ( + + )} +
+ ) : result.loading ? ( +
+ ) : null} +
-
- {/* Badges */} -
- {badges.length > 0 - ? badges.map((b) => ( - - - {b.label} - - )) - : result.loading - ? ["badge-sk-1", "badge-sk-2", "badge-sk-3"].map((key) => ( -
+ {/* Badges */} +
+ {badges.length > 0 + ? badges.map((b) => ( + + + {b.label} + )) - : null} -
+ : result.loading + ? ["badge-sk-1", "badge-sk-2", "badge-sk-3"].map((key) => ( +
+ )) + : null} +
- {/* Body */} - {result.loading && !collection && ( -
- {/* Heatmap skeleton — matches SVG aspect ratio (7 rows × 16px + 20px top) */} -
-
-
+ {/* Body */} + {result.loading && !collection && ( +
+ {/* Heatmap skeleton — matches SVG aspect ratio (7 rows × 16px + 20px top) */} +
+
+
+
+
+ {/* Stats skeleton */} +
+ {Array.from( + { length: visibleStats.length || ALL_STATS.length }, + (_, i) => `stat-sk-${i}`, + ).map((key) => ( +
+
+
+
+ ))}
- {/* Stats skeleton */} -
- {Array.from( - { length: visibleStats.length || ALL_STATS.length }, - (_, i) => `stat-sk-${i}`, - ).map((key) => ( -
-
-
-
- ))} + )} + {result.error && ( +
+ {result.error}
-
- )} - {result.error && ( -
- {result.error} -
- )} - {collection && ( - <> - - - {result.needsAuth && onSignIn && ( - { if (e.key === "Enter" || e.key === " ") handleSignIn(e as unknown as React.MouseEvent); }} - data-export-hidden - className="mt-2 block w-full text-center text-[11px] text-gh-text-secondary hover:text-gh-accent transition-colors cursor-pointer p-1 rounded focus-visible:ring-2 focus-visible:ring-gh-accent" - > - Public activity only — sign in for full data - - )} - + )} + {collection && ( + <> + + + + )} + + {result.needsAuth && onSignIn && ( + )} - +
); } diff --git a/src/lib/useContributions.ts b/src/lib/useContributions.ts index 6654cbb..5db7898 100644 --- a/src/lib/useContributions.ts +++ b/src/lib/useContributions.ts @@ -140,12 +140,7 @@ export function useContributions({ const settled = await Promise.allSettled( ctx.users.map(async (user) => { const [data, previousPeriodTotal] = await Promise.all([ - fetchUserContributions( - pat, - user, - { orgId, from: ctx.from, to: ctx.to }, - ctx.signal, - ), + fetchUserContributions(pat, user, { orgId, from: ctx.from, to: ctx.to }, ctx.signal), fetchPreviousPeriodTotal( pat, user,