diff --git a/src/components/dither/bar-list.tsx b/src/components/dither/bar-list.tsx new file mode 100644 index 0000000..2206f0b --- /dev/null +++ b/src/components/dither/bar-list.tsx @@ -0,0 +1,227 @@ +import clsx from "clsx"; +import { useEffect, useRef } from "react"; +import { + CELL, + clamp01, + easeOutCubic, + paintColumn, + prefersReducedMotion, +} from "./dither-paint"; +import { type DitherColor, seedOfColor } from "./palette"; +import { useChartDimensions } from "./use-chart-dimensions"; + +export interface BarListItem { + /** Per-item hue override — e.g. `grey` for a bot or an "everything else" bucket. */ + color?: DitherColor; + key: string; + label: React.ReactNode; + value: number; + /** Text shown at the end of the row; defaults to the localized value. */ + valueLabel?: string; +} + +// Fraction of the entrance spent staggering row starts. +const STAGGER = 0.55; +const ENTRANCE_MS = 900; +const TRACK_HEIGHT = 16; // css px — 8 dither cells tall +const DIM = 0.5; + +interface BarPaintJob { + activeKey: string | null; + canvas: HTMLCanvasElement; + color: DitherColor; + item: BarListItem; + lengthCells: number; + max: number; + offscreen: HTMLCanvasElement; + offscreenContext: CanvasRenderingContext2D; + start: number; + thickCells: number; +} + +/** Paint one bar at `progress` (0–1 of the entrance) onto its canvas. */ +const paintBar = (progress: number, job: BarPaintJob): void => { + const context = job.canvas.getContext("2d"); + if (!context) { + return; + } + // Setting the size also clears the canvas for this repaint. + job.canvas.width = job.lengthCells; + job.canvas.height = job.thickCells; + const grown = + (job.item.value / job.max) * + easeOutCubic(clamp01((progress - job.start) / (1 - STAGGER))); + if (grown <= 0) { + return; + } + const isActive = job.activeKey === job.item.key; + const dim = job.activeKey !== null && !isActive ? DIM : 1; + job.offscreenContext.clearRect(0, 0, job.thickCells, job.lengthCells); + const top = (1 - grown) * job.lengthCells; + const seed = seedOfColor(job.item.color ?? job.color); + for (let x = 0; x < job.thickCells; x += 1) { + paintColumn(job.offscreenContext, x, top, job.lengthCells, seed, { + dim, + intensity: isActive ? 1 : 0, + stacked: false, + variant: "gradient", + }); + } + // Rotate the vertically-painted bar 90° clockwise: offscreen (x, y) lands at + // (lengthCells − y, x), so the baseline maps to the left edge. + context.save(); + context.translate(job.lengthCells, 0); + context.rotate(Math.PI / 2); + context.drawImage(job.offscreen, 0, 0); + context.restore(); +}; + +/** + * Horizontal dither **bar list** — one labelled row per item, values always + * visible, longest bar = the max. Each bar is painted with the shared + * {@link paintColumn} dither on an offscreen canvas (as a vertical bar) and + * blitted rotated 90°, so the baseline lands on the left and the soft outline + * on the value end. Rows grow in a staggered top-to-bottom wave. + * + * Emphasis is controlled: the row whose key is `activeKey` is lifted and the + * rest are dimmed, so several lists can highlight the same entity together. + * `renderRow` wraps each row's cells (e.g. in a button) to own the interaction. + */ +export function BarList({ + activeKey = null, + className, + color = "blue", + items, + renderRow, +}: Readonly<{ + activeKey?: string | null; + className?: string; + /** Series hue for items without their own `color`. */ + color?: DitherColor; + items: BarListItem[]; + renderRow?: (item: BarListItem, cells: React.ReactNode) => React.ReactNode; +}>) { + const { ref: trackRef, size } = useChartDimensions(); + const canvasRefs = useRef<(HTMLCanvasElement | null)[]>([]); + const activeRef = useRef(activeKey); + const itemsRef = useRef(items); + const progressRef = useRef(0); + // Parents tend to rebuild `items` on every render; only a change in what the + // bars show should restart the entrance, so the effect keys on content. + const itemsKey = items + .map((item) => `${item.key}:${item.value}:${item.color ?? ""}`) + .join("|"); + + itemsRef.current = items; + const paintRef = useRef<(progress: number) => void>(() => { + // replaced once the paint effect runs + }); + + // biome-ignore lint/correctness/useExhaustiveDependencies: itemsKey stands in for items (see above) + useEffect(() => { + const { width } = size; + const currentItems = itemsRef.current; + if (width <= 0 || currentItems.length === 0) { + return; + } + const lengthCells = Math.max(8, Math.round(width / CELL)); + const thickCells = Math.max(2, Math.round(TRACK_HEIGHT / CELL)); + const max = Math.max(...currentItems.map((item) => item.value), 1); + const offscreen = document.createElement("canvas"); + offscreen.width = thickCells; + offscreen.height = lengthCells; + const offscreenContext = offscreen.getContext("2d"); + if (!offscreenContext) { + return; + } + + const paintAll = (progress: number): void => { + currentItems.forEach((item, index) => { + const canvas = canvasRefs.current[index]; + if (!canvas) { + return; + } + paintBar(progress, { + activeKey: activeRef.current, + canvas, + color, + item, + lengthCells, + max, + offscreen, + offscreenContext, + start: + currentItems.length > 1 + ? (index / (currentItems.length - 1)) * STAGGER + : 0, + thickCells, + }); + }); + }; + paintRef.current = paintAll; + + if (prefersReducedMotion()) { + progressRef.current = 1; + paintAll(1); + return; + } + let frame = 0; + let startTime = 0; + const tick = (now: number): void => { + if (!startTime) { + startTime = now; + } + const progress = Math.min(1, (now - startTime) / ENTRANCE_MS); + progressRef.current = progress; + paintAll(progress); + if (progress < 1) { + frame = requestAnimationFrame(tick); + } + }; + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); + }, [size, itemsKey, color]); + + // Emphasis changes are a repaint at the current progress, not a re-entrance. + useEffect(() => { + activeRef.current = activeKey; + paintRef.current(progressRef.current); + }, [activeKey]); + + return ( + + ); +} diff --git a/src/components/dither/dither-paint.ts b/src/components/dither/dither-paint.ts new file mode 100644 index 0000000..3a6c069 --- /dev/null +++ b/src/components/dither/dither-paint.ts @@ -0,0 +1,115 @@ +// Ported from arsenstorm.com (MIT, © Arsen Shkrumelyak) — the ordered-dither +// paint engine behind its charts, trimmed to what the bar list needs. +import { rgb, type Seed } from "./palette"; + +const BAYER_SIZE = 4; +const BAYER_STEPS = 16; + +// 4×4 ordered (Bayer) matrix, normalized to 0–1 thresholds. +export const BAYER = [ + [0, 8, 2, 10], + [12, 4, 14, 6], + [3, 11, 1, 9], + [15, 7, 13, 5], +].map((row) => row.map((value) => (value + 0.5) / BAYER_STEPS)); + +export const CELL = 2; // css px per dither cell — chunky enough to read pixelated +// Opacity of the top border outline (just under solid, so it reads as a soft +// edge rather than a hard line). +export const BORDER_ALPHA = 0.72; +// Opacity of a dither "off" cell relative to an "on" cell. The scatter modulates +// between these two tiers of the *same* colour instead of leaving holes, so the +// background never shows through as stark white on a light theme. +export const OFF_TIER = 0.4; + +const DOTTED_BIAS = 0.12; +const STACKED_BIAS = 0.2; +const HOVER_THRESHOLD_LIFT = 0.1; +const HOVER_ALPHA_LIFT = 0.22; +const MIN_DENSITY_ALPHA = 0.3; + +export type PaintVariant = "dotted" | "gradient" | "hatched" | "solid"; + +export interface PaintOpts { + dim: number; // selection dim multiplier (0.5 dimmed, 1 normal) + intensity: number; // 0–1 hover lift + sparse?: number; // raise the dither threshold (thin out) — front layers + stacked: boolean; // denser + solid floor when layers stack + variant: PaintVariant; +} + +const bayerAt = (x: number, y: number): number => + BAYER[y % BAYER_SIZE]?.[x % BAYER_SIZE] ?? 0; + +// Colour vs opacity — the guiding rule for the whole engine: every pixel is the +// series' single `fill` colour and only its alpha varies, so the same paint +// reads correctly on both light and dark backgrounds. + +/** + * Fill one backing-canvas column `x` from row `top` down to `floor` with the + * ordered-dither scatter — solid at the floor, dissolving upward so it fades + * out toward the value line — then cap the top with a soft border outline in + * the series colour. + */ +export function paintColumn( + context: CanvasRenderingContext2D, + x: number, + top: number, + floor: number, + seed: Seed, + { variant, intensity, dim, stacked, sparse = 0 }: PaintOpts +): void { + const t = Math.round(top); + const f = Math.round(floor); + const depth = f - t; + if (depth <= 0) { + context.fillStyle = rgb(seed.fill, 1, BORDER_ALPHA * dim); + context.fillRect(x, t, 1, 1); + return; + } + const bias = + (variant === "dotted" ? DOTTED_BIAS : 0) + + (stacked ? STACKED_BIAS : 0) - + sparse; + for (let y = t; y < f; y += 1) { + // Inverted falloff: 0 at the top line, 1 at the floor — dense at the + // bottom, thinning as it rises toward the outline. + let density = (y - t) / depth; + if (stacked) { + density = 0.5 + 0.5 * density; + } + if (variant === "hatched" && (x + y) % BAYER_SIZE >= 2) { + continue; + } + const lit = + variant === "solid" || + density > bayerAt(x, y) - HOVER_THRESHOLD_LIFT * intensity - bias; + // "dotted" keeps real gaps for its open look; every other variant covers + // the cell and lets the dither ride the alpha. + if (variant === "dotted" && !lit) { + continue; + } + const k = + (MIN_DENSITY_ALPHA + density * (1 - MIN_DENSITY_ALPHA)) * + (1 + HOVER_ALPHA_LIFT * intensity); + const alpha = clamp01((lit ? k : k * OFF_TIER) * dim); + context.fillStyle = rgb(seed.fill, 1, alpha); + context.fillRect(x, y, 1, 1); + } + // Top border outline — kept just under full opacity, with a faint feather + // row beneath, so it reads as a soft edge rather than a hard line. + context.fillStyle = rgb(seed.fill, 1, BORDER_ALPHA * dim); + context.fillRect(x, t, 1, 1); + if (depth > 1) { + context.fillStyle = rgb(seed.fill, 1, BORDER_ALPHA * 0.5 * dim); + context.fillRect(x, t + 1, 1, 1); + } +} + +export const easeOutCubic = (t: number): number => 1 - (1 - t) ** 3; + +export const clamp01 = (t: number): number => Math.min(1, Math.max(0, t)); + +/** Whether the OS asks for reduced motion (snap to the final frame). */ +export const prefersReducedMotion = (): boolean => + window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false; diff --git a/src/components/dither/palette.ts b/src/components/dither/palette.ts new file mode 100644 index 0000000..d474982 --- /dev/null +++ b/src/components/dither/palette.ts @@ -0,0 +1,44 @@ +export type Rgb = [number, number, number]; + +export type DitherColor = + | "green" + | "blue" + | "purple" + | "pink" + | "orange" + | "red" + | "grey"; + +export interface Seed { + fill: Rgb; + line: Rgb; + star: Rgb; +} + +// Each seed: the area-fill hue, the bright series line, and the star sparkle. +export const PALETTE: Record = { + blue: { fill: [53, 143, 243], line: [150, 200, 255], star: [205, 228, 255] }, + green: { fill: [40, 210, 110], line: [150, 255, 180], star: [200, 255, 220] }, + // No-data: a muted grey so empty metrics read as "nothing here". + grey: { fill: [92, 92, 100], line: [140, 140, 150], star: [165, 165, 175] }, + orange: { + fill: [255, 150, 50], + line: [255, 195, 130], + star: [255, 220, 175], + }, + pink: { fill: [240, 90, 190], line: [255, 170, 220], star: [255, 205, 235] }, + purple: { + fill: [150, 110, 255], + line: [200, 175, 255], + star: [225, 210, 255], + }, + red: { fill: [240, 70, 70], line: [255, 150, 140], star: [255, 195, 185] }, +}; + +export const rgb = ([r, g, b]: Rgb, k = 1, a = 1) => + `rgba(${Math.round(r * k)},${Math.round(g * k)},${Math.round(b * k)},${a})`; + +export const seedOfColor = (color: DitherColor): Seed => PALETTE[color]; + +export const isDitherColor = (value: unknown): value is DitherColor => + typeof value === "string" && value in PALETTE; diff --git a/src/components/dither/use-chart-dimensions.ts b/src/components/dither/use-chart-dimensions.ts new file mode 100644 index 0000000..21b8e0f --- /dev/null +++ b/src/components/dither/use-chart-dimensions.ts @@ -0,0 +1,43 @@ +import { useLayoutEffect, useRef, useState } from "react"; + +export interface Dimensions { + height: number; + width: number; +} + +/** + * Tracks an element's CSS pixel size via {@link ResizeObserver}. Uses + * `clientWidth`/`clientHeight` (the layout size) rather than + * `getBoundingClientRect()` so a parent `layoutId` morph — which scales the + * element via a transform — can't trick the chart into measuring a scaled size + * and locking its canvas to it. + */ +export function useChartDimensions() { + const ref = useRef(null); + const [size, setSize] = useState({ height: 0, width: 0 }); + + useLayoutEffect(() => { + const el = ref.current; + // biome-ignore lint/suspicious/noUnnecessaryConditions: the ref is null until the element mounts + if (!el) { + return; + } + + const measure = () => { + const width = Math.max(0, el.clientWidth); + const height = Math.max(0, el.clientHeight); + setSize((prev) => + prev.width === width && prev.height === height + ? prev // guard against repeat fires + : { height, width } + ); + }; + + const ro = new ResizeObserver(measure); + ro.observe(el); + measure(); + return () => ro.disconnect(); + }, []); + + return { ref, size }; +} diff --git a/src/components/fame/contributor-facets.tsx b/src/components/fame/contributor-facets.tsx new file mode 100644 index 0000000..197d30f --- /dev/null +++ b/src/components/fame/contributor-facets.tsx @@ -0,0 +1,171 @@ +import clsx from "clsx"; +import { type PointerEvent, useState } from "react"; +import { BarList, type BarListItem } from "@/components/dither/bar-list"; +import { Text } from "@/components/ui/text"; +import { formatCompactNumber, formatCount } from "@/format"; +import { + CONTRIBUTOR_SORT_KEYS, + type ContributorRow, + type ContributorSortKey, + summarizeContributors, +} from "@/github/contributor-summary"; +import type { ContributorStats } from "@/github/types"; + +const PANEL_TITLES: Record = { + activeWeeks: "Active weeks", + additions: "Lines added", + commits: "Commits", + deletions: "Lines deleted", +}; +const ROWS_PER_PANEL = 8; +// The signed-in user's bars use a second hue so they can find themselves at a glance. +const VIEWER_COLOR = "green"; + +const ROW_CLASS_NAME = + "grid min-h-11 w-full @sm:grid-cols-[minmax(0,9rem)_minmax(0,1fr)_4.5rem] grid-cols-[minmax(0,6.5rem)_minmax(0,1fr)_4rem] items-center gap-3 rounded text-left focus-visible:outline-2 focus-visible:outline-blue-500 sm:min-h-10"; + +function BotPill() { + return ( + + bot + + ); +} + +const toBarListItem = ( + row: ContributorRow, + metric: ContributorSortKey, + viewerLogin: string | undefined +): BarListItem => ({ + color: row.login === viewerLogin ? VIEWER_COLOR : undefined, + key: row.login, + label: ( + + {row.name} + {row.isBot ? : null} + + ), + value: row[metric], + valueLabel: formatCompactNumber(row[metric]), +}); + +const describeFooter = ( + hiddenRank: number, + remainingCount: number +): string | null => { + if (hiddenRank >= 0) { + return `Highlighted contributor ranks #${hiddenRank + 1} here.`; + } + + return remainingCount > 0 + ? `${formatCount(remainingCount, "more contributor", "more contributors")} not shown` + : null; +}; + +function FacetPanel({ + contributors, + highlightedLogin, + metric, + onHighlight, + showBots, + viewerLogin, +}: Readonly<{ + contributors: ContributorStats[]; + highlightedLogin: string | null; + metric: ContributorSortKey; + onHighlight: (login: string | null) => void; + showBots: boolean; + viewerLogin?: string; +}>) { + const { rows } = summarizeContributors(contributors, { + showBots, + sortBy: metric, + }); + const shown = rows.slice(0, ROWS_PER_PANEL); + const highlightedIndex = highlightedLogin + ? rows.findIndex((row) => row.login === highlightedLogin) + : -1; + const footer = describeFooter( + highlightedIndex >= ROWS_PER_PANEL ? highlightedIndex : -1, + rows.length - shown.length + ); + + return ( +
+

