sahin.io

All skills and agents

portfolio-counts-single-source

The one rule for every company count and status count on sahin.io/angel-investments ("234 companies", "212 active", public/acquired/failed, exited). Use before writing, changing, or displaying any such number on a page, in a theme view, a constant, a data summary, a validator, llms.txt, or the Airtable sync; whenever two numbers on the site disagree; and whenever the owner says counts, totals, or statuses do not match.

Source file
.claude/skills/portfolio-counts-single-source/SKILL.md

Portfolio counts: one source of truth

The owner's rule (2026-10-11): the company number and the status counts must come from the same source of truth, everywhere, and this must not happen again. The Status Guide once counted "Active" with its own filter while the theme views counted "active" with another; they happened to agree, but nothing guaranteed it.

The source

shared/portfolioStatus.ts is the only place that knows what a status is and how to count it. It is pure (no fs, no path), so server renderers and React code both import it.

Need Use
All counts at once summarizePortfolioStatus(companies) → { total, active, public, acquired, failed, exited, byStatus }
Is this company active? isActiveCompany(company)
Filter by one status hasExitStatus('Acquired')
Read a stored status normalizeExitStatus(raw) (unknown/missing → Live)
The allowed statuses EXIT_STATUSES = Live, Public, Acquired, Failed; labels in EXIT_STATUS_LABELS
Guard data validatePortfolioStatuses(companies) throws if any company lacks a valid status

The company list itself is client/public/portfolio-data.json (companies), loaded on the server by loadPublicPortfolio() and on the client by client/src/data/portfolioData.ts. Count over that whole list, or over an explicitly filtered view of it, and say which.

Never

  • Write company.exitStatus === 'Live' (or any status string) in a page, view, or script. Use the helpers.
  • Hand-type a count (234, 212) in copy, a constant, a test, or a doc. The pinned constants that must exist (PUBLIC_COMPANY_COUNT, PUBLIC_EXIT_EXPECTATIONS in server/publicPortfolio.ts, portfolioMetadata.totalCompanies in client/src/lib/portfolioMetadata.ts, portfolioCompanyCount in scripts/validate-public-seo-build.mjs, the summary block in portfolio-data.json) are written only by scripts/airtable-manus-apply.mjs and are checked against the source by the test below.
  • Add a new reducer or Map that tallies statuses. If byStatus does not cover the case, extend shared/portfolioStatus.ts and its test.
  • Treat "Realized" as a status in data. It is a Status Guide card with no members (() => false), kept for the page's taxonomy only.

The guard

server/portfolio-status-single-source.test.ts asserts, against the summary of the full company list, every consumer:

  • pinned constants and the JSON summary block
  • client/src/lib/portfolioMetadata.ts and scripts/validate-public-seo-build.mjs
  • the Status Guide cards (server/publicPages/status.ts)
  • entity facts and the hub intro sentence (server/entityFacts.ts, renderPublicHubIndex)
  • llms.txt facts (server/llmsText.ts)
  • the Lessons page status block (server/publicPages/lessons.ts)
  • the VC theme views (client/src/components/views/*PortfolioView.tsx): no status string comparisons, and status filter counts (the Y Combinator panel) from statusOptions() in publicThemePrimitives.tsx, which wraps summarizePortfolioStatus

When you add a page or view that shows a count, add one assertion for it to this test in the same change. The deploy gate runs it; a mismatch blocks the push.

Checklist for a change that touches counts

  1. Import from shared/portfolioStatus.ts; delete any local status filter.
  2. If the number is on a client theme view (client/src/components/views/*, client/src/pages/Home.tsx), compute it with summarizePortfolioStatus over the same companies array the view renders, so "N companies shown" and "N active" come from one call.
  3. Add or update the assertion in server/portfolio-status-single-source.test.ts.
  4. Run npx tsx scripts/gate.ts (deploy-gate skill).
  5. If the owner reports two numbers that differ, run the test first; it names the consumer that drifted. Fix the consumer, never the source.

When the data changes

scripts/airtable-manus-apply.mjs validates every company's status against the same four values, recomputes the summary block, and rewrites the pinned constants. If the sync adds a status that is not one of the four, it throws; decide with the owner whether it maps to an existing status or whether EXIT_STATUSES grows (then the Status Guide, labels, and test grow with it).