diff --git a/src/components/MetricBar.tsx b/src/components/MetricBar.tsx index c3fbae3..887ceca 100644 --- a/src/components/MetricBar.tsx +++ b/src/components/MetricBar.tsx @@ -41,7 +41,7 @@ export function MetricBar() { let modes: LeaderMode[] | null = null; if (routeId === "/") { - // The company board (?kind=org) ranks by commits only — no metric to pick yet. + // The organization board (?kind=org) ranks by commits only — no metric to pick yet. modes = boardKind === "org" ? null : LEADER_MODES; } else if (routeId === "/$user") { if (lookup?.kind === "org") { diff --git a/src/components/OrgView.tsx b/src/components/OrgView.tsx index 48c02d0..271e9ca 100644 --- a/src/components/OrgView.tsx +++ b/src/components/OrgView.tsx @@ -6,7 +6,7 @@ import type { OrgMemberEntry, OrgResult } from "#/lib/org"; import type { OrgSummary } from "#/lib/org-cache"; /** - * Org ("company") views rendered by the /$user route when a login resolves to an organization + * Organization views rendered by the /$user route when a login resolves to an organization * (GitHub logins share one namespace, so /paritytech IS the org page). Header + lifetime totals * of the members' contributions *to this org*, plus the building/error states. No chart yet: * orgs have no monthly data until the background worker lands (issue #84 follow-up). @@ -153,6 +153,27 @@ export function OrgResultView({ ); } + // Valid but too large to build on demand — recorded and queued for the background worker. + // A gentle notice, not a failure: no Retry (retrying can't help), just "check back later". + if (result.indexing) { + return ( +
+

+ We’re still indexing {result.login} +

+

{result.indexing}

+
+ + ← Try another lookup + +
+
+ ); + } + // Hard failure, or polling gave up on a stalled build. const message = result.error ? result.error @@ -247,7 +268,7 @@ function LoadedOrg({ repositories, as attributed by GitHub. Private members and private contributions aren’t included.{" "} @@ -285,7 +306,7 @@ function MemberBoard({ Public members ranked by their lifetime commits to {org.login}’s repositories.{" "} diff --git a/src/content/company/members.mdx b/src/content/organizations/members.mdx similarity index 73% rename from src/content/company/members.mdx rename to src/content/organizations/members.mdx index af9978d..bbb39c8 100644 --- a/src/content/company/members.mdx +++ b/src/content/organizations/members.mdx @@ -6,7 +6,7 @@ updatedAt: "2026-07-08" order: 2 --- -The member leaderboard on a company page lists the organization's **public +The member leaderboard on an organization page lists the organization's **public members** — and that's almost always the answer to "why am I not on it": **GitHub makes organization membership private by default.** Unless you've explicitly published yours, nobody outside the organization (including us) can @@ -21,17 +21,17 @@ see that you're a member at all. GitHub only lets *you* do this for yourself — an org admin can't publish your membership for you. Once it's public, you'll appear the next time the -company's data is refreshed, and your contributions start counting toward the -[company totals](/company/stats) too. +organization's data is refreshed, and your contributions start counting toward +the [organization totals](/organizations/stats) too. ## Other reasons someone is missing - **They're not a member.** Contributing to an org's repositories — even a lot — doesn't make someone a member. Outside contributors don't appear on the member list and don't count toward the totals. -- **The list hasn't refreshed yet.** Membership is captured when the company - is first looked up and re-synced periodically, so a freshly-publicized - membership can take a while to show up. +- **The list hasn't refreshed yet.** Membership is captured when the + organization is first looked up and re-synced periodically, so a + freshly-publicized membership can take a while to show up. - **The account is suspended on commit-history.** Profiles under review are hidden from every leaderboard, member lists included. @@ -39,6 +39,6 @@ company's data is refreshed, and your contributions start counting toward the Being on the list with a **0** is normal and not a bug. The numbers are contributions **to this organization's public repositories** only — see -[how company stats are calculated](/company/stats). A member shows zero when -their work in the org happens in private repositories, under an email not -linked to their GitHub account, or simply in other places entirely. +[how organization stats are calculated](/organizations/stats). A member shows +zero when their work in the org happens in private repositories, under an email +not linked to their GitHub account, or simply in other places entirely. diff --git a/src/content/company/stats.mdx b/src/content/organizations/stats.mdx similarity index 59% rename from src/content/company/stats.mdx rename to src/content/organizations/stats.mdx index 7dd4292..cce5f0f 100644 --- a/src/content/company/stats.mdx +++ b/src/content/organizations/stats.mdx @@ -1,15 +1,15 @@ --- -title: "How company stats are calculated" -description: "Where a company's commits, pull requests, reviews and issues come from: each public member's contributions to that organization's repositories, summed — and what that number deliberately leaves out." +title: "How organization stats are calculated" +description: "Where an organization's commits, pull requests, reviews and issues come from: each public member's contributions to that organization's repositories, summed — and what that number deliberately leaves out." publishedAt: "2026-07-08" updatedAt: "2026-07-08" order: 1 --- -A company page answers a narrower question than it might look like: **how much -have this organization's public members publicly contributed to the +An organization page answers a narrower question than it might look like: **how +much have this organization's public members publicly contributed to the organization's own repositories?** Every number on the page — the headline -stats, the member leaderboard, the company's position on the company +stats, the member leaderboard, the organization's position on the organization leaderboard — is built from that one measure. ## The calculation @@ -24,8 +24,8 @@ Within that scope, GitHub's usual contribution rules apply, the same ones your personal profile uses: commits count on the default branch, under an email linked to the account, in non-fork repositories. -The company totals are simply those per-member numbers **summed**. The member -leaderboard shows the same rows the sum is made of, so the two always +The organization totals are simply those per-member numbers **summed**. The +member leaderboard shows the same rows the sum is made of, so the two always reconcile. ## What the number is *not* @@ -35,32 +35,33 @@ things are excluded, and they can make the totals smaller than you'd expect: - **Private members.** GitHub keeps organization membership private by default, and we can only see members who made theirs public. An active - company whose people all keep membership private shows a total of zero — - see [why am I not on this member list?](/company/members). + organization whose people all keep membership private shows a total of zero — + see [why am I not on this member list?](/organizations/members). - **Outside contributors.** Someone who sends pull requests to the org's repos without being a member isn't counted. The measure is about the - company's people, not its repos' traffic. + organization's people, not its repos' traffic. - **Private repositories.** We only see public activity. Work in the org's private repos is invisible to us, even for public members. -So treat the company numbers as the org's **public footprint**: what its +So treat the organization numbers as the org's **public footprint**: what its publicly-listed people have visibly built there. ## How this differs from a personal profile Your personal page counts your public contributions **across all of GitHub**. -A company page counts only what landed **in that org's repositories**. That's -why your number on a member leaderboard is always less than or equal to the -number on your own profile — often much less, if most of your work happens +An organization page counts only what landed **in that org's repositories**. +That's why your number on a member leaderboard is always less than or equal to +the number on your own profile — often much less, if most of your work happens elsewhere. ## Freshness and quirks - Totals cover every **completed month** up to now; the current in-progress month rolls in once it ends (the same convention as personal profiles). -- A company's numbers are collected when it's first looked up and refreshed - periodically after that, so very recent activity can take a while to appear. -- Company pages don't have a commit chart yet — we currently store lifetime +- An organization's numbers are collected when it's first looked up and + refreshed periodically after that, so very recent activity can take a while + to appear. +- Organization pages don't have a commit chart yet — we currently store lifetime totals per member, and the per-month history that powers the chart is collected in a slower background pass. - In rare cases GitHub's API cannot serve a specific slice of a member's diff --git a/src/lib/cache.ts b/src/lib/cache.ts index 07901d4..eb2bae4 100644 --- a/src/lib/cache.ts +++ b/src/lib/cache.ts @@ -435,7 +435,7 @@ async function persistEntity( } } -async function recordLookup(database: DB, id: string, now: Date) { +export async function recordLookup(database: DB, id: string, now: Date) { try { await database.insert(lookups).values({ entityId: id, searchedAt: now }); } catch { diff --git a/src/lib/commit-history.ts b/src/lib/commit-history.ts index 3d5c125..2dda4b4 100644 --- a/src/lib/commit-history.ts +++ b/src/lib/commit-history.ts @@ -115,6 +115,10 @@ export interface RecentEntry { login: string; name: string | null; avatarUrl: string | null; + /** Drives the chip's avatar shape (org = square, user = circle) and the verified badge. */ + kind: "user" | "org"; + /** Org-only verified badge; null for users. */ + isVerified: boolean | null; } export interface StartPageData { recent: RecentEntry[]; @@ -220,13 +224,20 @@ async function queryRecent(limit: number): Promise { login: entities.login, name: entities.name, avatarUrl: entities.avatarUrl, + kind: entities.kind, + isVerified: entities.isVerified, last: sql`max(${lookups.searchedAt})`, }) .from(lookups) .innerJoin(entities, eq(entities.id, lookups.entityId)) - // kind filter is defensive — org lookups aren't recorded (v1), but the strip links every - // entry to /$user, which for an org login would kick off a bogus user build. - .where(and(isNull(entities.suspendedAt), eq(entities.kind, "user"))) + // Users and orgs both belong in the strip (each links to /$user, which resolves either); + // repos are the only other kind and don't get a page here, so they're excluded. + .where( + and( + isNull(entities.suspendedAt), + inArray(entities.kind, ["user", "org"]), + ), + ) .groupBy(entities.id) .orderBy(desc(sql`max(${lookups.searchedAt})`)) .limit(limit); @@ -234,6 +245,8 @@ async function queryRecent(limit: number): Promise { login: r.login, name: r.name, avatarUrl: r.avatarUrl, + kind: r.kind === "org" ? "org" : "user", + isVerified: r.isVerified, })); } diff --git a/src/lib/content.ts b/src/lib/content.ts index b6430f3..c455d5d 100644 --- a/src/lib/content.ts +++ b/src/lib/content.ts @@ -1,9 +1,9 @@ /** * The MDX content collections: * - src/content/metrics/.mdx → /metrics/ (individual metrics, /metrics/explained hub) - * - src/content/company/.mdx → /company/ (company/org context — deliberately its own - * collection, NOT mixed into the individuals' hub: the definitions differ, e.g. org-scoped vs - * global contributions) + * - src/content/organizations/.mdx → /organizations/ (organization context — + * deliberately its own collection, NOT mixed into the individuals' hub: the definitions differ, + * e.g. org-scoped vs global contributions) * * Two globs per collection with different costs: * - an eager, frontmatter-only glob (tiny — just the exported metadata objects), used @@ -69,21 +69,19 @@ export function loadArticle( return load?.(); } -// ── Company collection (/company/) ───────────────────────────────────── +// ── Organization collection (/organizations/) ────────────────────────── -const companyFrontmatters = import.meta.glob( - "../content/company/*.mdx", +const orgFrontmatters = import.meta.glob( + "../content/organizations/*.mdx", { eager: true, import: "frontmatter" }, ); -const companyComponents = import.meta.glob<{ default: MDXContent }>( - "../content/company/*.mdx", +const orgComponents = import.meta.glob<{ default: MDXContent }>( + "../content/organizations/*.mdx", ); -/** All company articles in curated reading order. */ -export const companyArticles: ArticleMeta[] = Object.entries( - companyFrontmatters, -) +/** All organization articles in curated reading order. */ +export const orgArticles: ArticleMeta[] = Object.entries(orgFrontmatters) .map(([path, fm]) => ({ slug: slugOf(path), ...fm })) .sort( (a, b) => @@ -91,14 +89,14 @@ export const companyArticles: ArticleMeta[] = Object.entries( (b.order ?? Number.MAX_SAFE_INTEGER) || a.title.localeCompare(b.title), ); -export function getCompanyArticleMeta(slug: string): ArticleMeta | undefined { - return companyArticles.find((a) => a.slug === slug); +export function getOrgArticleMeta(slug: string): ArticleMeta | undefined { + return orgArticles.find((a) => a.slug === slug); } -/** Lazily import a company article's compiled MDX module (shape fits React.lazy). */ -export function loadCompanyArticle( +/** Lazily import an organization article's compiled MDX module (shape fits React.lazy). */ +export function loadOrgArticle( slug: string, ): Promise<{ default: MDXContent }> | undefined { - const load = companyComponents[`../content/company/${slug}.mdx`]; + const load = orgComponents[`../content/organizations/${slug}.mdx`]; return load?.(); } diff --git a/src/lib/org-cache.ts b/src/lib/org-cache.ts index 6dd00f0..4edeed0 100644 --- a/src/lib/org-cache.ts +++ b/src/lib/org-cache.ts @@ -1,4 +1,5 @@ import { and, eq, isNull, sql } from "drizzle-orm"; +import { recordLookup } from "#/lib/cache"; import { type DB, db } from "#/lib/db"; import { entities, orgMembers } from "#/lib/db/schema"; import { @@ -12,12 +13,13 @@ import { } from "#/lib/github"; /** - * Incremental org ("company") cache — the org sibling of cache.ts. + * Incremental organization cache — the org sibling of cache.ts. * * An org's numbers are the sum of its members' contributions *to that org* (org-scoped * `contributionsCollection(organizationID: …)`, a different number from each member's global * totals). Those per-member sums live in `org_members`; the roll-up lands on the org's - * `entities` row so the company leaderboard ranks orgs exactly like the user board ranks users. + * `entities` row so the organization leaderboard ranks orgs exactly like the user board ranks + * users. * * Builds are **resumable** at member granularity: enumeration inserts every member as a pending * `org_members` row (`lastFetched` null), each fetched member is stamped the moment its totals @@ -43,9 +45,10 @@ const BUILD_BUDGET_MS = 6_000; const MEMBER_CONCURRENCY = 3; // Orgs with more visible members than this get a clean refusal instead of an on-demand build: -// at ~2 requests per member a mega-org lookup would eat the shared token's hourly budget and -// poison it for every visitor. Lifting this needs the background worker. -const MAX_ORG_MEMBERS = 400; +// at ~2 requests per member even a mid-size org lookup can eat into the shared token's hourly +// budget and poison it for every visitor. We keep this deliberately low and record the refused +// orgs (see getFromDb) so the background worker can backfill them later, off the request path. +const MAX_ORG_MEMBERS = 25; export interface OrgSummary { login: string; @@ -141,11 +144,19 @@ async function getFromDb( if (!row) { // First sighting: the profile fetch validates the login and yields the node id (keys every - // org-scoped contribution query) + createdAt (caps member windows). Refuse oversized orgs - // BEFORE writing anything — a half-enumerated mega-org would burn quota on every poll. + // org-scoped contribution query) + createdAt (caps member windows). Record the profile + // (a single cheap upsert into `entities`, builtAt still null) BEFORE refusing an oversized + // org — this leaves a tracked row the background worker can later backfill, instead of + // discarding the lookup. The request path still won't build it live. const profile = await fetchOrgProfile(login, token); - assertBuildable(profile); row = await upsertOrgProfile(database, id, profile, now); + // Record the lookup before the size check so even an oversized org we refuse to build + // still surfaces in "recently looked up" — the row exists and the strip links to /$user. + await recordLookup(database, id, now); + assertBuildable(profile); + } else { + // Known org, revisited: bump its recency so it re-sorts to the front of the strip. + await recordLookup(database, id, now); } const complete = row.builtAt != null; @@ -183,6 +194,13 @@ async function getFromDb( throw new GitHubError(`Could not resolve "${login}" on GitHub.`, 502); } + // Enrolled but oversized: the row is recorded for the worker, but the request path must refuse + // it — cheaply, from the stored memberCount, so a repeat lookup never pays to re-enumerate its + // members before bailing. (assertBuildable already caught it at first sighting.) + if ((row.memberCount ?? 0) > MAX_ORG_MEMBERS) { + throw new GitHubError(tooLargeMessage(login, row.memberCount ?? 0), 422); + } + // Enumerate members once per build: every member becomes a pending org_members row, which // doubles as the resume marker. Re-enumeration (membership drift) is the worker's job. if ((await memberRowCount(database, id)) === 0) { @@ -278,7 +296,7 @@ function assertBuildable(profile: OrgProfile) { } function tooLargeMessage(login: string, count: number) { - return `"${login}" has ${count.toLocaleString()} public members — too many for an on-demand build yet.`; + return `${login} has ${count.toLocaleString()} public members — a bit much for us to crunch on the spot. We’ve added it to the queue and are indexing it in the background; check back a little later. Thanks for your interest!`; } async function memberRowCount(database: DB, id: string): Promise { diff --git a/src/lib/org.ts b/src/lib/org.ts index da09fdb..d77a519 100644 --- a/src/lib/org.ts +++ b/src/lib/org.ts @@ -12,7 +12,7 @@ import { type BuildProgress, GitHubError } from "#/lib/github"; import { getOrgSummary, type OrgSummary, orgEntityId } from "#/lib/org-cache"; /** - * Server functions for org ("company") pages and the company leaderboard. Split from + * Server functions for organization pages and the organization leaderboard. Split from * commit-history.ts to keep that module user-only; same createServerFn conventions (and the * same "don't rename to *.server.ts" caveat documented there). */ @@ -38,10 +38,14 @@ export interface OrgResult { // Non-null while the initial server-side build is still in progress — each poll of the loader // advances it (progress counts *members*, not months). Mutually exclusive with `error`. building: BuildProgress | null; + // Non-null when the org is valid but too large for an on-demand build: it's been recorded and + // the background worker will fill it in. Carries a friendly "still indexing" message, and the + // UI shows a gentle notice rather than a failure card. Mutually exclusive with the fields above. + indexing: string | null; } /** - * A company's members ranked by their commits *to that org* — the same rows the org's totals + * An organization's members ranked by their commits *to that org* — the same rows the org's totals * are summed from, so the list always reconciles with the headline numbers. Suspended members * are hidden (consistent with every other board); not-yet-fetched members (mid-build) carry * no numbers yet and are skipped. @@ -77,7 +81,7 @@ async function resolveOrg(login: string): Promise { const org = await getOrgSummary(login, serverToken()); // Best-effort: a members-query hiccup must not drop an already-loaded org page. const members = await queryOrgMembers(orgEntityId(login)).catch(() => []); - return { login, org, members, error: null, building: null }; + return { login, org, members, error: null, building: null, indexing: null }; } catch (e) { // The 503 "still building" rejection carries progress — surface it as `building` so the // client polls to continue instead of showing a failure card (same mapping as the user @@ -86,16 +90,22 @@ async function resolveOrg(login: string): Promise { e instanceof GitHubError && e.status === 503 && e.progress ? e.progress : null; + // 422 = valid but too large to build on demand. It's been recorded for the background + // worker, so this isn't a failure — surface it as a friendly "still indexing" notice. + const indexing = + e instanceof GitHubError && e.status === 422 ? e.message : null; return { login, org: null, members: [], - error: building - ? null - : e instanceof Error - ? e.message - : "Failed to load", + error: + building || indexing + ? null + : e instanceof Error + ? e.message + : "Failed to load", building, + indexing, }; } } @@ -145,7 +155,7 @@ export const getLookup = createServerFn({ method: "GET" }) if (solo) { const kinds = await knownKinds(solo); // Both kinds existing at once means a login changed hands across a rename — prefer the - // user row (the historical default); the org stays reachable via the company board. + // user row (the historical default); the org stays reachable via the organization board. if (kinds.has("org") && !kinds.has("user")) { return { kind: "org", org: await resolveOrg(solo) }; } @@ -157,7 +167,7 @@ export const getLookup = createServerFn({ method: "GET" }) /could not resolve to a user/i.test(users[0].error) ) { const org = await resolveOrg(solo); - if (org.org || org.building) return { kind: "org", org }; + if (org.org || org.building || org.indexing) return { kind: "org", org }; // The login isn't a user. If the org path failed for any reason other than "no such // org" (token scopes, a GitHub hiccup mid-build), that error is the truthful one — // surface it instead of the misleading user 404. A login that is neither keeps the @@ -169,7 +179,7 @@ export const getLookup = createServerFn({ method: "GET" }) return { kind: "users", users }; }); -export interface CompanyLeaderEntry { +export interface OrgLeaderEntry { login: string; name: string | null; avatarUrl: string | null; @@ -184,14 +194,14 @@ export interface CompanyLeaderEntry { } /** - * Companies ranked by their members' org-scoped commits. Only fully built orgs appear — a + * Organizations ranked by their members' org-scoped commits. Only fully built orgs appear — a * half-built org's totals are still zero/partial and would rank nonsense. v1 ranks by commits * only; the per-metric machinery can follow once orgs get their own metric bar. */ -async function queryCompanyLeaderboard( +async function queryOrgLeaderboard( offset: number, limit: number, -): Promise { +): Promise { if (!db) return []; return ( db @@ -221,11 +231,11 @@ async function queryCompanyLeaderboard( ); } -/** One page of the company leaderboard — same clamp conventions as getLeaderboard. */ -export const getCompanyLeaderboard = createServerFn({ method: "GET" }) +/** One page of the organization leaderboard — same clamp conventions as getLeaderboard. */ +export const getOrgLeaderboard = createServerFn({ method: "GET" }) .validator((p: { offset: number; limit: number }) => p) - .handler(({ data }): Promise => { + .handler(({ data }): Promise => { const offset = Math.min(Math.max(0, data.offset), LEADERBOARD_MAX); const limit = Math.min(Math.max(0, data.limit), LEADERBOARD_MAX - offset); - return queryCompanyLeaderboard(offset, limit); + return queryOrgLeaderboard(offset, limit); }); diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 7c7dc7f..01f87f8 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as UserRouteImport } from './routes/$user' import { Route as IndexRouteImport } from './routes/index' +import { Route as OrganizationsSlugRouteImport } from './routes/organizations.$slug' import { Route as MetricsExplainedRouteImport } from './routes/metrics.explained' import { Route as MetricsSlugRouteImport } from './routes/metrics.$slug' import { Route as EmbedUserRouteImport } from './routes/embed.$user' @@ -26,6 +27,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const OrganizationsSlugRoute = OrganizationsSlugRouteImport.update({ + id: '/organizations/$slug', + path: '/organizations/$slug', + getParentRoute: () => rootRouteImport, +} as any) const MetricsExplainedRoute = MetricsExplainedRouteImport.update({ id: '/metrics/explained', path: '/metrics/explained', @@ -54,6 +60,7 @@ export interface FileRoutesByFullPath { '/embed/$user': typeof EmbedUserRoute '/metrics/$slug': typeof MetricsSlugRoute '/metrics/explained': typeof MetricsExplainedRoute + '/organizations/$slug': typeof OrganizationsSlugRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -62,6 +69,7 @@ export interface FileRoutesByTo { '/embed/$user': typeof EmbedUserRoute '/metrics/$slug': typeof MetricsSlugRoute '/metrics/explained': typeof MetricsExplainedRoute + '/organizations/$slug': typeof OrganizationsSlugRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -71,6 +79,7 @@ export interface FileRoutesById { '/embed/$user': typeof EmbedUserRoute '/metrics/$slug': typeof MetricsSlugRoute '/metrics/explained': typeof MetricsExplainedRoute + '/organizations/$slug': typeof OrganizationsSlugRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -81,6 +90,7 @@ export interface FileRouteTypes { | '/embed/$user' | '/metrics/$slug' | '/metrics/explained' + | '/organizations/$slug' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -89,6 +99,7 @@ export interface FileRouteTypes { | '/embed/$user' | '/metrics/$slug' | '/metrics/explained' + | '/organizations/$slug' id: | '__root__' | '/' @@ -97,6 +108,7 @@ export interface FileRouteTypes { | '/embed/$user' | '/metrics/$slug' | '/metrics/explained' + | '/organizations/$slug' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -106,6 +118,7 @@ export interface RootRouteChildren { EmbedUserRoute: typeof EmbedUserRoute MetricsSlugRoute: typeof MetricsSlugRoute MetricsExplainedRoute: typeof MetricsExplainedRoute + OrganizationsSlugRoute: typeof OrganizationsSlugRoute } declare module '@tanstack/react-router' { @@ -124,6 +137,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/organizations/$slug': { + id: '/organizations/$slug' + path: '/organizations/$slug' + fullPath: '/organizations/$slug' + preLoaderRoute: typeof OrganizationsSlugRouteImport + parentRoute: typeof rootRouteImport + } '/metrics/explained': { id: '/metrics/explained' path: '/metrics/explained' @@ -162,6 +182,7 @@ const rootRouteChildren: RootRouteChildren = { EmbedUserRoute: EmbedUserRoute, MetricsSlugRoute: MetricsSlugRoute, MetricsExplainedRoute: MetricsExplainedRoute, + OrganizationsSlugRoute: OrganizationsSlugRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/$user.tsx b/src/routes/$user.tsx index 90372d2..218e91f 100644 --- a/src/routes/$user.tsx +++ b/src/routes/$user.tsx @@ -60,7 +60,7 @@ export const Route = createFileRoute("/$user")({ const logins = parseLogins(params.user); const isOrg = loaderData?.kind === "org"; const title = isOrg - ? `${logins[0]} — company commit history` + ? `${logins[0]} — organization commit history` : logins.length > 1 ? `${logins.join(" vs ")} — commit history` : `${logins[0] ?? "GitHub user"}’s commit history`; diff --git a/src/routes/company.$slug.tsx b/src/routes/company.$slug.tsx index cd66226..117b69b 100644 --- a/src/routes/company.$slug.tsx +++ b/src/routes/company.$slug.tsx @@ -1,144 +1,17 @@ -import { createFileRoute, Link, notFound } from "@tanstack/react-router"; -import { type ComponentType, lazy, Suspense } from "react"; -import { mdxComponents } from "#/components/MdxComponents"; -import { getCompanyArticleMeta, loadCompanyArticle } from "#/lib/content"; +import { createFileRoute, redirect } from "@tanstack/react-router"; /** - * Company-context explainer articles (src/content/company/*.mdx) — the org twins of the - * /metrics/$slug pages. A separate collection on purpose: company numbers are org-scoped and - * public-member-only, so their definitions differ from the individual metric explainers. - * Same URL policy as /metrics: bare /company stays a valid GitHub login on the $user route. - * No per-article OG images (yet) — these fall back to the site-wide card. + * Permanent redirect from the old /company/ explainer URLs to their new /organizations/ + * home (the collection was renamed when "companies" became "organizations" site-wide). Kept as a + * thin route so existing links and search-engine results don't 404. Bare /company still falls + * through to the $user route as a valid GitHub login — same policy as /organizations. */ - -const SITE = "https://commit-history.com"; - -// One stable lazy component per slug — created on demand, cached at module level so -// re-renders don't recreate (and thereby remount) the article body. -type BodyComponent = ComponentType<{ components?: typeof mdxComponents }>; -const bodies = new Map(); -function articleBody(slug: string): BodyComponent { - let body = bodies.get(slug); - if (!body) { - body = lazy(() => { - const load = loadCompanyArticle(slug); - if (!load) throw notFound(); - return load; - }); - bodies.set(slug, body); - } - return body; -} - -function formatDate(iso: string) { - return new Date(iso).toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric", - }); -} - export const Route = createFileRoute("/company/$slug")({ - loader: ({ params }) => { - const meta = getCompanyArticleMeta(params.slug); - if (!meta) throw notFound(); - return meta; - }, - head: ({ params }) => { - const meta = getCompanyArticleMeta(params.slug); - if (!meta) return {}; - const url = `${SITE}/company/${meta.slug}`; - return { - meta: [ - { title: `${meta.title} · Commit History` }, - { name: "description", content: meta.description }, - { property: "og:type", content: "article" }, - { property: "og:title", content: meta.title }, - { property: "og:description", content: meta.description }, - { property: "og:url", content: url }, - { property: "article:published_time", content: meta.publishedAt }, - { property: "article:modified_time", content: meta.updatedAt }, - { name: "twitter:title", content: meta.title }, - { name: "twitter:description", content: meta.description }, - ], - links: [{ rel: "canonical", href: url }], - scripts: [ - { - type: "application/ld+json", - children: JSON.stringify([ - { - "@context": "https://schema.org", - "@type": "TechArticle", - headline: meta.title, - description: meta.description, - datePublished: meta.publishedAt, - dateModified: meta.updatedAt, - mainEntityOfPage: url, - author: { - "@type": "Person", - name: "Philip Poloczek", - url: "https://github.com/peetzweg", - }, - publisher: { - "@type": "Organization", - name: "Commit History", - url: SITE, - logo: { - "@type": "ImageObject", - url: `${SITE}/crown-180.png`, - }, - }, - }, - { - "@context": "https://schema.org", - "@type": "BreadcrumbList", - itemListElement: [ - { - "@type": "ListItem", - position: 1, - name: "Commit History", - item: SITE, - }, - { "@type": "ListItem", position: 2, name: meta.title }, - ], - }, - ]), - }, - ], - }; + beforeLoad: ({ params }) => { + throw redirect({ + to: "/organizations/$slug", + params: { slug: params.slug }, + statusCode: 301, + }); }, - component: ArticlePage, }); - -function ArticlePage() { - const meta = Route.useLoaderData(); - const Body = articleBody(meta.slug); - return ( -
- -
-
-

