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_EXPECTATIONSinserver/publicPortfolio.ts,portfolioMetadata.totalCompaniesinclient/src/lib/portfolioMetadata.ts,portfolioCompanyCountinscripts/validate-public-seo-build.mjs, thesummaryblock inportfolio-data.json) are written only byscripts/airtable-manus-apply.mjsand are checked against the source by the test below. - Add a new reducer or
Mapthat tallies statuses. IfbyStatusdoes not cover the case, extendshared/portfolioStatus.tsand 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
summaryblock client/src/lib/portfolioMetadata.tsandscripts/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.txtfacts (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) fromstatusOptions()inpublicThemePrimitives.tsx, which wrapssummarizePortfolioStatus
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
- Import from
shared/portfolioStatus.ts; delete any local status filter. - If the number is on a client theme view (
client/src/components/views/*,client/src/pages/Home.tsx), compute it withsummarizePortfolioStatusover the samecompaniesarray the view renders, so "N companies shown" and "N active" come from one call. - Add or update the assertion in
server/portfolio-status-single-source.test.ts. - Run
npx tsx scripts/gate.ts(deploy-gate skill). - 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).