From 8920b9ea08abc6c514b1744e181f7695e4ad81b2 Mon Sep 17 00:00:00 2001 From: peetzweg Date: Wed, 8 Jul 2026 21:33:23 +0200 Subject: [PATCH 1/3] Record oversized orgs on lookup instead of discarding them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lower the on-demand org build threshold to 25 members and record any org that exceeds it as an entity row (builtAt null) at first sighting, rather than refusing before writing anything. This keeps the shared GitHub token well clear of its hourly budget on the request path while capturing which companies people looked up, so the background worker can backfill them later. Repeat lookups of a recorded oversized org short-circuit on the stored memberCount, so they never re-enumerate members before refusing. No schema change — reuses the existing entities table. --- src/lib/org-cache.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/lib/org-cache.ts b/src/lib/org-cache.ts index 6dd00f0..fae2f09 100644 --- a/src/lib/org-cache.ts +++ b/src/lib/org-cache.ts @@ -43,9 +43,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 +142,13 @@ 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); + assertBuildable(profile); } const complete = row.builtAt != null; @@ -183,6 +186,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) { From 59d2b635cae4125d4aa33dbaa1036068a135c338 Mon Sep 17 00:00:00 2001 From: peetzweg Date: Wed, 8 Jul 2026 21:53:24 +0200 Subject: [PATCH 2/3] Rebrand "companies" to "Organizations"; show orgs in recent lookups; unhide board toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes that together make the organization leaderboard a shippable, go-live feature: - Rebrand: user-facing copy, code identifiers (Company* → Org*, matching the codebase's existing `org` convention), and URLs (/company/* → /organizations/*) all say "Organizations" now — GitHub's own term. Old /company/ URLs 301 to their new home so no link or search result breaks. The GitHub user-profile `company` field is left untouched (it's a different, GitHub-named thing). - Recent lookups now include organizations: the org build path records the lookup, queryRecent surfaces user+org entries, and the chip renders a square avatar plus the verified badge for orgs. - The Developers/Organizations board toggle is shown to everyone (drops the SHOW_BOARD_TOGGLE dark-launch flag; supersedes PR #101). --- src/components/MetricBar.tsx | 2 +- src/components/OrgView.tsx | 6 +- .../{company => organizations}/members.mdx | 18 +-- .../{company => organizations}/stats.mdx | 35 ++-- src/lib/cache.ts | 2 +- src/lib/commit-history.ts | 19 ++- src/lib/content.ts | 32 ++-- src/lib/org-cache.ts | 12 +- src/lib/org.ts | 22 +-- src/routeTree.gen.ts | 21 +++ src/routes/$user.tsx | 2 +- src/routes/company.$slug.tsx | 149 ++---------------- src/routes/index.tsx | 83 ++++++---- src/routes/organizations.$slug.tsx | 145 +++++++++++++++++ vite.config.ts | 8 +- 15 files changed, 315 insertions(+), 241 deletions(-) rename src/content/{company => organizations}/members.mdx (73%) rename src/content/{company => organizations}/stats.mdx (59%) create mode 100644 src/routes/organizations.$slug.tsx 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..c700c24 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). @@ -247,7 +247,7 @@ function LoadedOrg({ repositories, as attributed by GitHub. Private members and private contributions aren’t included.{" "} @@ -285,7 +285,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 fae2f09..87b1a4c 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 @@ -148,7 +150,13 @@ async function getFromDb( // discarding the lookup. The request path still won't build it live. const profile = await fetchOrgProfile(login, token); 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; diff --git a/src/lib/org.ts b/src/lib/org.ts index da09fdb..604bf86 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). */ @@ -41,7 +41,7 @@ export interface OrgResult { } /** - * 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. @@ -145,7 +145,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) }; } @@ -169,7 +169,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 +184,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 +221,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]) => (