+ {PANEL_TITLES[metric]} +

+ toBarListItem(row, metric, viewerLogin))} + renderRow={(item, cells) => { + const isHighlighted = highlightedLogin === item.key; + // Hover follows the mouse only; on touch a tap toggles instead, so nothing sticks. + const handlePointerEnter = (event: PointerEvent): void => { + if (event.pointerType === "mouse") { + onHighlight(item.key); + } + }; + + return ( + + ); + }} + /> + {/* Reserve the line so panels stay the same height with or without a footer. */} + {footer} +
+ ); +} + +/** + * Four small multiples ranking the same contributors by one measure each, so + * no single ordering is presented as "the" ranking. Highlighting a person in + * one panel highlights them in all four. + */ +export function ContributorFacets({ + contributors, + showBots, + viewerLogin, +}: Readonly<{ + contributors: ContributorStats[]; + showBots: boolean; + /** Login of the signed-in user, whose bars get their own colour. */ + viewerLogin?: string; +}>) { + const [highlightedLogin, setHighlightedLogin] = useState(null); + + return ( +
+ {CONTRIBUTOR_SORT_KEYS.map((metric) => ( + + ))} +
+ ); +} diff --git a/src/components/fame/fame-page-content.tsx b/src/components/fame/fame-page-content.tsx index e2b05ff..0f07e6c 100644 --- a/src/components/fame/fame-page-content.tsx +++ b/src/components/fame/fame-page-content.tsx @@ -1,78 +1,126 @@ -import { GitHubAccessGate } from "@/components/repositories/gate"; -import { ToolPage } from "@/components/tool-page"; -import { Divider } from "@/components/ui/divider"; -import { Strong, Text } from "@/components/ui/text"; -import type { FamePageData } from "@/server-functions"; +import { RepositoryWorkspace } from "@/components/repositories/repository-workspace"; +import { ToolWorkspace } from "@/components/tool-page"; +import type { GitHubAccount } from "@/github/types"; +import type { FamePageData, RepositoriesPage } from "@/server-functions"; import { TOOLS } from "@/tools"; -import { OrganizationsTable } from "./organizations-table"; +import { FameRepositoriesSection } from "./fame-repositories-section"; +import { FameAnalysisToolbar, FameToolbar } from "./fame-toolbar"; import { RepoAnalysisPanel } from "./repo-analysis-panel"; -import { RepositoriesTable } from "./repositories-table"; +import { RepoAnalysisSkeleton } from "./repo-analysis-skeleton"; +import { FAME_TABLE_COLUMNS } from "./repositories-table"; +import type { StatsPendingRetry } from "./use-stats-pending-retry"; export function FamePageContent({ + accounts, hasGitHubAccess, + isAnalyzing, + isLoadingRepositories, isSignedIn, - onSelectOrganization, + onClearRepository, + onLoadPage, + onPreloadAccount, + onSelectAccount, onSelectRepository, + onShowBotsChange, org, pageData, + pendingRetry, repo, + repositoriesData, + showBots, + viewerLogin, }: Readonly<{ + accounts: GitHubAccount[] | null; hasGitHubAccess: boolean; + /** A navigation to a repository is in flight; its analysis is loading. */ + isAnalyzing: boolean; + isLoadingRepositories: boolean; isSignedIn: boolean; - onSelectOrganization: (organizationHandle: string) => void; + onClearRepository: () => void; + onLoadPage: (cursor: string) => Promise; + onPreloadAccount?: (accountHandle: string) => void; + onSelectAccount: (accountHandle: string) => void; onSelectRepository: (repositoryName: string) => void; + onShowBotsChange: (showBots: boolean) => void; org?: string; pageData: FamePageData; + pendingRetry: StatsPendingRetry; repo?: string; + repositoriesData: Promise | null; + showBots: boolean; + viewerLogin?: string; }>) { - return ( - - {org ? null : ( - <> - Select the organization or user account to analyze. - - - - - - )} - {org && !repo ? ( - <> - - Select a repository from {org} to analyze. - - - - - - - ) : null} - {org && repo ? ( - + if (org && repo) { + return ( + + + {isAnalyzing ? ( + + ) : ( + )} + + ); + } + + return ( + + - - ) : null} - + } + > + {(data) => + org ? ( + + ) : null + } + + ); } diff --git a/src/components/fame/fame-pending-state.tsx b/src/components/fame/fame-pending-state.tsx deleted file mode 100644 index 7390cbf..0000000 --- a/src/components/fame/fame-pending-state.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { ToolPage } from "@/components/tool-page"; -import { Text } from "@/components/ui/text"; -import { TOOLS } from "@/tools"; - -export function FamePendingState() { - return ( - -
-
- Analyzing repository... - - This may take a moment for larger repositories. - -
- - ); -} diff --git a/src/components/fame/fame-repositories-section.tsx b/src/components/fame/fame-repositories-section.tsx new file mode 100644 index 0000000..f80d633 --- /dev/null +++ b/src/components/fame/fame-repositories-section.tsx @@ -0,0 +1,69 @@ +import { useRef } from "react"; +import { RepositoryListPagination } from "@/components/repositories/pagination"; +import { useRepositoryList } from "@/components/repositories/use-repository-list"; +import { useRepositoryPages } from "@/components/repositories/use-repository-pages"; +import { useScrollToPageTop } from "@/components/repositories/use-scroll-to-page-top"; +import type { GitHubAccount } from "@/github/types"; +import { usePageDataErrorToast } from "@/route-utils"; +import type { RepositoriesPage } from "@/server-functions"; +import { FameToolbar } from "./fame-toolbar"; +import { RepositoriesTable } from "./repositories-table"; + +/** The loaded repository list for one account: toolbar, table, and pagination. */ +export function FameRepositoriesSection({ + accounts, + data, + onLoadPage, + onPreloadAccount, + onSelectAccount, + onSelectRepository, + org, +}: Readonly<{ + accounts: GitHubAccount[] | null; + /** The first page of repositories; later pages are fetched with `onLoadPage`. */ + data: RepositoriesPage; + onLoadPage: (cursor: string) => Promise; + onPreloadAccount?: (accountHandle: string) => void; + onSelectAccount: (accountHandle: string) => void; + onSelectRepository: (repositoryName: string) => void; + org: string; +}>) { + const pages = useRepositoryPages(data, onLoadPage); + const list = useRepositoryList(pages.repositories, { + expectedCount: pages.totalCount, + }); + const listTopRef = useRef(null); + + usePageDataErrorToast(pages.error); + useScrollToPageTop(listTopRef, list.currentPage); + + return ( + <> + {/* Paging scrolls back to the toolbar so each page starts at its top. */} + +
+ + +
+ + ); +} diff --git a/src/components/fame/fame-toolbar.tsx b/src/components/fame/fame-toolbar.tsx new file mode 100644 index 0000000..4a3819b --- /dev/null +++ b/src/components/fame/fame-toolbar.tsx @@ -0,0 +1,109 @@ +import { ArrowLeft } from "lucide-react"; +import { AccountListbox } from "@/components/repositories/account-listbox"; +import { RepositoryListToolbar } from "@/components/repositories/list-toolbar"; +import type { RepositorySort } from "@/components/repositories/list-types"; +import { Button } from "@/components/ui/button"; +import type { GitHubAccount } from "@/github/types"; + +function FameAccountListbox({ + accounts, + onPreloadAccount, + onSelectAccount, + org, +}: Readonly<{ + accounts: GitHubAccount[] | null; + onPreloadAccount?: (accountHandle: string) => void; + onSelectAccount: (accountHandle: string) => void; + org?: string; +}>) { + return ( + + ); +} + +/** The account picker with the repository list's search and sort controls. */ +export function FameToolbar({ + accounts, + className, + disabled, + onChangeSearch, + onChangeSort, + onPreloadAccount, + onSelectAccount, + org, + ref, + search, + sort, +}: Readonly<{ + accounts: GitHubAccount[] | null; + className?: string; + disabled: boolean; + onChangeSearch?: (value: string) => void; + onChangeSort?: (value: RepositorySort) => void; + onPreloadAccount?: (accountHandle: string) => void; + onSelectAccount: (accountHandle: string) => void; + org?: string; + ref?: React.Ref; + search?: string; + sort?: RepositorySort; +}>) { + return ( + + + + ); +} + +/** The account picker shown above an analysis, with a way back to the list. */ +export function FameAnalysisToolbar({ + accounts, + onClearRepository, + onPreloadAccount, + onSelectAccount, + org, +}: Readonly<{ + accounts: GitHubAccount[] | null; + onClearRepository: () => void; + onPreloadAccount?: (accountHandle: string) => void; + onSelectAccount: (accountHandle: string) => void; + org: string; +}>) { + return ( +
+
+ +
+ +
+ ); +} diff --git a/src/components/fame/organizations-table.tsx b/src/components/fame/organizations-table.tsx deleted file mode 100644 index d43fac1..0000000 --- a/src/components/fame/organizations-table.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { EmptyTableRow } from "@/components/repositories/empty-table-row"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { Text } from "@/components/ui/text"; -import type { GitHubAccount } from "@/github/types"; - -export function OrganizationsTable({ - accounts, - onSelect, -}: Readonly<{ - accounts: GitHubAccount[] | null; - onSelect: (organizationHandle: string) => void; -}>) { - return ( - - - - ID - Avatar - Name - - - - {accounts && accounts.length > 0 ? ( - accounts.map((account) => ( - onSelect(account.handle)} - > - {account.id} - - {account.handle} - - - {account.handle} - - - )) - ) : ( - No GitHub accounts found. - )} - -
- ); -} diff --git a/src/components/fame/overview-metric.tsx b/src/components/fame/overview-metric.tsx deleted file mode 100644 index adfd03e..0000000 --- a/src/components/fame/overview-metric.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import clsx from "clsx"; -import { Text } from "@/components/ui/text"; - -type MetricTone = "default" | "negative" | "positive"; - -const TONE_STYLES: Record = - { - default: { prefix: "" }, - negative: { className: "text-red-600 dark:text-red-400", prefix: "-" }, - positive: { - className: "text-emerald-600 dark:text-emerald-400", - prefix: "+", - }, - }; - -export function OverviewMetric({ - className, - isLast = false, - label, - tone = "default", - value, -}: Readonly<{ - className?: string; - isLast?: boolean; - label: string; - tone?: MetricTone; - value: number; -}>) { - const toneStyle = TONE_STYLES[tone]; - - return ( -
- - {label} - - - {toneStyle.prefix} - {value.toLocaleString()} - -
- ); -} diff --git a/src/components/fame/repo-analysis-panel.tsx b/src/components/fame/repo-analysis-panel.tsx index 23c121a..7723685 100644 --- a/src/components/fame/repo-analysis-panel.tsx +++ b/src/components/fame/repo-analysis-panel.tsx @@ -1,245 +1,159 @@ -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Text } from "@/components/ui/text"; +import { Button } from "@/components/ui/button"; +import { Divider } from "@/components/ui/divider"; +import { Strong, Text } from "@/components/ui/text"; +import { summarizeContributors } from "@/github/contributor-summary"; import type { RepoStats } from "@/github/types"; -import { OverviewMetric } from "./overview-metric"; +import { ContributorFacets } from "./contributor-facets"; +import { RepoAnalysisSkeleton } from "./repo-analysis-skeleton"; +import { RepoOverviewTiles } from "./repo-overview-tiles"; +import { ShowBotsField } from "./show-bots-field"; +import type { StatsPendingRetry } from "./use-stats-pending-retry"; + +function PendingState({ + org, + pendingRetry, + repo, +}: Readonly<{ org: string; pendingRetry: StatsPendingRetry; repo: string }>) { + if (pendingRetry.attemptsExhausted) { + return ( +
+ + GitHub is still calculating contributor statistics + + + {org}/{repo} is taking longer than usual. Try again in a few minutes. + + +
+ ); + } + + return ( + + ); +} + +function ErrorState({ error }: Readonly<{ error: string }>) { + return ( +
+
+ +
+ + Analysis Failed + + + {error} + +
+ ); +} + +function NoDataState() { + return ( +
+
+ +
+ No Data Available + + Could not retrieve repository statistics. + +
+ ); +} export function RepoAnalysisPanel({ error, + onShowBotsChange, org, + pendingRetry, repo, + showBots, stats, statsPending, + viewerLogin, }: Readonly<{ error: string | null; + onShowBotsChange: (showBots: boolean) => void; org: string; + pendingRetry: StatsPendingRetry; repo: string; + showBots: boolean; stats: RepoStats | null; statsPending: boolean; + viewerLogin?: string; }>) { if (statsPending) { - return ( -
-
- - GitHub is calculating contributor statistics - - - {org}/{repo} is not ready yet. This page will retry automatically. - -
- ); + return ; } if (error) { - return ( -
-
- -
- - Analysis Failed - - - {error} - -
- ); + return ; } if (!stats) { - return ( -
-
- -
- No Data Available - - Could not retrieve repository statistics. - -
- ); + return ; } - const totalChanges = stats.totalAdditions + stats.totalDeletions; + const summary = summarizeContributors(stats.contributors, { + showBots, + sortBy: "commits", + }); return ( -
- - - Repository Overview - - Summary statistics for {org}/{repo} - - - -
- - - - - - -
- {totalChanges > 0 ? ( -
- - Code Changes Distribution - -
-
-
-
-
-
-
- Additions ( - {((stats.totalAdditions / totalChanges) * 100).toFixed(1)}%) -
-
-
- Deletions ( - {((stats.totalDeletions / totalChanges) * 100).toFixed(1)}%) -
-
-
- ) : null} - - - - - Contributors - Contribution breakdown by developer - - - {stats.contributors.map((contributor, index) => ( -
-
-
- - {contributor.name} - - - {contributor.email} - -
-
- - - {contributor.commits.toLocaleString()} commits - -
- {contributor.percentage.toFixed(1)}% -
-
-
-
-
-
-
-
- - +{contributor.additions.toLocaleString()} - -
-
-
- - -{contributor.deletions.toLocaleString()} - -
-
- - {contributor.activeWeeks.toLocaleString()} active weeks - -
-
-
-
-
-
- ))} - - -
+ <> +
+ + Statistics cover the {stats.defaultBranch} branch and + at most GitHub's top 100 contributors by commit count. + {stats.totalFilesTruncated + ? " The file count is partial because GitHub truncated the file tree." + : null} + + +
+ +
+ + {summary.rows.length === 0 ? ( + No contributors to show. + ) : ( + + )} +
+ ); } diff --git a/src/components/fame/repo-analysis-skeleton.tsx b/src/components/fame/repo-analysis-skeleton.tsx new file mode 100644 index 0000000..bc6691b --- /dev/null +++ b/src/components/fame/repo-analysis-skeleton.tsx @@ -0,0 +1,109 @@ +import clsx from "clsx"; +import { Divider } from "@/components/ui/divider"; +import { Strong, Text } from "@/components/ui/text"; + +const BAR_CLASS_NAME = "animate-pulse rounded bg-zinc-950/10 dark:bg-white/10"; +const TILE_KEYS = ["commits", "contributors", "net-lines", "files"]; +const PANEL_KEYS = ["commits", "additions", "deletions", "active-weeks"]; +const ROW_KEYS = ["1", "2", "3", "4", "5", "6", "7", "8"]; +// Widths fall off like a real ranking so the panels read as charts, not boxes. +const ROW_WIDTHS = ["100%", "62%", "41%", "30%", "24%", "18%", "14%", "11%"]; + +function Bar({ + className, + style, +}: Readonly<{ className: string; style?: React.CSSProperties }>) { + return ( + + ); +} + +function TilePlaceholder() { + return ( +
+ + + +
+ ); +} + +function PanelPlaceholder() { + return ( +
+ +
    + {ROW_KEYS.map((key, index) => ( +
  • + + + + + +
  • + ))} +
+ +
+ ); +} + +/** + * The analysis layout with placeholder bars, shown while statistics load. + * The status line sits where the coverage note will be, so the page keeps + * its shape when the numbers land. + */ +export function RepoAnalysisSkeleton({ + org, + repo, + status, +}: Readonly<{ + org: string; + repo: string; + /** What the wait is for; the repository name is filled in. */ + status: "analyzing" | "github-calculating"; +}>) { + return ( + + + {status === "analyzing" ? ( + <> + Analyzing{" "} + + {org}/{repo} + + … + + ) : ( + <> + GitHub is still calculating statistics for{" "} + + {org}/{repo} + + . This page retries automatically. + + )} + + +
+
+ {TILE_KEYS.map((key) => ( + + ))} +
+ +
+ {PANEL_KEYS.map((key) => ( + + ))} +
+
+
+ ); +} diff --git a/src/components/fame/repo-overview-tiles.tsx b/src/components/fame/repo-overview-tiles.tsx new file mode 100644 index 0000000..ac6dd42 --- /dev/null +++ b/src/components/fame/repo-overview-tiles.tsx @@ -0,0 +1,63 @@ +import { Text } from "@/components/ui/text"; +import { formatCompactNumber } from "@/format"; +import type { ContributorSummary } from "@/github/contributor-summary"; +import type { RepoStats } from "@/github/types"; +import { describeHiddenBots } from "./show-bots-field"; + +function Tile({ + hint, + label, + value, +}: Readonly<{ hint: string; label: string; value: string }>) { + return ( +
+ {label} +

+ {value} +

+ {hint} +
+ ); +} + +const describeCommits = (unattributedCommits: number): string => + unattributedCommits > 0 + ? `+${unattributedCommits.toLocaleString()} unattributed, not counted` + : "Across shown contributors"; + +const describeContributors = (hiddenBotCount: number): string => + hiddenBotCount > 0 ? describeHiddenBots(hiddenBotCount) : "Shown below"; + +/** The headline numbers as a row of tiles; hints carry the caveats. */ +export function RepoOverviewTiles({ + stats, + summary, +}: Readonly<{ stats: RepoStats; summary: ContributorSummary }>) { + const netLines = summary.totalAdditions - summary.totalDeletions; + const files = `${stats.totalFiles.toLocaleString()}${stats.totalFilesTruncated ? "+" : ""}`; + + return ( +
+ + + + +
+ ); +} diff --git a/src/components/fame/repositories-table.tsx b/src/components/fame/repositories-table.tsx index 99a15c0..676ea31 100644 --- a/src/components/fame/repositories-table.tsx +++ b/src/components/fame/repositories-table.tsx @@ -1,58 +1,140 @@ +import { type KeyboardEvent, memo, useState } from "react"; import { RepositoryActionsMenu } from "@/components/repositories/actions-menu"; import { EmptyTableRow } from "@/components/repositories/empty-table-row"; +import { formatRepositoryPushedAt } from "@/components/repositories/list-utils"; +import { stopEventPropagation } from "@/components/repositories/selectable-row"; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { Text } from "@/components/ui/text"; + ARCHIVED_BADGE, + FORK_BADGE, + StateBadge, + VISIBILITY_BADGES, +} from "@/components/repositories/state-badges"; +import { + RepositoryTableHead, + type TableColumn, +} from "@/components/repositories/table-head"; +import { SkeletonRows } from "@/components/repositories/table-skeleton"; +import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; +import { Strong, Text } from "@/components/ui/text"; import type { GitHubRepository } from "@/github/types"; +export const FAME_TABLE_COLUMNS: TableColumn[] = [ + { label: "Name" }, + { className: "w-28", label: "Visibility" }, + { className: "w-36", label: "Type" }, + { className: "w-32", label: "Last pushed" }, +]; + +/** A fixed-layout repository table whose rows open the analysis for that repository. */ export function RepositoriesTable({ + filteredRepositories, onSelect, - repositories, + placeholderRowCount = 0, }: Readonly<{ + filteredRepositories: GitHubRepository[]; onSelect: (repositoryName: string) => void; - repositories: GitHubRepository[] | null; + /** Rows to render as skeletons for repositories that are still loading. */ + placeholderRowCount?: number; }>) { + const isEmpty = + filteredRepositories.length === 0 && placeholderRowCount === 0; + // The actions column follows the data columns. + const columnCount = FAME_TABLE_COLUMNS.length + 1; + return ( - - - - ID - Name - - Actions - - - +
+ - {repositories && repositories.length > 0 ? ( - repositories.map((repository) => ( - onSelect(repository.name)} - > - {repository.id} - - {repository.name} - - event.stopPropagation()}> - - - - )) - ) : ( - - )} + {filteredRepositories.map((repository) => ( + + ))} + + {isEmpty ? : null}
); } + +/** Memoised so paging or filtering doesn't re-render rows that stayed put. */ +const FameRepositoryRow = memo(function FameRepositoryRowComponent({ + onSelect, + repository, +}: Readonly<{ + onSelect: (repositoryName: string) => void; + repository: GitHubRepository; +}>) { + // The actions menu mounts only once the row is hovered, pressed, or focused. + const [isInteractive, setIsInteractive] = useState(false); + const activate = (): void => setIsInteractive(true); + + const handleRowKeyDown = ( + event: KeyboardEvent + ): void => { + const isRowTarget = event.target === event.currentTarget; + const isSelectKey = event.key === "Enter" || event.key === " "; + + if (!(isRowTarget && isSelectKey)) { + return; + } + + event.preventDefault(); + onSelect(repository.name); + }; + + return ( + onSelect(repository.name)} + onFocusCapture={activate} + onKeyDown={handleRowKeyDown} + onPointerDownCapture={activate} + onPointerEnter={activate} + tabIndex={0} + > + + {repository.name} + + + + + + + + + {formatRepositoryPushedAt(repository.pushedAt)} + + + + + + ); +}); + +function RepositoryTypeCell({ + repository, +}: Readonly<{ + repository: GitHubRepository; +}>) { + if (!(repository.fork || repository.archived)) { + return Source; + } + + return ( +
+ {repository.fork ? : null} + {repository.archived ? : null} +
+ ); +} diff --git a/src/components/fame/show-bots-field.tsx b/src/components/fame/show-bots-field.tsx new file mode 100644 index 0000000..0d7eb61 --- /dev/null +++ b/src/components/fame/show-bots-field.tsx @@ -0,0 +1,29 @@ +import { Checkbox } from "@headlessui/react"; +import { CheckIcon } from "@heroicons/react/16/solid"; +import { Field, Label } from "@/components/ui/fieldset"; +import { formatCount } from "@/format"; + +/** "1 bot account hidden" / "2 bot accounts hidden". */ +export const describeHiddenBots = (hiddenBotCount: number): string => + `${formatCount(hiddenBotCount, "bot account", "bot accounts")} hidden`; + +export function ShowBotsField({ + onChange, + showBots, +}: Readonly<{ + onChange: (showBots: boolean) => void; + showBots: boolean; +}>) { + return ( + + + + + + + ); +} diff --git a/src/components/fame/use-stats-pending-retry.test.ts b/src/components/fame/use-stats-pending-retry.test.ts new file mode 100644 index 0000000..406fe5c --- /dev/null +++ b/src/components/fame/use-stats-pending-retry.test.ts @@ -0,0 +1,114 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + STATS_PENDING_REFRESH_MS, + useStatsPendingRetry, +} from "./use-stats-pending-retry"; + +vi.mock("sonner", () => ({ toast: { error: vi.fn() } })); + +describe("useStatsPendingRetry", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("polls every 5 seconds until the attempt ceiling", async () => { + const refresh = vi.fn(() => Promise.resolve()); + const { result } = renderHook(() => + useStatsPendingRetry({ enabled: true, refresh, targetKey: "o/r" }) + ); + + for (let i = 0; i < 36; i += 1) { + // biome-ignore lint/performance/noAwaitInLoops: each iteration must advance timers sequentially so the hook's effect reschedules its next setTimeout before the following advance. + await act(async () => { + await vi.advanceTimersByTimeAsync(STATS_PENDING_REFRESH_MS); + }); + } + + expect(refresh).toHaveBeenCalledTimes(36); + expect(result.current.attemptsExhausted).toBe(true); + + await act(async () => { + await vi.advanceTimersByTimeAsync(STATS_PENDING_REFRESH_MS * 3); + }); + + expect(refresh).toHaveBeenCalledTimes(36); + }); + + it("retryNow resets the ceiling and refreshes immediately", async () => { + const refresh = vi.fn(() => Promise.resolve()); + const { result } = renderHook(() => + useStatsPendingRetry({ enabled: true, refresh, targetKey: "o/r" }) + ); + + for (let i = 0; i < 36; i += 1) { + // biome-ignore lint/performance/noAwaitInLoops: each iteration must advance timers sequentially so the hook's effect reschedules its next setTimeout before the following advance. + await act(async () => { + await vi.advanceTimersByTimeAsync(STATS_PENDING_REFRESH_MS); + }); + } + + expect(result.current.attemptsExhausted).toBe(true); + + act(() => { + result.current.retryNow(); + }); + + expect(refresh).toHaveBeenCalledTimes(37); + expect(result.current.attemptsExhausted).toBe(false); + + await act(async () => { + await vi.advanceTimersByTimeAsync(STATS_PENDING_REFRESH_MS); + }); + + expect(refresh).toHaveBeenCalledTimes(38); + }); + + it("does not poll when disabled", async () => { + const refresh = vi.fn(() => Promise.resolve()); + const { result } = renderHook(() => + useStatsPendingRetry({ enabled: false, refresh, targetKey: "o/r" }) + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(STATS_PENDING_REFRESH_MS * 5); + }); + + expect(refresh).toHaveBeenCalledTimes(0); + expect(result.current.attemptsExhausted).toBe(false); + }); + + it("resets the counter when the target changes", async () => { + const refresh = vi.fn(() => Promise.resolve()); + const { result, rerender } = renderHook( + ({ enabled, refresh: refreshFn, targetKey }) => + useStatsPendingRetry({ enabled, refresh: refreshFn, targetKey }), + { + initialProps: { enabled: true, refresh, targetKey: "o/a" }, + } + ); + + for (let i = 0; i < 36; i += 1) { + // biome-ignore lint/performance/noAwaitInLoops: each iteration must advance timers sequentially so the hook's effect reschedules its next setTimeout before the following advance. + await act(async () => { + await vi.advanceTimersByTimeAsync(STATS_PENDING_REFRESH_MS); + }); + } + + expect(result.current.attemptsExhausted).toBe(true); + + rerender({ enabled: true, refresh, targetKey: "o/b" }); + + expect(result.current.attemptsExhausted).toBe(false); + + await act(async () => { + await vi.advanceTimersByTimeAsync(STATS_PENDING_REFRESH_MS); + }); + + expect(refresh).toHaveBeenCalledTimes(37); + }); +}); diff --git a/src/components/fame/use-stats-pending-retry.ts b/src/components/fame/use-stats-pending-retry.ts new file mode 100644 index 0000000..0cbd62c --- /dev/null +++ b/src/components/fame/use-stats-pending-retry.ts @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; + +/** GitHub usually finishes computing statistics well within three minutes. */ +export const STATS_PENDING_MAX_ATTEMPTS = 36; +export const STATS_PENDING_REFRESH_MS = 5000; + +export interface StatsPendingRetry { + /** True once the automatic retries are used up and polling has stopped. */ + attemptsExhausted: boolean; + /** Restarts polling from zero and refreshes immediately. */ + retryNow: () => void; +} + +const reportRefreshError = (error: unknown): void => { + toast.error( + error instanceof Error + ? error.message + : "Failed to refresh repository statistics." + ); +}; + +/** + * Polls `refresh` while `enabled`, at most `STATS_PENDING_MAX_ATTEMPTS` times. + * The attempt counter resets whenever `targetKey` changes (a new repository). + */ +export const useStatsPendingRetry = ({ + enabled, + refresh, + targetKey, +}: { + enabled: boolean; + refresh: () => Promise; + targetKey: string; +}): StatsPendingRetry => { + const [attempts, setAttempts] = useState(0); + const [trackedKey, setTrackedKey] = useState(targetKey); + + if (trackedKey !== targetKey) { + setTrackedKey(targetKey); + setAttempts(0); + } + + const attemptsExhausted = attempts >= STATS_PENDING_MAX_ATTEMPTS; + + useEffect(() => { + if (!enabled || attempts >= STATS_PENDING_MAX_ATTEMPTS) { + return; + } + + const timeoutId = window.setTimeout(() => { + setAttempts((count) => count + 1); + refresh().catch(reportRefreshError); + }, STATS_PENDING_REFRESH_MS); + + return () => window.clearTimeout(timeoutId); + }, [attempts, enabled, refresh]); + + const retryNow = useCallback(() => { + setAttempts(0); + refresh().catch(reportRefreshError); + }, [refresh]); + + return { attemptsExhausted: enabled && attemptsExhausted, retryNow }; +}; diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx index c2dba80..44f7438 100644 --- a/src/components/navigation.tsx +++ b/src/components/navigation.tsx @@ -181,15 +181,17 @@ export function Navigation({ - {navItems.map(({ label, to }) => ( - - {label} - - ))} + {navItems + .filter((item) => item.enabled ?? true) + .map(({ label, to }) => ( + + {label} + + ))} diff --git a/src/components/repositories/list-toolbar.tsx b/src/components/repositories/list-toolbar.tsx index 691ab0a..db4c6fb 100644 --- a/src/components/repositories/list-toolbar.tsx +++ b/src/components/repositories/list-toolbar.tsx @@ -30,13 +30,13 @@ export function RepositoryListToolbar({ return (
{children}
-
+
) { return ( -
+
- +
Repository diff --git a/src/components/repositories/selectable-row.tsx b/src/components/repositories/selectable-row.tsx index f2ab41f..0537fe7 100644 --- a/src/components/repositories/selectable-row.tsx +++ b/src/components/repositories/selectable-row.tsx @@ -57,7 +57,7 @@ function SelectionCheckbox({ +
) { return ( diff --git a/src/components/repositories/table-skeleton.tsx b/src/components/repositories/table-skeleton.tsx index 57ac1dd..20fa1d5 100644 --- a/src/components/repositories/table-skeleton.tsx +++ b/src/components/repositories/table-skeleton.tsx @@ -20,10 +20,13 @@ export function SkeletonRows({ columns, count, keyPrefix = "placeholder", + selectable = true, }: Readonly<{ columns: TableColumn[]; count: number; keyPrefix?: string; + /** Render the leading select-column cell. */ + selectable?: boolean; }>) { const rowKeys = Array.from( { length: count }, @@ -32,11 +35,13 @@ export function SkeletonRows({ return rowKeys.map((rowKey) => ( - - - - - + {selectable ? ( + + + + + + ) : null} {columns.map((column) => ( @@ -56,24 +61,30 @@ export function SkeletonRows({ /** Placeholder rows shown while a repository list loads, matching the real table's columns. */ export function RepositoriesTableSkeleton({ columns, + selectable = true, }: Readonly<{ columns: TableColumn[]; + /** Render the leading select column. */ + selectable?: boolean; }>) { return ( Loading repositories… -
+
- - Select - - + {selectable ? ( + + Select + + + ) : null}
diff --git a/src/components/transfer/transfer-toolbar.tsx b/src/components/transfer/transfer-toolbar.tsx index 811f3ae..3b323e4 100644 --- a/src/components/transfer/transfer-toolbar.tsx +++ b/src/components/transfer/transfer-toolbar.tsx @@ -49,7 +49,7 @@ export function TransferToolbar({
{children} diff --git a/src/config.ts b/src/config.ts index d06cb83..d144ad8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,6 +14,6 @@ export const CONFIG: { enabled: true, }, commitFame: { - enabled: false, + enabled: true, }, }; diff --git a/src/format.ts b/src/format.ts index accc281..50930e2 100644 --- a/src/format.ts +++ b/src/format.ts @@ -12,3 +12,15 @@ export const formatCount = ( export const formatRepositoryCount = (count: number): string => formatCount(count, "repository", "repositories"); + +const COMPACT_THRESHOLD = 10_000; +const compactFormatter = new Intl.NumberFormat("en", { + maximumFractionDigits: 1, + notation: "compact", +}); + +/** 9,876 stays exact; 12,345 becomes 12.3K so columns of large numbers stay narrow. */ +export const formatCompactNumber = (value: number): string => + Math.abs(value) >= COMPACT_THRESHOLD + ? compactFormatter.format(value) + : value.toLocaleString(); diff --git a/src/github/analysis.ts b/src/github/analysis.ts index fad8025..7085c0a 100644 --- a/src/github/analysis.ts +++ b/src/github/analysis.ts @@ -10,6 +10,7 @@ import { type Sleep, sleep, } from "./client"; +import { isBotLogin } from "./contributor-summary"; import { type AnalyzeGitHubRepositoryOptions, type ContributorStats, @@ -29,6 +30,7 @@ interface GitHubTreeResponse { tree: Array<{ type?: string; }>; + truncated?: boolean; } interface GitHubContributorWeek { @@ -152,25 +154,44 @@ const summarizeWeeks = ( return { activeWeeks, additions, deletions }; }; +const loadContributorProfile = async ( + context: GitHubRequestContext, + login: string +): Promise> => { + const fallback = { + email: `${login}@users.noreply.github.com`, + name: login, + }; + + try { + const profile = await fetchGitHubJson( + context, + `/users/${login}`, + `Failed to load the GitHub profile for ${login}.` + ); + + return { + email: profile.email ?? fallback.email, + name: profile.name ?? fallback.name, + }; + } catch { + // A missing or rate-limited profile must not sink the whole analysis. + return fallback; + } +}; + const loadContributorRow = async ( context: GitHubRequestContext, - contributor: ContributorWithAuthor, - totalCommits: number + contributor: ContributorWithAuthor ): Promise => { const { login } = contributor.author; - const profile = await fetchGitHubJson( - context, - `/users/${login}`, - `Failed to load the GitHub profile for ${login}.` - ); - const commits = contributor.total || 0; return { ...summarizeWeeks(contributor.weeks ?? []), - commits, - email: profile.email ?? `${login}@users.noreply.github.com`, - name: profile.name ?? login, - percentage: totalCommits > 0 ? (commits / totalCommits) * 100 : 0, + ...(await loadContributorProfile(context, login)), + commits: contributor.total || 0, + isBot: isBotLogin(login), + login, }; }; @@ -181,11 +202,17 @@ const hasAuthor = ( const sumBy = (items: Item[], getValue: (item: Item) => number): number => items.reduce((total, item) => total + getValue(item), 0); -const countFiles = async ( +interface RepositoryTreeSummary { + defaultBranch: string; + totalFiles: number; + totalFilesTruncated: boolean; +} + +const loadRepositoryTree = async ( context: GitHubRequestContext, owner: string, repositoryName: string -): Promise => { +): Promise => { const pathname = repositoryPathname(owner, repositoryName); const repository = await fetchGitHubJson( context, @@ -198,7 +225,11 @@ const countFiles = async ( `Failed to load the file tree for ${owner}/${repositoryName}.` ); - return tree.tree.filter((item) => item.type === "blob").length; + return { + defaultBranch: repository.default_branch, + totalFiles: tree.tree.filter((item) => item.type === "blob").length, + totalFilesTruncated: tree.truncated === true, + }; }; export async function analyzeGitHubRepository( @@ -224,29 +255,26 @@ export async function analyzeGitHubRepository( sleep: options.sleep ?? sleep, } ); - const totalCommits = sumBy( - contributors, + const unattributedCommits = sumBy( + contributors.filter((contributor) => !hasAuthor(contributor)), (contributor) => contributor.total || 0 ); - const [contributorRows, totalFiles] = await Promise.all([ + const [contributorRows, tree] = await Promise.all([ mapWithConcurrency( contributors.filter(hasAuthor), MAX_CONTRIBUTOR_PROFILE_CONCURRENCY, - (contributor) => loadContributorRow(context, contributor, totalCommits) + (contributor) => loadContributorRow(context, contributor) ), - countFiles(context, owner, repositoryName), + loadRepositoryTree(context, owner, repositoryName), ]); - const totalAdditions = sumBy(contributorRows, (row) => row.additions); - const totalDeletions = sumBy(contributorRows, (row) => row.deletions); contributorRows.sort((left, right) => right.commits - left.commits); return { contributors: contributorRows, - totalAdditions, - totalCommits, - totalDeletions, - totalFiles, - totalLines: Math.max(0, totalAdditions - totalDeletions), + defaultBranch: tree.defaultBranch, + totalFiles: tree.totalFiles, + totalFilesTruncated: tree.totalFilesTruncated, + unattributedCommits, }; } diff --git a/src/github/contributor-summary.ts b/src/github/contributor-summary.ts new file mode 100644 index 0000000..f098140 --- /dev/null +++ b/src/github/contributor-summary.ts @@ -0,0 +1,76 @@ +import type { ContributorStats } from "./types"; + +export const CONTRIBUTOR_SORT_KEYS = [ + "commits", + "additions", + "deletions", + "activeWeeks", +] as const; + +export type ContributorSortKey = (typeof CONTRIBUTOR_SORT_KEYS)[number]; + +export const isContributorSortKey = ( + value: unknown +): value is ContributorSortKey => + CONTRIBUTOR_SORT_KEYS.includes(value as ContributorSortKey); + +/** GitHub apps and bots authenticate with a login that ends in `[bot]`. */ +export const isBotLogin = (login: string): boolean => login.endsWith("[bot]"); + +export interface ContributorRow extends ContributorStats { + /** Share of the visible contributors' commits, 0–100. */ + percentage: number; +} + +export interface ContributorSummary { + /** Bots omitted from `rows` and the totals; 0 when bots are shown. */ + hiddenBotCount: number; + rows: ContributorRow[]; + totalAdditions: number; + totalCommits: number; + totalDeletions: number; +} + +const PERCENT = 100; + +const compareRows = ( + sortBy: ContributorSortKey, + left: ContributorStats, + right: ContributorStats +): number => + right[sortBy] - left[sortBy] || + right.commits - left.commits || + left.login.localeCompare(right.login); + +/** + * Filters, totals, and sorts contributors for display. Percentages are taken + * against the visible rows' commits so they always sum to 100 (±rounding). + */ +export const summarizeContributors = ( + contributors: ContributorStats[], + options: { showBots: boolean; sortBy: ContributorSortKey } +): ContributorSummary => { + const visible = options.showBots + ? contributors + : contributors.filter((contributor) => !contributor.isBot); + const hiddenBotCount = contributors.length - visible.length; + let totalAdditions = 0; + let totalCommits = 0; + let totalDeletions = 0; + + for (const contributor of visible) { + totalAdditions += contributor.additions; + totalCommits += contributor.commits; + totalDeletions += contributor.deletions; + } + + const rows = visible + .map((contributor) => ({ + ...contributor, + percentage: + totalCommits > 0 ? (contributor.commits / totalCommits) * PERCENT : 0, + })) + .sort((left, right) => compareRows(options.sortBy, left, right)); + + return { hiddenBotCount, rows, totalAdditions, totalCommits, totalDeletions }; +}; diff --git a/src/github/github.test.ts b/src/github/github.test.ts index 75faf7b..9a69332 100644 --- a/src/github/github.test.ts +++ b/src/github/github.test.ts @@ -6,9 +6,13 @@ import { listGitHubRepositoriesPage, } from "./accounts"; import { analyzeGitHubRepository } from "./analysis"; +import { isBotLogin, summarizeContributors } from "./contributor-summary"; import { manageGitHubRepositories } from "./manage"; import { transferGitHubRepositories } from "./transfer"; -import { isGitHubContributorStatsPendingError } from "./types"; +import { + type ContributorStats, + isGitHubContributorStatsPendingError, +} from "./types"; const MANAGED_REPOSITORY_PATH_PATTERN = /\/repos\/owner\/([^/]+)$/; @@ -443,15 +447,15 @@ describe("analyzeGitHubRepository", () => { commits: 3, deletions: 4, email: "alice@users.noreply.github.com", + isBot: false, + login: "alice", name: "Alice", - percentage: 100, }, ], - totalAdditions: 11, - totalCommits: 3, - totalDeletions: 4, + defaultBranch: "main", totalFiles: 2, - totalLines: 7, + totalFilesTruncated: false, + unattributedCommits: 0, }); }); @@ -615,6 +619,290 @@ describe("analyzeGitHubRepository", () => { expect(maxInFlight).toBeLessThanOrEqual(5); expect(stats.contributors).toHaveLength(12); }); + + it("flags bots and counts unattributed commits", async () => { + const fetchImplementation = createFetchImplementation((url) => { + if (url.endsWith("/repos/source/repo")) { + return new Response(JSON.stringify(createRepositoryInfoResponse()), { + status: 200, + statusText: "OK", + }); + } + + if (url.endsWith("/git/trees/main?recursive=1")) { + return new Response(JSON.stringify(createTreeResponse()), { + status: 200, + statusText: "OK", + }); + } + + if (url.endsWith("/stats/contributors")) { + return new Response( + JSON.stringify([ + { author: { login: "alice" }, total: 3, weeks: [] }, + { author: { login: "dependabot[bot]" }, total: 2, weeks: [] }, + { author: null, total: 4, weeks: [] }, + ]), + { status: 200, statusText: "OK" } + ); + } + + if (url.includes("/users/")) { + const [, login] = url.split("/users/"); + + return new Response( + JSON.stringify({ email: null, login, name: null }), + { status: 200, statusText: "OK" } + ); + } + + return new Response("not found", { + status: 404, + statusText: "Not Found", + }); + }); + + const stats = await analyzeGitHubRepository("token", "source", "repo", { + fetchImplementation, + }); + + expect(stats.contributors).toHaveLength(2); + expect( + stats.contributors.find((contributor) => contributor.login === "alice") + ).toMatchObject({ isBot: false }); + expect( + stats.contributors.find( + (contributor) => contributor.login === "dependabot[bot]" + ) + ).toMatchObject({ isBot: true }); + expect(stats.unattributedCommits).toBe(4); + }); + + it("flags a truncated file tree", async () => { + const fetchImplementation = createFetchImplementation((url) => { + if (url.endsWith("/repos/source/repo")) { + return new Response(JSON.stringify(createRepositoryInfoResponse()), { + status: 200, + statusText: "OK", + }); + } + + if (url.endsWith("/git/trees/main?recursive=1")) { + return new Response( + JSON.stringify({ tree: [{ type: "blob" }], truncated: true }), + { status: 200, statusText: "OK" } + ); + } + + if (url.endsWith("/stats/contributors")) { + return new Response(JSON.stringify([]), { + status: 200, + statusText: "OK", + }); + } + + return new Response("not found", { + status: 404, + statusText: "Not Found", + }); + }); + + const stats = await analyzeGitHubRepository("token", "source", "repo", { + fetchImplementation, + }); + + expect(stats.totalFiles).toBe(1); + expect(stats.totalFilesTruncated).toBe(true); + }); + + it("keeps a contributor row when the profile lookup fails", async () => { + const fetchImplementation = createFetchImplementation((url) => { + if (url.endsWith("/repos/source/repo")) { + return new Response(JSON.stringify(createRepositoryInfoResponse()), { + status: 200, + statusText: "OK", + }); + } + + if (url.endsWith("/git/trees/main?recursive=1")) { + return new Response(JSON.stringify(createTreeResponse()), { + status: 200, + statusText: "OK", + }); + } + + if (url.endsWith("/stats/contributors")) { + return new Response(JSON.stringify(createContributorStatsResponse()), { + status: 200, + statusText: "OK", + }); + } + + if (url.endsWith("/users/alice")) { + return new Response("nope", { + status: 500, + statusText: "Server Error", + }); + } + + return new Response("not found", { + status: 404, + statusText: "Not Found", + }); + }); + + const stats = await analyzeGitHubRepository("token", "source", "repo", { + fetchImplementation, + }); + + expect(stats.contributors).toEqual([ + expect.objectContaining({ + email: "alice@users.noreply.github.com", + name: "alice", + }), + ]); + }); +}); + +describe("summarizeContributors", () => { + const createContributor = ( + overrides: Partial = {} + ): ContributorStats => ({ + activeWeeks: 1, + additions: 0, + commits: 1, + deletions: 0, + email: "x@users.noreply.github.com", + isBot: false, + login: "x", + name: "x", + ...overrides, + }); + + it("classifies logins ending in [bot] as bots", () => { + expect(isBotLogin("dependabot[bot]")).toBe(true); + expect(isBotLogin("alice")).toBe(false); + expect(isBotLogin("bot")).toBe(false); + }); + + it("hides bots and excludes them from totals by default", () => { + const alice = createContributor({ + additions: 60, + commits: 6, + deletions: 6, + login: "alice", + }); + const bot = createContributor({ + additions: 40, + commits: 4, + deletions: 4, + isBot: true, + login: "renovate[bot]", + }); + + const summary = summarizeContributors([alice, bot], { + showBots: false, + sortBy: "commits", + }); + + expect(summary.rows).toHaveLength(1); + expect(summary.hiddenBotCount).toBe(1); + expect(summary.totalCommits).toBe(6); + expect(summary.totalAdditions).toBe(60); + expect(summary.totalDeletions).toBe(6); + expect(summary.rows[0]?.percentage).toBe(100); + }); + + it("includes bots when shown", () => { + const alice = createContributor({ + additions: 60, + commits: 6, + deletions: 6, + login: "alice", + }); + const bot = createContributor({ + additions: 40, + commits: 4, + deletions: 4, + isBot: true, + login: "renovate[bot]", + }); + + const summary = summarizeContributors([alice, bot], { + showBots: true, + sortBy: "commits", + }); + + expect(summary.rows).toHaveLength(2); + expect(summary.hiddenBotCount).toBe(0); + expect(summary.totalCommits).toBe(10); + expect(summary.rows.find((row) => row.login === "alice")?.percentage).toBe( + 60 + ); + expect( + summary.rows.find((row) => row.login === "renovate[bot]")?.percentage + ).toBe(40); + }); + + it("percentages sum to 100 across visible rows", () => { + const contributors = [ + createContributor({ commits: 1, login: "alice" }), + createContributor({ commits: 1, login: "bob" }), + createContributor({ commits: 1, login: "carol" }), + ]; + + const summary = summarizeContributors(contributors, { + showBots: true, + sortBy: "commits", + }); + + const totalPercentage = summary.rows.reduce( + (total, row) => total + row.percentage, + 0 + ); + + expect(totalPercentage).toBeCloseTo(100, 5); + }); + + it("sorts by the requested key with commits as a tie-breaker", () => { + const alice = createContributor({ + additions: 10, + commits: 5, + login: "alice", + }); + const bob = createContributor({ + additions: 50, + commits: 2, + login: "bob", + }); + const carol = createContributor({ + additions: 50, + commits: 2, + login: "carol", + }); + + const byAdditions = summarizeContributors([alice, bob, carol], { + showBots: true, + sortBy: "additions", + }); + + expect(byAdditions.rows.map((row) => row.login)).toEqual([ + "bob", + "carol", + "alice", + ]); + + const byCommits = summarizeContributors([alice, bob, carol], { + showBots: true, + sortBy: "commits", + }); + + expect(byCommits.rows.map((row) => row.login)).toEqual([ + "alice", + "bob", + "carol", + ]); + }); }); describe("transferGitHubRepositories", () => { diff --git a/src/github/types.ts b/src/github/types.ts index b3a964c..1df5d45 100644 --- a/src/github/types.ts +++ b/src/github/types.ts @@ -129,22 +129,29 @@ export interface ManageGitHubRepositoriesOptions { } export interface ContributorStats { + /** Number of weeks with at least one commit. */ activeWeeks: number; additions: number; commits: number; deletions: number; email: string; + /** True when the GitHub login ends in `[bot]`. */ + isBot: boolean; + login: string; name: string; - percentage: number; } export interface RepoStats { + /** Every contributor GitHub returned (at most its top 100), bots included, sorted by commits descending. */ contributors: ContributorStats[]; - totalAdditions: number; - totalCommits: number; - totalDeletions: number; + /** The branch the statistics cover. */ + defaultBranch: string; + /** Blobs in the default-branch tree; partial when `totalFilesTruncated` is true. */ totalFiles: number; - totalLines: number; + /** True when GitHub truncated the recursive tree response. */ + totalFilesTruncated: boolean; + /** Commits whose author GitHub could not resolve to an account. */ + unattributedCommits: number; } export interface AnalyzeGitHubRepositoryOptions { diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 5be8a1a..e8468d0 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -11,7 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' import { Route as GithubRouteImport } from './routes/_github' -import { Route as FameRouteImport } from './routes/fame' +import { Route as GithubFameRouteImport } from './routes/_github/fame' import { Route as GithubManageRouteImport } from './routes/_github/manage' import { Route as GithubTransferRouteImport } from './routes/_github/transfer' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' @@ -25,10 +25,10 @@ const GithubRoute = GithubRouteImport.update({ id: '/_github', getParentRoute: () => rootRouteImport, } as any) -const FameRoute = FameRouteImport.update({ +const GithubFameRoute = GithubFameRouteImport.update({ id: '/fame', path: '/fame', - getParentRoute: () => rootRouteImport, + getParentRoute: () => GithubRoute, } as any) const GithubManageRoute = GithubManageRouteImport.update({ id: '/manage', @@ -48,14 +48,14 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute - '/fame': typeof FameRoute + '/fame': typeof GithubFameRoute '/manage': typeof GithubManageRoute '/transfer': typeof GithubTransferRoute '/api/auth/$': typeof ApiAuthSplatRoute } export interface FileRoutesByTo { '/': typeof IndexRoute - '/fame': typeof FameRoute + '/fame': typeof GithubFameRoute '/manage': typeof GithubManageRoute '/transfer': typeof GithubTransferRoute '/api/auth/$': typeof ApiAuthSplatRoute @@ -64,7 +64,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/_github': typeof GithubRouteWithChildren - '/fame': typeof FameRoute + '/_github/fame': typeof GithubFameRoute '/_github/manage': typeof GithubManageRoute '/_github/transfer': typeof GithubTransferRoute '/api/auth/$': typeof ApiAuthSplatRoute @@ -78,7 +78,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/_github' - | '/fame' + | '/_github/fame' | '/_github/manage' | '/_github/transfer' | '/api/auth/$' @@ -87,7 +87,6 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute GithubRoute: typeof GithubRouteWithChildren - FameRoute: typeof FameRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute } @@ -107,12 +106,12 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof GithubRouteImport parentRoute: typeof rootRouteImport } - '/fame': { - id: '/fame' + '/_github/fame': { + id: '/_github/fame' path: '/fame' fullPath: '/fame' - preLoaderRoute: typeof FameRouteImport - parentRoute: typeof rootRouteImport + preLoaderRoute: typeof GithubFameRouteImport + parentRoute: typeof GithubRoute } '/_github/manage': { id: '/_github/manage' @@ -139,11 +138,13 @@ declare module '@tanstack/react-router' { } interface GithubRouteChildren { + GithubFameRoute: typeof GithubFameRoute GithubManageRoute: typeof GithubManageRoute GithubTransferRoute: typeof GithubTransferRoute } const GithubRouteChildren: GithubRouteChildren = { + GithubFameRoute: GithubFameRoute, GithubManageRoute: GithubManageRoute, GithubTransferRoute: GithubTransferRoute, } @@ -154,7 +155,6 @@ const GithubRouteWithChildren = const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, GithubRoute: GithubRouteWithChildren, - FameRoute: FameRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, } export const routeTree = rootRouteImport diff --git a/src/routes/_github/fame.tsx b/src/routes/_github/fame.tsx new file mode 100644 index 0000000..d435daf --- /dev/null +++ b/src/routes/_github/fame.tsx @@ -0,0 +1,193 @@ +import { + createFileRoute, + getRouteApi, + redirect, + useRouter, +} from "@tanstack/react-router"; +import { useServerFn } from "@tanstack/react-start"; +import { useCallback } from "react"; +import { useAppSession } from "@/app-session"; +import { FamePageContent } from "@/components/fame/fame-page-content"; +import { useStatsPendingRetry } from "@/components/fame/use-stats-pending-retry"; +import { + normalizeSearchValue, + pickKnownAccount, + usePageDataErrorToast, + usePendingSearch, +} from "@/route-utils"; +import { + type FamePageData, + getFamePageData, + getRepositoriesPage, +} from "@/server-functions"; +import { createToolRouteOptions, TOOLS } from "@/tools"; + +interface FameSearch { + bots?: true; + org?: string; + repo?: string; +} + +type FameLoaderDeps = Pick; + +type GitHubLayoutData = ReturnType; + +const PRELOAD_STALE_TIME_MS = 30_000; + +const EMPTY_PAGE_DATA: FamePageData = { + error: null, + stats: null, + statsPending: false, +}; + +const validateFameSearch = (search: Record): FameSearch => ({ + bots: search.bots === true || search.bots === "true" ? true : undefined, + org: normalizeSearchValue(search.org), + repo: normalizeSearchValue(search.repo), +}); + +const githubRoute = getRouteApi("/_github"); + +export const Route = createFileRoute("/_github/fame")({ + ...createToolRouteOptions(TOOLS.fame), + component: FameRoute, + // The account list comes from the parent route. The repository list is + // returned as a promise so the page renders (with a skeleton) while it + // loads; an analysis is awaited so the page lands with its numbers. + loader: async ({ + deps, + parentMatchPromise, + }: { + deps: FameLoaderDeps; + parentMatchPromise: Promise<{ loaderData?: GitHubLayoutData }>; + }) => { + const { loaderData } = await parentMatchPromise; + const viewerLogin = loaderData?.viewerLogin; + const org = pickKnownAccount(deps.org, loaderData?.accounts ?? null); + + // An account that isn't in the picker (typo, lost access) is cleared + // from the URL rather than shown as an empty list. + if (deps.org && !org) { + throw redirect({ replace: true, search: {}, to: "/fame" }); + } + + if (org && deps.repo) { + return { + pageData: await getFamePageData({ data: { org, repo: deps.repo } }), + repositoriesData: null, + }; + } + + return { + pageData: EMPTY_PAGE_DATA, + // Only the first page is deferred here; the section pulls the rest. + repositoriesData: org + ? getRepositoriesPage({ data: { account: org, viewerLogin } }) + : null, + }; + }, + // Only the repository selection needs a fresh load; display options do not. + loaderDeps: ({ search }): FameLoaderDeps => ({ + org: search.org, + repo: search.repo, + }), + // Keep a hover-preloaded list fresh long enough for the click to use it. + preloadStaleTime: PRELOAD_STALE_TIME_MS, + staleTime: PRELOAD_STALE_TIME_MS, + validateSearch: validateFameSearch, +}); + +function FameRoute() { + const appSession = useAppSession(); + const { + accounts, + error: accountsError, + viewerLogin, + } = githubRoute.useLoaderData(); + const { pageData, repositoriesData } = Route.useLoaderData(); + const router = useRouter(); + const search = Route.useSearch(); + const navigate = Route.useNavigate(); + const { isNavigating, pendingSearch } = usePendingSearch( + "/fame", + validateFameSearch + ); + // Show the picker's destination right away; only a different account loads + // a new list, and only a different repository loads a new analysis. + const org = isNavigating ? pendingSearch.org : search.org; + const repo = isNavigating ? pendingSearch.repo : search.repo; + const isLoadingRepositories = + isNavigating && pendingSearch.org !== search.org; + const isAnalyzing = + isNavigating && + Boolean(pendingSearch.repo) && + (pendingSearch.repo !== search.repo || pendingSearch.org !== search.org); + const loadRepositoriesPage = useServerFn(getRepositoriesPage); + + usePageDataErrorToast(accountsError); + usePageDataErrorToast(pageData.error); + + const refresh = useCallback(() => router.invalidate(), [router]); + const pendingRetry = useStatsPendingRetry({ + enabled: Boolean(pageData.statsPending && search.org && search.repo), + refresh, + targetKey: `${search.org ?? ""}/${search.repo ?? ""}`, + }); + + return ( + + navigate({ + search: (previous) => ({ ...previous, repo: undefined }), + }) + } + onLoadPage={(cursor) => + loadRepositoriesPage({ + data: { account: search.org ?? "", cursor, viewerLogin }, + }) + } + onPreloadAccount={(accountHandle) => { + router + .preloadRoute({ search: { org: accountHandle }, to: "/fame" }) + .catch(() => { + // Preloading is best-effort; the click still loads normally. + }); + }} + onSelectAccount={(accountHandle) => + navigate({ + search: (previous) => ({ + ...previous, + org: accountHandle, + repo: undefined, + }), + }) + } + onSelectRepository={(repositoryName) => + navigate({ + search: (previous) => ({ ...previous, repo: repositoryName }), + }) + } + onShowBotsChange={(showBots) => + navigate({ + replace: true, + search: (previous) => ({ + ...previous, + bots: showBots ? true : undefined, + }), + }) + } + org={org} + pageData={pageData} + pendingRetry={pendingRetry} + repo={repo} + repositoriesData={repositoriesData} + showBots={search.bots === true} + viewerLogin={viewerLogin} + /> + ); +} diff --git a/src/routes/fame.tsx b/src/routes/fame.tsx deleted file mode 100644 index cc20ab9..0000000 --- a/src/routes/fame.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { createFileRoute, useRouter } from "@tanstack/react-router"; -import { useEffect } from "react"; -import { toast } from "sonner"; -import { useAppSession } from "@/app-session"; -import { FamePageContent } from "@/components/fame/fame-page-content"; -import { FamePendingState } from "@/components/fame/fame-pending-state"; -import { normalizeSearchValue, usePageDataErrorToast } from "@/route-utils"; -import { getFamePageData } from "@/server-functions"; -import { createToolRouteOptions, TOOLS } from "@/tools"; - -interface FameSearch { - org?: string; - repo?: string; -} - -const FAME_STATS_PENDING_REFRESH_MS = 5000; - -const validateFameSearch = (search: Record): FameSearch => ({ - org: normalizeSearchValue(search.org), - repo: normalizeSearchValue(search.repo), -}); - -export const Route = createFileRoute("/fame")({ - ...createToolRouteOptions(TOOLS.fame), - component: FameRoute, - loader: ({ deps }) => getFamePageData({ data: deps }), - loaderDeps: ({ search }) => search, - pendingComponent: FamePendingState, - pendingMs: 0, - validateSearch: validateFameSearch, -}); - -function FameRoute() { - const appSession = useAppSession(); - const pageData = Route.useLoaderData(); - const router = useRouter(); - const search = Route.useSearch(); - const navigate = Route.useNavigate(); - - usePageDataErrorToast(pageData.error); - - useEffect(() => { - if (!(pageData.statsPending && search.org && search.repo)) { - return; - } - - const timeoutId = window.setTimeout(() => { - router.invalidate().catch((error: unknown) => { - toast.error( - error instanceof Error - ? error.message - : "Failed to refresh repository statistics." - ); - }); - }, FAME_STATS_PENDING_REFRESH_MS); - - return () => window.clearTimeout(timeoutId); - }, [pageData.statsPending, router, search.org, search.repo]); - - return ( - - navigate({ search: { org: organizationHandle, repo: undefined } }) - } - onSelectRepository={(repositoryName) => - navigate({ search: { org: search.org, repo: repositoryName } }) - } - org={search.org} - pageData={pageData} - repo={search.repo} - /> - ); -} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index d3bc373..a7ca101 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -17,12 +17,12 @@ function HomePage() {
{TOOL_LIST.filter((tool) => tool.enabled).map((tool) => (
{tool.title} - {tool.description} -
diff --git a/src/server-functions.server.ts b/src/server-functions.server.ts index 2a9304e..63a8b7e 100644 --- a/src/server-functions.server.ts +++ b/src/server-functions.server.ts @@ -4,7 +4,6 @@ import { getGitHubOrganizationSupportsInternal, isViewerAccount, listGitHubAccounts, - listGitHubRepositories, listGitHubRepositoriesPage, } from "./github/accounts"; import { analyzeGitHubRepository } from "./github/analysis"; @@ -158,8 +157,6 @@ export function resolveRepositoriesPage( const EMPTY_FAME_PAGE_DATA: FamePageData = { error: null, - organizations: null, - repositories: null, stats: null, statsPending: false, }; @@ -168,18 +165,8 @@ const loadFamePageData = async ( accessToken: string, search: FameSearchInput ): Promise => { - if (!search.org) { - return { - ...EMPTY_FAME_PAGE_DATA, - organizations: await listGitHubAccounts(accessToken), - }; - } - - if (!search.repo) { - return { - ...EMPTY_FAME_PAGE_DATA, - repositories: await listGitHubRepositories(accessToken, search.org), - }; + if (!(search.org && search.repo)) { + return EMPTY_FAME_PAGE_DATA; } return { diff --git a/src/server-functions.ts b/src/server-functions.ts index 4b1f92d..2998121 100644 --- a/src/server-functions.ts +++ b/src/server-functions.ts @@ -77,8 +77,6 @@ export interface FameSearchInput { export interface FamePageData { error: string | null; - organizations: GitHubAccount[] | null; - repositories: GitHubRepository[] | null; stats: RepoStats | null; statsPending: boolean; } diff --git a/src/tools.ts b/src/tools.ts index 9a3bc85..a990d37 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -14,7 +14,7 @@ export const TOOLS = { fame: { color: "amber", description: - "See how your commits compare to your colleagues and who's doing more.", + "See who contributes to a repository by commits, lines changed, and active weeks.", enabled: CONFIG.commitFame.enabled, href: "/fame", title: "Commit Fame",