{meta.title}

-

{meta.description}

-

- Updated{" "} - -

-
-
- - - -
-
-
- ); -} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 53c800f..571efe3 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -13,7 +13,7 @@ import { type LeaderMode, type RecentEntry, } from "#/lib/commit-history"; -import { type CompanyLeaderEntry, getCompanyLeaderboard } from "#/lib/org"; +import { getOrgLeaderboard, type OrgLeaderEntry } from "#/lib/org"; import { cn } from "#/lib/utils"; // Leaderboard metrics that live in the URL as `?metric=…`. "public" (commits) is the default and @@ -35,22 +35,17 @@ function isLeaderMetricParam(v: unknown): v is LeaderMode { interface HomeSearch { /** Selected leaderboard metric; absent = the default (commits). */ metric?: LeaderMode; - /** Which board is shown; absent = developers, "org" = the company board. */ + /** Which board is shown; absent = developers, "org" = the organization board. */ kind?: "org"; } -// Company board page size — a single page for now (the board is young); swap for the +// Organization board page size — a single page for now (the board is young); swap for the // LEADERBOARD_PAGE_STOPS infinite-scroll pattern when it outgrows this. -const COMPANY_PAGE_SIZE = 100; - -// Feature flag: the company board ships dark — fully working but only reachable via a direct -// `/?kind=org` URL, so the board can be filled and checked in production before it's -// advertised. Flip to true to show the Developers/Companies switch to everyone. -const SHOW_BOARD_TOGGLE = false; +const ORG_PAGE_SIZE = 100; export const Route = createFileRoute("/")({ // `?metric=` selects the leaderboard type so a view can be shared; invalid/absent → commits. - // `?kind=org` flips the board to companies (same clean-URL convention: default is absent). + // `?kind=org` flips the board to organizations (same clean-URL convention: default is absent). validateSearch: (search: Record): HomeSearch => ({ ...(isLeaderMetricParam(search.metric) ? { metric: search.metric } : {}), ...(search.kind === "org" ? { kind: "org" as const } : {}), @@ -62,16 +57,16 @@ export const Route = createFileRoute("/")({ loaderDeps: ({ search }) => ({ kind: search.kind }), loader: async ({ deps }) => { if (deps.kind === "org") { - const [recent, companies] = await Promise.all([ + const [recent, orgs] = await Promise.all([ getRecentLookups(), - getCompanyLeaderboard({ - data: { offset: 0, limit: COMPANY_PAGE_SIZE }, + getOrgLeaderboard({ + data: { offset: 0, limit: ORG_PAGE_SIZE }, }), ]); - return { recent, leaderboard: [] as LeaderEntry[], companies }; + return { recent, leaderboard: [] as LeaderEntry[], orgs }; } const start = await getStartPageData(); - return { ...start, companies: [] as CompanyLeaderEntry[] }; + return { ...start, orgs: [] as OrgLeaderEntry[] }; }, component: Home, }); @@ -136,13 +131,11 @@ function Home() { {recent.length > 0 && } - {SHOW_BOARD_TOGGLE && ( -
- -
- )} +
+ +
{kind === "org" ? ( - + ) : ( initial.leaderboard.length > 0 && ( @@ -162,9 +155,10 @@ function Home() { } /** - * Centered tab bar above the leaderboard heading, switching between the developer and company - * boards via `?kind=`. Switching also drops `?metric=` — the company board ranks by commits - * only (per-metric company boards can follow), so a stale metric param would be meaningless. + * Centered tab bar above the leaderboard heading, switching between the developer and + * organization boards via `?kind=`. Switching also drops `?metric=` — the organization board + * ranks by commits only (per-metric org boards can follow), so a stale metric param would be + * meaningless. */ function BoardKindToggle() { const { kind } = Route.useSearch(); @@ -185,7 +179,7 @@ function BoardKindToggle() { {( [ ["user", "Developers"], - ["org", "Companies"], + ["org", "Organizations"], ] as const ).map(([k, label]) => (