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..d0180cc 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) { @@ -49,121 +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 */} -
-
- {(!result.data || !avatarLoaded) && ( -
- )} - {result.data && ( - {`${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 && ( - <> - - - + )} + {collection && ( + <> + + + + )} + + {result.needsAuth && onSignIn && ( + )} - +
); } diff --git a/src/components/SettingsDrawer.tsx b/src/components/SettingsDrawer.tsx deleted file mode 100644 index 549002b..0000000 --- a/src/components/SettingsDrawer.tsx +++ /dev/null @@ -1,311 +0,0 @@ -import { usePostHog } from "@posthog/react"; -import { useState } from "react"; -import { analyticsEvents, captureAnalyticsEvent } from "../lib/analytics"; -import { fetchOrgMembers } from "../lib/github"; -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"; - -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 = [ - { value: 0, label: "Off" }, - { value: 60, label: "1 min" }, - { value: 300, label: "5 min" }, - { value: 900, label: "15 min" }, - { value: 1800, label: "30 min" }, -]; - -interface SettingsDrawerProps { - open: boolean; - onClose: () => void; - settings: UseSettingsReturn; - token: string; - authMethod: "oauth" | "pat" | "none"; - isAuthenticating: boolean; - authError: string | null; - onSignIn: () => void; - onSignOut: () => void; - onUserAdded: (username: string) => void; - onFetch: (options?: FetchAllOptions) => void; -} - -export default function SettingsDrawer({ - open, - onClose, - settings, - token, - authMethod, - isAuthenticating, - authError, - onSignIn, - onSignOut, - onUserAdded, - onFetch, -}: SettingsDrawerProps) { - const { - pat, - setPat, - org, - setOrg, - fromDate, - setFromDate, - toDate, - setToDate, - users, - setUsers, - visibleStats, - setVisibleStats, - refreshInterval, - setRefreshInterval, - } = settings; - const { addToast } = useToast(); - const posthog = usePostHog(); - const [userInput, setUserInput] = useState(""); - const [importingOrg, setImportingOrg] = useState(false); - const { containerRef, handleKeyDown } = useDialogBehavior({ open, onClose }); - - function addUser() { - const u = userInput.trim().toLowerCase(); - if (u && !users.includes(u)) { - setUsers([...users, u]); - onUserAdded(u); - } - setUserInput(""); - } - - function removeUser(username: string) { - setUsers(users.filter((x) => x !== username)); - } - - async function importOrgMembers() { - if (!token || !org) return; - setImportingOrg(true); - try { - const members = await fetchOrgMembers(token, org); - const newUsers = members.filter((m) => !users.includes(m)); - if (newUsers.length > 0) { - setUsers([...users, ...newUsers]); - for (const u of newUsers) onUserAdded(u); - } - captureAnalyticsEvent(posthog, analyticsEvents.orgImportCompleted, { - imported_count: newUsers.length, - total_user_count: users.length + newUsers.length, - }); - } catch (e) { - addToast("error", `Failed to import org members: ${(e as Error).message}`); - } finally { - setImportingOrg(false); - } - } - - return ( - <> - {/* Backdrop */} - -
- -
- {/* --- 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. -

- )} - - {/* Organization */} -
- - setOrg(e.target.value)} - placeholder="Optional — filter by org" - className={`${inputClass} w-full`} - /> -
- - {org && token && ( - - )} -
- - {/* --- Group 3: Date range --- */} -
- Date Range - onFetch({ from, to, trigger: "date-preset" })} - /> -
- - {/* --- 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} - - ))} -
-
-
- - )} -
- - - ); -} 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/SettingsDrawer.tsx b/src/components/SettingsDrawer/SettingsDrawer.tsx new file mode 100644 index 0000000..3e8d8a3 --- /dev/null +++ b/src/components/SettingsDrawer/SettingsDrawer.tsx @@ -0,0 +1,305 @@ +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 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 = [ + { value: 0, label: "Off" }, + { value: 60, label: "1 min" }, + { value: 300, label: "5 min" }, + { value: 900, label: "15 min" }, + { value: 1800, label: "30 min" }, +]; + +interface SettingsDrawerProps { + open: boolean; + onClose: () => void; + settings: UseSettingsReturn; + token: string; + authMethod: "oauth" | "pat" | "none"; + isAuthenticating: boolean; + authError: string | null; + onSignIn: () => void; + onSignOut: () => void; + onUserAdded: (username: string) => void; + onFetch: (options?: FetchAllOptions) => void; +} + +export default function SettingsDrawer({ + open, + onClose, + settings, + token, + authMethod, + isAuthenticating, + authError, + onSignIn, + onSignOut, + onUserAdded, + onFetch, +}: SettingsDrawerProps) { + const { + pat, + setPat, + org, + setOrg, + fromDate, + setFromDate, + toDate, + setToDate, + users, + setUsers, + visibleStats, + setVisibleStats, + refreshInterval, + setRefreshInterval, + } = settings; + const { addToast } = useToast(); + const posthog = usePostHog(); + const [userInput, setUserInput] = useState(""); + const [importingOrg, setImportingOrg] = useState(false); + const { containerRef, handleKeyDown } = useDialogBehavior({ open, onClose }); + + function addUser() { + const u = userInput.trim().toLowerCase(); + if (u && !users.includes(u)) { + setUsers([...users, u]); + onUserAdded(u); + } + setUserInput(""); + } + + function removeUser(username: string) { + setUsers(users.filter((x) => x !== username)); + } + + async function importOrgMembers() { + if (!token || !org) return; + setImportingOrg(true); + try { + const members = await fetchOrgMembers(token, org); + const newUsers = members.filter((m) => !users.includes(m)); + if (newUsers.length > 0) { + setUsers([...users, ...newUsers]); + for (const u of newUsers) onUserAdded(u); + } + captureAnalyticsEvent(posthog, analyticsEvents.orgImportCompleted, { + imported_count: newUsers.length, + total_user_count: users.length + newUsers.length, + }); + } catch (e) { + addToast("error", `Failed to import org members: ${(e as Error).message}`); + } finally { + setImportingOrg(false); + } + } + + 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 */} + +
+ +
+
+ Authentication + +
+ +
+ +
+ + +
+ + {users.length > 0 && ( +
+ {users.map((u) => ( + removeUser(u)} /> + ))} +
+ )} + {users.length === 0 && ( +

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

+ )} + +
+ + +
+ + {org && token && ( + + )} +
+ +
+ Date Range + +
+ +
+ Display +
+ Visible Stats +
+ {ALL_STATS.map((stat) => ( + toggleStat(stat.id)} + > + {stat.label} + + ))} +
+
+ +
+ Auto Refresh +
+ {REFRESH_OPTIONS.map((opt) => ( + setRefreshInterval(opt.value)} + > + {opt.label} + + ))} +
+
+
+
+ + + ); +} 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"; 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..5db7898 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,150 @@ 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 +235,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