Revise State of Startups landing page (#47335)

This commit is contained in:
Prashant Sridharan authored and GitHub committed 2026-06-30 01:35:40 +01:00
1 parent 635b2d6050
commit ff1941b35f
44 files changed
+24456 -2782

No files matched your search

@@ -10,16 +10,10 @@ import { Button, cn } from 'ui'
import { ParticipantsCarousel } from './components/ParticipantsCarousel'
import { StateOfStartupsAuroraHeader } from './components/StateOfStartupsAuroraHeader'
import {
SurveyDataProvider,
type SurveyDataCache,
type SurveyStatCache,
} from './components/survey-data-context'
import { SurveyChapter } from './components/SurveyChapter'
import { SurveyChapterSection } from './components/SurveyChapterSection'
import { SurveySectionBreak } from './components/SurveySectionBreak'
import { YearProvider } from './components/year-context'
import { YearToggle } from './components/YearToggle'
interface FloatingTocProps {
tocRef: RefObject<HTMLDivElement | null>
@@ -121,13 +115,7 @@ function FloatingTableOfContents({
)
}
export default function StateOfStartups2026Content({
preloadedData,
preloadedStats,
}: {
preloadedData: SurveyDataCache
preloadedStats: SurveyStatCache
}) {
export default function StateOfStartups2026Content() {
const [showFloatingToc, setShowFloatingToc] = useState(false)
const [isTocOpen, setIsTocOpen] = useState(false)
const [activeChapter, setActiveChapter] = useState(1)
@@ -200,104 +188,78 @@ export default function StateOfStartups2026Content({
return (
<YearProvider>
<SurveyDataProvider preloadedData={preloadedData} preloadedStats={preloadedStats}>
<DefaultLayout className="bg-alternative overflow-hidden">
<AnimatePresence>
{showFloatingToc && (
<motion.div
key="floating-controls"
className="fixed top-20 inset-x-0 z-50 pointer-events-none flex justify-center"
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<motion.div layout className="flex items-start gap-2 pointer-events-auto">
<FloatingTableOfContents
tocRef={tocRef}
isTocOpen={isTocOpen}
setIsTocOpen={setIsTocOpen}
activeChapter={activeChapter}
/>
<YearToggle />
</motion.div>
<DefaultLayout className="bg-alternative overflow-hidden">
<AnimatePresence>
{showFloatingToc && (
<motion.div
key="floating-controls"
className="fixed top-20 inset-x-0 z-50 pointer-events-none flex justify-center"
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<motion.div layout className="flex items-start gap-2 pointer-events-auto">
<FloatingTableOfContents
tocRef={tocRef}
isTocOpen={isTocOpen}
setIsTocOpen={setIsTocOpen}
activeChapter={activeChapter}
/>
</motion.div>
)}
</AnimatePresence>
{/* Intro section */}
<section ref={heroRef} className="w-full">
<StateOfStartupsAuroraHeader />
<SurveySectionBreak className="hidden md:block" />
<div className="grid grid-cols-1 md:grid-cols-3 max-w-240 mx-auto md:border-x border-muted">
{/* Intro text */}
<div className="md:col-span-2 flex flex-col gap-4 px-8 py-10 border-b md:border-b-0 md:border-r border-muted text-foreground text-xl md:text-2xl text-balance">
<p>{pageData.heroSection.subheader}</p>
<p>{pageData.heroSection.cta}</p>
</div>
{/* Table of contents */}
<ol className="flex flex-col py-5">
{pageData.pageChapters.map((chapter, chapterIndex) => (
<li key={chapterIndex + 1}>
<Link
href={`#chapter-${chapterIndex + 1}`}
className="group flex flex-row gap-5 py-3 pl-7 pr-8 font-mono uppercase tracking-wide text-sm transition-all text-foreground-light hover:text-brand-link hover:bg-brand-300/25"
>
<span className="text-xs rounded-full bg-surface-75 border border-surface-200 group-hover:border-brand-500/40 w-5 h-5 flex items-center justify-center group-hover:bg-brand-600/5">
{chapterIndex + 1}
</span>{' '}
{chapter.shortTitle}
</Link>
</li>
))}
</ol>
</motion.div>
)}
</AnimatePresence>
{/* Intro section */}
<section ref={heroRef} className="w-full">
<StateOfStartupsAuroraHeader />
<SurveySectionBreak className="hidden md:block" />
<div className="grid grid-cols-1 md:grid-cols-3 max-w-240 mx-auto md:border-x border-muted">
{/* Intro text */}
<div className="md:col-span-2 flex flex-col gap-4 px-8 py-10 border-b md:border-b-0 md:border-r border-muted text-foreground text-xl md:text-2xl text-balance">
<p>{pageData.heroSection.subheader}</p>
<p>{pageData.heroSection.cta}</p>
</div>
<SurveySectionBreak />
</section>
{pageData.pageChapters.map((chapter, chapterIndex) => (
<SurveyChapter
key={chapterIndex + 1}
number={chapterIndex + 1}
shortTitle={chapter.shortTitle}
title={chapter.title}
description={chapter.description}
pullQuote={chapter.pullQuote}
pullQuoteCarousel={
(
chapter as {
pullQuoteCarousel?: Parameters<typeof SurveyChapter>[0]['pullQuoteCarousel']
}
).pullQuoteCarousel
}
>
{chapter.sections.map((section, sectionIndex) => (
<SurveyChapterSection
key={sectionIndex + 1}
title={section.title}
description={section.description}
stats={section.stats}
charts={section.charts}
wordCloud={section.wordCloud}
summarizedAnswer={section.summarizedAnswer}
rankedAnswersPair={section.rankedAnswersPair}
pullQuote={
(
section as {
pullQuote?: Parameters<typeof SurveyChapterSection>[0]['pullQuote']
}
).pullQuote
}
newInYear={(section as { newInYear?: 2025 | 2026 }).newInYear}
/>
{/* Table of contents */}
<ol className="flex flex-col py-5">
{pageData.pageChapters.map((chapter, chapterIndex) => (
<li key={chapterIndex + 1}>
<Link
href={`#chapter-${chapterIndex + 1}`}
className="group flex flex-row gap-5 py-3 pl-7 pr-8 font-mono uppercase tracking-wide text-sm transition-all text-foreground-light hover:text-brand-link hover:bg-brand-300/25"
>
<span className="text-xs rounded-full bg-surface-75 border border-surface-200 group-hover:border-brand-500/40 w-5 h-5 flex items-center justify-center group-hover:bg-brand-600/5">
{chapterIndex + 1}
</span>{' '}
{chapter.shortTitle}
</Link>
</li>
))}
</SurveyChapter>
))}
<CTABanner ref={ctaBannerRef} />
<ParticipantsList />
</DefaultLayout>
</SurveyDataProvider>
</ol>
</div>
<SurveySectionBreak />
</section>
{pageData.pageChapters.map((chapter, chapterIndex) => (
<SurveyChapter
key={chapterIndex + 1}
number={chapterIndex + 1}
shortTitle={chapter.shortTitle}
title={chapter.title}
description={chapter.description}
pullQuote={chapter.pullQuote}
pullQuoteCarousel={chapter.pullQuoteCarousel}
>
{chapter.sections.map((section, sectionIndex) => (
<SurveyChapterSection key={sectionIndex + 1} section={section} />
))}
</SurveyChapter>
))}
<CTABanner ref={ctaBannerRef} />
<ParticipantsList />
</DefaultLayout>
</YearProvider>
)
}
@@ -0,0 +1,42 @@
'use client'
export interface CohortToggleOption {
label: string
filter: string | string[] | null
}
interface CohortToggleProps {
eyebrow: string
options: CohortToggleOption[]
value: string
onValueChange: (label: string) => void
}
export function CohortToggle({ eyebrow, options, value, onValueChange }: CohortToggleProps) {
return (
<div className="flex flex-col gap-2 px-8 py-6 border-t border-muted">
<span className="text-foreground-lighter text-xs font-mono uppercase tracking-widest">
{eyebrow}
</span>
<div className="flex flex-wrap gap-1.5">
{options.map((option) => {
const isActive = option.label === value
return (
<button
key={option.label}
type="button"
onClick={() => onValueChange(option.label)}
className={`px-3 py-1 text-xs font-mono uppercase tracking-wider border rounded-full transition-colors ${
isActive
? 'border-brand-500/40 bg-brand-300/40 text-brand-link dark:text-brand'
: 'border-overlay text-foreground-light hover:text-foreground hover:bg-surface-100'
}`}
>
{option.label}
</button>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,32 @@
import { ArrowUpRight } from 'lucide-react'
import Link from 'next/link'
export interface SectionCalloutData {
eyebrow: string
body: string
href: string
cta: string
external?: boolean
}
export function SectionCallout({ eyebrow, body, href, cta, external }: SectionCalloutData) {
return (
<div className="border-t border-muted px-8 py-8">
<div className="bg-surface-100/60 border border-muted rounded-lg p-6 flex flex-col gap-3">
<span className="text-brand-link dark:text-brand text-xs font-mono uppercase tracking-widest">
{eyebrow}
</span>
<p className="text-foreground-light text-balance">{body}</p>
<Link
href={href}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
className="inline-flex items-center gap-1 text-brand-link dark:text-brand text-sm font-medium hover:underline w-fit"
>
{cta}
<ArrowUpRight size={14} strokeWidth={2} />
</Link>
</div>
</div>
)
}
@@ -0,0 +1,93 @@
'use client'
import { useMemo } from 'react'
import { distributionPercent, getDistribution } from '../lib/survey-keys'
import { ChartLegend, SurveyChartShell } from './SurveyChartShell'
import { useYear } from './year-context'
export interface ChannelCohort {
label: string
filter: string
tone: 'accent' | 'muted'
}
export interface ChannelRow {
target: string
display: string
}
interface SurveyChannelMixChartProps {
title: string
eyebrow?: string
column: string
cohortColumn: string
cohorts: ChannelCohort[]
rows: ChannelRow[]
}
export function SurveyChannelMixChart({
title,
eyebrow,
column,
cohortColumn,
cohorts,
rows,
}: SurveyChannelMixChartProps) {
const { year } = useYear()
const data = useMemo(() => {
const cohortDists = cohorts.map((c) =>
getDistribution(year, column, 'multi', { [cohortColumn]: c.filter })
)
return rows.map((row) => ({
display: row.display,
values: cohorts.map((_, ci) => distributionPercent(cohortDists[ci], row.target)),
}))
}, [year, column, cohortColumn, cohorts, rows])
const hasData = data.some((r) => r.values.some((v) => v !== null))
return (
<SurveyChartShell eyebrow={eyebrow} title={title} isEmpty={!hasData} emptyNoun="breakdown">
<div className="px-8 pb-10 flex flex-col gap-6">
<ChartLegend
items={cohorts.map((c) => ({ label: c.label, tone: c.tone, variant: 'dot' }))}
/>
<div className="flex flex-col gap-6">
{data.map((row) => (
<div key={row.display} className="flex flex-col gap-2">
<span className="text-foreground text-sm font-mono uppercase tracking-widest">
{row.display}
</span>
<div className="flex flex-col gap-1.5">
{row.values.map((v, ci) => (
<div key={ci} className="flex items-center gap-3">
<div className="h-3 flex-1 relative overflow-hidden">
<div
className="absolute inset-0 pointer-events-none bg-foreground-muted/40"
style={{
maskImage: 'url("/images/state-of-startups/pattern-stipple.svg")',
maskSize: '4px',
maskRepeat: 'repeat',
}}
/>
<div
className={`h-full relative ${cohorts[ci].tone === 'accent' ? 'bg-brand' : 'bg-foreground-light'}`}
style={{ width: `${v ?? 0}%` }}
/>
</div>
<span className="w-12 text-right text-sm font-mono tabular-nums text-foreground-light">
{v === null ? '—' : `${v}%`}
</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
</SurveyChartShell>
)
}
@@ -1,172 +1,221 @@
'use client'
import { useAccent } from './accent-context'
import { AcceleratorParticipationChart } from './charts/AcceleratorParticipationChart'
import { AICodebasePercentChart } from './charts/AICodebasePercentChart'
import { AICodingToolsChart } from './charts/AICodingToolsChart'
import { AIModelsChart } from './charts/AIModelsChart'
import { AuthProviderChart } from './charts/AuthProviderChart'
import { BiggestChallengeChart } from './charts/BiggestChallengeChart'
import { BuildingAgentsChart } from './charts/BuildingAgentsChart'
import { DatabasesChart } from './charts/DatabasesChart'
import { FundingStageChart } from './charts/FundingStageChart'
import { IndustryChart } from './charts/IndustryChart'
import { InitialPayingCustomersChart } from './charts/InitialPayingCustomersChart'
import { LocationChart } from './charts/LocationChart'
import { MCPAdoptionChart } from './charts/MCPAdoptionChart'
import { NewIdeasChart } from './charts/NewIdeasChart'
import { PaidSubscriptionsChart } from './charts/PaidSubscriptionsChart'
import { RegularSocialMediaUseChart } from './charts/RegularSocialMediaUseChart'
import { RoleChart } from './charts/RoleChart'
import { SalesToolsChart } from './charts/SalesToolsChart'
import { WorldOutlookChart } from './charts/WorldOutlookChart'
import type { SurveySection } from '~/data/surveys/state-of-startups-2026'
import { useMemo, useState } from 'react'
import { getDistribution } from '../lib/survey-keys'
import type { Aggregation, SurveyFilters } from '../lib/survey-keys'
import { CohortToggle } from './CohortToggle'
import { SectionCallout } from './SectionCallout'
import { SurveyChannelMixChart } from './SurveyChannelMixChart'
import { SurveyChart } from './SurveyChart'
import { SurveyCrossTabChart } from './SurveyCrossTabChart'
import { SurveyPullQuote } from './SurveyPullQuote'
import { SurveyPullQuoteGrid } from './SurveyPullQuoteGrid'
import { SurveyRankedAnswersPair } from './SurveyRankedAnswersPair'
import { SurveySectionBreak } from './SurveySectionBreak'
import { SurveyStatCard, type SurveyStatSource } from './SurveyStatCard'
import { SurveyStatCard } from './SurveyStatCard'
import { SurveySummarizedAnswer } from './SurveySummarizedAnswer'
import { SurveyWordCloud } from './SurveyWordCloud'
import { useYear, type SurveyYear } from './year-context'
import { YearProvider } from './year-context'
import { YearToggle } from './YearToggle'
interface SurveyChapterSectionProps {
title: string
description: string
stats?: Array<{ percent: number; label: string; source?: SurveyStatSource }>
charts?: string[]
export function SurveyChapterSection({ section }: { section: SurveySection }) {
const {
id,
eyebrow,
title,
description,
statsHeading,
stats,
charts,
pullQuote,
pullQuotes,
cohortToggle,
callout,
wordCloud,
summarizedAnswer,
rankedAnswersPair,
} = section
wordCloud?: {
label: string
words: { text: string; count: number }[]
}
summarizedAnswer?: {
label: string
answers: string[]
}
rankedAnswersPair?: Array<{ label: string; answers: string[] }>
pullQuote?: {
quote: string
author: string
authorPosition?: string
authorAvatar?: string
}
/**
* Set when an entire section's data first appeared in a given survey year.
* If the user toggles the page to an earlier year, the section body is
* replaced with a "Not available in <prior year>" placeholder while the
* title and description still render.
*/
newInYear?: SurveyYear
children?: React.ReactNode
}
const [cohortLabel, setCohortLabel] = useState(cohortToggle?.defaultLabel ?? '')
export function SurveyChapterSection({
title,
description,
stats,
charts,
wordCloud,
summarizedAnswer,
rankedAnswersPair,
pullQuote,
newInYear,
children,
}: SurveyChapterSectionProps) {
const accent = useAccent()
const { year } = useYear()
const isAvailableForYear = !newInYear || year >= newInYear
// The active cohort toggle option merges its filter into every stat and bar
// chart in the section. Cross-tab and channel-mix carry their own cohorts.
const cohortFilter: SurveyFilters | undefined = useMemo(() => {
if (!cohortToggle) return undefined
const option = cohortToggle.options.find((o) => o.label === cohortLabel)
if (!option || option.filter === null) return undefined
return { [cohortToggle.key]: option.filter }
}, [cohortToggle, cohortLabel])
const eyebrowColor = 'text-brand-link dark:text-brand'
const chartComponents = {
AcceleratorParticipationChart,
AICodebasePercentChart,
AICodingToolsChart,
AIModelsChart,
AuthProviderChart,
BiggestChallengeChart,
BuildingAgentsChart,
DatabasesChart,
FundingStageChart,
IndustryChart,
InitialPayingCustomersChart,
LocationChart,
MCPAdoptionChart,
NewIdeasChart,
PaidSubscriptionsChart,
RegularSocialMediaUseChart,
RoleChart,
SalesToolsChart,
WorldOutlookChart,
}
// A section can be compared across years only if it has any 2025 data. New-in-
// 2026 questions (share of codebase, auth, agents, MCP, the AI-codebase
// cross-tabs) have no 2025 baseline, so we hide the year toggle for them.
const hasComparison = useMemo(() => {
const probe = (col: string, agg: Aggregation, f?: SurveyFilters) =>
getDistribution(2025, col, agg, f) !== undefined
if (stats.some((s) => s.query && probe(s.query.column, s.query.aggregation, s.query.filters)))
return true
return charts.some((c) => {
if (c.kind === 'cross-tab') {
return c.cohorts.some((co) =>
c.series.some((s) =>
probe(s.query.column, s.query.aggregation, { [c.axisColumn]: co.filter })
)
)
}
if (c.kind === 'channel-mix') {
return c.cohorts.some((co) => probe(c.column, 'multi', { [c.cohortColumn]: co.filter }))
}
return probe(c.column, c.aggregation, c.filters)
})
}, [stats, charts])
// Each section keeps its own 2025/2026 state so a reader can compare a single
// section's stats and charts without affecting the rest of the page.
return (
<div id={title.toLowerCase().replace(/\s+/g, '-')} className="">
<div className="max-w-240 mx-auto flex flex-col md:border-x border-muted">
<header className="grid grid-cols-1 md:grid-cols-3 text-balance">
<h3
className={`pt-8 pb-4 px-8 md:border-r border-muted font-mono uppercase tracking-wider text-sm ${eyebrowColor}`}
>
{title}
</h3>
<p className="pb-8 md:pt-8 px-8 md:col-span-2 text-foreground-light text-lg md:text-xl leading-relaxed">
{description}
</p>
</header>
{isAvailableForYear ? (
<>
{stats && (
<aside className="border-t border-muted flex flex-col xs:flex-row flex-wrap divide-y xs:divide-x xs:divide-y-0 divide-muted">
{stats.map((stat, index) => (
<SurveyStatCard
key={index}
percent={stat.percent}
label={stat.label}
source={stat.source}
/>
))}
</aside>
)}
{charts?.map((chartName, index) => {
const ChartComponent = chartComponents[chartName as keyof typeof chartComponents]
return ChartComponent ? <ChartComponent key={index} /> : null
})}
{rankedAnswersPair && <SurveyRankedAnswersPair rankedAnswersPair={rankedAnswersPair} />}
{wordCloud && <SurveyWordCloud label={wordCloud.label} answers={wordCloud.words} />}
{summarizedAnswer && (
<SurveySummarizedAnswer
label={summarizedAnswer.label}
answers={summarizedAnswer.answers}
/>
)}
{children}
</>
) : (
<div className="border-t border-muted px-8 py-16 flex flex-col items-center justify-center gap-2 text-center">
<p className="text-foreground-light text-balance">Not available in {year}.</p>
<p className="text-foreground-lighter text-sm text-balance">
This question was added to the {newInYear} survey. Switch the year toggle to{' '}
{newInYear} to view.
<YearProvider defaultYear={2026}>
<div id={id} className="">
<div className="max-w-240 mx-auto flex flex-col md:border-x border-muted">
<header className="grid grid-cols-1 md:grid-cols-3 text-balance">
<div className="pt-8 pb-4 px-8 md:border-r border-muted flex flex-col gap-2">
<h3 className={`font-mono uppercase tracking-wider text-sm ${eyebrowColor}`}>
{eyebrow}
</h3>
<p className="text-foreground text-lg tracking-tight">{title}</p>
</div>
<p className="pb-8 md:pt-8 px-8 md:col-span-2 text-foreground-light text-lg md:text-xl leading-relaxed">
{description}
</p>
</div>
</header>
{/* Section-level year comparison control, sitting just above the data.
Hidden for new-in-2026 sections that have no 2025 baseline. */}
{hasComparison && (
<div className="flex items-center justify-between gap-4 px-8 py-3 border-t border-muted">
<span className="text-foreground-lighter text-xs font-mono uppercase tracking-widest">
Compare year
</span>
<YearToggle className="shrink-0 shadow-none" />
</div>
)}
{cohortToggle && (
<CohortToggle
eyebrow={cohortToggle.eyebrow}
options={cohortToggle.options}
value={cohortLabel}
onValueChange={setCohortLabel}
/>
)}
{/* Optional question header describing the stat cards below as the
answer to a specific survey question. */}
{statsHeading && (
<header className="px-8 pt-8 pb-2 border-t border-muted flex flex-col gap-1">
<p className="text-foreground/30 text-sm font-mono uppercase tracking-widest">
{'Q&A'}
</p>
<h3 className="text-foreground text-xl tracking-tight text-balance">
{statsHeading.title}
{statsHeading.note && (
<span className="ml-2 text-sm font-normal text-foreground-light">
({statsHeading.note})
</span>
)}
</h3>
</header>
)}
{stats.length > 0 && (
<aside
className={`${statsHeading ? '' : 'border-t border-muted'} flex flex-col xs:flex-row flex-wrap divide-y xs:divide-x xs:divide-y-0 divide-muted`}
>
{stats.map((stat, index) => (
<SurveyStatCard
key={index}
label={stat.label}
query={stat.query}
value={stat.value}
cohortFilter={cohortFilter}
/>
))}
</aside>
)}
{charts.map((chart, index) => {
if (chart.kind === 'cross-tab') {
return (
<SurveyCrossTabChart
key={index}
title={chart.title}
eyebrow={chart.eyebrow}
axisColumn={chart.axisColumn}
xAxisLabel={chart.xAxisLabel}
yAxisLabel={chart.yAxisLabel}
cohorts={chart.cohorts}
series={chart.series}
/>
)
}
if (chart.kind === 'channel-mix') {
return (
<SurveyChannelMixChart
key={index}
title={chart.title}
eyebrow={chart.eyebrow}
column={chart.column}
cohortColumn={chart.cohortColumn}
cohorts={chart.cohorts}
rows={chart.rows}
/>
)
}
return (
<SurveyChart
key={index}
title={chart.title}
note={chart.note}
column={chart.column}
aggregation={chart.aggregation}
filters={chart.filters}
cohortFilter={cohortFilter}
maxBars={chart.maxBars}
/>
)
})}
{rankedAnswersPair && rankedAnswersPair.length > 0 && (
<SurveyRankedAnswersPair rankedAnswersPair={rankedAnswersPair} />
)}
{wordCloud && <SurveyWordCloud label={wordCloud.label} answers={wordCloud.words} />}
{summarizedAnswer && (
<SurveySummarizedAnswer
label={summarizedAnswer.label}
answers={summarizedAnswer.answers}
/>
)}
{callout && <SectionCallout {...callout} />}
</div>
{pullQuote && (
<SurveyPullQuote
quote={pullQuote.quote}
author={pullQuote.author}
authorPosition={pullQuote.authorPosition}
/>
)}
{pullQuotes && pullQuotes.length > 0 && <SurveyPullQuoteGrid quotes={pullQuotes} />}
<SurveySectionBreak />
</div>
{pullQuote && isAvailableForYear && (
<SurveyPullQuote
quote={pullQuote.quote}
author={pullQuote.author}
authorPosition={pullQuote.authorPosition}
authorAvatar={pullQuote.authorAvatar}
/>
)}
<SurveySectionBreak />
</div>
</YearProvider>
)
}
@@ -2,489 +2,166 @@
import CodeBlock from '~/components/CodeBlock/CodeBlock'
import { motion } from 'framer-motion'
import { ChevronsUpDown } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from 'ui'
import { useMemo, useState } from 'react'
import { Button } from 'ui'
import { useAccent } from './accent-context'
import { useSurveyDataCache, type ChartDataItem } from './survey-data-context'
import type { Aggregation, SurveyFilters } from '../lib/survey-key'
import { distributionToBars, getDistribution, mergeFilters } from '../lib/survey-keys'
import { buildSurveySql } from '../lib/survey-sql'
import { SurveyChartShell } from './SurveyChartShell'
import TwoOptionToggle from './TwoOptionToggle'
import { rpcNameForYear, useYear, type SurveyYear } from './year-context'
// Sentinel for “no filter”
const NO_FILTER = 'unset'
interface FilterOption {
value: string
label: string
}
interface FilterConfig {
label: string
options: FilterOption[]
}
interface Filters {
[key: string]: FilterConfig
}
interface FilterColumnConfig {
label: string
options: string[]
}
// Sometimes the label doesn't match the value, so we need to map them
// Also, our options aren't always in a predicatable order, so we need to map them too
const FILTER_COLUMN_CONFIGS: Record<string, FilterColumnConfig> = {
team_size: {
label: 'Team Size',
options: ['1–10', '11–50', '51–100', '101–250', '250+'],
},
money_raised: {
label: 'Money Raised',
options: ['USD $0–10M', 'USD $11–50M', 'USD $51–100M', 'USD $100M+'],
},
person_age: {
label: 'Age',
options: ['18–21', '22–29', '30–39', '40–49', '50–59', '60+'],
},
location: {
label: 'Location',
options: [
'Africa',
'Asia',
'Europe',
'Middle East',
'North America',
'South America',
'Remote',
],
},
}
function useFilterOptions(filterColumns: string[]) {
// Build filters synchronously since everything is predefined
const filters: Filters = {}
for (const column of filterColumns) {
const config = FILTER_COLUMN_CONFIGS[column]
if (!config) {
console.warn(`No configuration found for filter column: ${column}`)
continue
}
filters[column] = {
label: config.label,
options: config.options.map((option) => ({ value: option, label: option })),
}
}
return { filters }
}
import { useYear } from './year-context'
interface SurveyChartProps {
title: string
targetColumn: string
filterColumns: string[]
generateSQLQuery?: (activeFilters: Record<string, string>) => string
/**
* Base RPC name without a year suffix (e.g. `get_role_stats`). The chart
* appends `_<year>` based on the active year context.
*/
functionName: string
/**
* Year in which this question was first asked. When the user views an
* earlier year, the chart shows a "new question" empty state instead of
* fetching.
*/
newInYear?: SurveyYear
/** Smaller parenthetical under the title clarifying how the question was asked. */
note?: string
column: string
aggregation: Aggregation
/** Static filters baked into the chart config (e.g. location = North America). */
filters?: SurveyFilters
/** Section-level cohort toggle filter, merged on top of `filters`. */
cohortFilter?: SurveyFilters
maxBars?: number
}
const accentBarFg = 'bg-brand'
const accentBarText = 'text-brand-link dark:text-brand'
export function SurveyChart({
title,
targetColumn, // Used for SQL query generation when generateSQLQuery is provided
filterColumns,
generateSQLQuery,
functionName,
newInYear,
note,
column,
aggregation,
filters,
cohortFilter,
maxBars,
}: SurveyChartProps) {
const { year } = useYear()
const isAvailableForYear = !newInYear || year >= newInYear
const resolvedFunctionName = rpcNameForYear(functionName, year)
const { get, fetchAndCache } = useSurveyDataCache()
const accent = useAccent()
const accentBg = 'hsl(var(--brand-300))'
const accentBarFg = 'bg-brand'
const accentBarText = 'text-brand-link dark:text-brand'
// Each chart uses a subset of available filters, defined below
const { filters } = useFilterOptions(filterColumns)
// Start with all filters unset (showing "all")
const [activeFilters, setActiveFilters] = useState(
filterColumns.reduce(
(acc: Record<string, string>, col: string) => ({ ...acc, [col]: NO_FILTER }),
{}
)
const effectiveFilters = useMemo(
() => mergeFilters(filters, cohortFilter),
[filters, cohortFilter]
)
// Build function parameters based on filterColumns
const buildFunctionParams = useCallback((filterValues: Record<string, string>) => {
const params: Record<string, any> = {}
if (filterValues.person_age && filterValues.person_age !== NO_FILTER) {
params.person_age_filter = [filterValues.person_age]
}
if (filterValues.location && filterValues.location !== NO_FILTER) {
params.location_filter = [filterValues.location]
}
if (filterValues.money_raised && filterValues.money_raised !== NO_FILTER) {
params.money_raised_filter = [filterValues.money_raised]
}
if (filterValues.team_size && filterValues.team_size !== NO_FILTER) {
params.team_size_filter = [filterValues.team_size]
}
return params
}, [])
const params = useMemo(
() => buildFunctionParams(activeFilters),
[activeFilters, buildFunctionParams]
)
const cached = isAvailableForYear ? get(resolvedFunctionName, params) : undefined
const [chartData, setChartData] = useState<ChartDataItem[]>(cached ?? [])
const [isLoading, setIsLoading] = useState<boolean>(!cached && isAvailableForYear)
const [dataError, setDataError] = useState<string | null>(null)
// Sync data with cache + on-demand fetch when year/filters change
useEffect(() => {
if (!isAvailableForYear) {
setChartData([])
setIsLoading(false)
setDataError(null)
return
}
const next = get(resolvedFunctionName, params)
if (next) {
setChartData(next)
setIsLoading(false)
setDataError(null)
return
}
let cancelled = false
setIsLoading(true)
setDataError(null)
fetchAndCache(resolvedFunctionName, params)
.then((data) => {
if (cancelled) return
setChartData(data)
setIsLoading(false)
})
.catch((err: any) => {
if (cancelled) return
setDataError(err?.message ?? 'Error fetching data')
setIsLoading(false)
})
return () => {
cancelled = true
}
}, [resolvedFunctionName, params, isAvailableForYear, get, fetchAndCache])
const chartData = useMemo(() => {
const dist = getDistribution(year, column, aggregation, effectiveFilters)
return distributionToBars(dist, maxBars)
}, [year, column, aggregation, effectiveFilters, maxBars])
const [view, setView] = useState<'chart' | 'sql'>('chart')
const [isExpanded, setIsExpanded] = useState(false)
// Handle both view change and expansion via a wrapper function
const handleViewChange = (newView: 'chart' | 'sql') => {
setView(newView)
setIsExpanded(true)
}
const setFilterValue = (filterKey: string, value: string) => {
setActiveFilters((prev: Record<string, string>) => ({
...prev,
[filterKey]: value,
}))
}
// Find the maximum value for scaling the bars
const maxValue = chartData.length > 0 ? Math.max(...chartData.map((item) => item.value)) : 0
// State for expand/collapse button
const [isExpanded, setIsExpanded] = useState(false)
// Fixed height for all states (loading, error, loaded collapsed)
const FIXED_HEIGHT = 300 // px
const BUTTON_AREA_HEIGHT = 40 // px
const CHART_HEIGHT = FIXED_HEIGHT - BUTTON_AREA_HEIGHT // px
const sql = useMemo(
() => buildSurveySql(year, column, aggregation, effectiveFilters, maxBars),
[year, column, aggregation, effectiveFilters, maxBars]
)
return (
<div
className="w-full bg-200 border-t border-muted"
style={{
background: `radial-gradient(circle at center -150%, ${accentBg}, transparent 80%), radial-gradient(ellipse at center 230%, hsl(var(--background-surface-200)), transparent 75%)`,
}}
>
<header className="px-8 py-8">
<p className="text-foreground/30 text-sm font-mono uppercase tracking-widest">Q&A</p>
<h3 className="text-foreground text-xl tracking-tight text-balance">{title}</h3>
</header>
<div>
{/* Filters and toggle */}
<div className="flex flex-row flex-wrap gap-6 px-8 pb-4 justify-between">
{filters && activeFilters && setFilterValue && (
<div className="flex flex-wrap gap-3">
{Object.entries(filters).map(([filterKey, filterConfig]) => (
<SurveyFilter
key={filterKey}
filterKey={filterKey}
filterConfig={filterConfig}
selectedValue={activeFilters[filterKey]}
setFilterValue={setFilterValue}
/>
))}
</div>
)}
<div className="hidden xs:block">
<TwoOptionToggle
options={['SQL', 'chart']}
activeOption={view}
onClickOption={handleViewChange}
borderOverride="border-overlay"
/>
</div>
<SurveyChartShell eyebrow="Q&A" title={title} note={note} isEmpty={chartData.length === 0}>
<div className="flex flex-row flex-wrap gap-6 px-8 pb-4 justify-end">
<div className="hidden xs:block">
<TwoOptionToggle
options={['SQL', 'chart']}
activeOption={view}
onClickOption={handleViewChange}
borderOverride="border-overlay"
/>
</div>
<motion.div
key={view} // Force a re-render when view changes
className="overflow-hidden relative"
initial={false}
animate={{
height: isExpanded ? 'auto' : `${FIXED_HEIGHT}px`,
}}
transition={{
duration: 0.2,
ease: 'easeInOut',
}}
>
{!isAvailableForYear ? (
<div className="flex flex-col items-center justify-center gap-2 py-16 px-8 text-center">
<p className="text-foreground-light text-balance">
This question was added to the {newInYear} survey.
</p>
<p className="text-foreground-lighter text-sm text-balance">
Switch the year to {newInYear} to view results.
</p>
</div>
) : dataError ? (
<div className="flex items-center justify-center h-full">
<p className="text-danger">Error: {dataError}</p>
</div>
) : view === 'chart' ? (
</div>
<motion.div
key={view}
className="overflow-hidden relative"
initial={false}
animate={{ height: isExpanded ? 'auto' : `${FIXED_HEIGHT}px` }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
{view === 'chart' ? (
<div className="flex flex-col h-full w-full justify-between px-8 pt-4 pb-12 min-h-[300px]">
<div
className={`flex flex-col h-full w-full justify-between px-8 pt-4 pb-12 min-h-[300px] transition-opacity ${
isLoading && chartData.length === 0 ? 'opacity-0' : 'opacity-100'
}`}
className="flex flex-col gap-10"
style={{ height: isExpanded ? 'auto' : `${CHART_HEIGHT}px` }}
>
{chartData.length > 0 ? (
<div
className="flex flex-col gap-10"
style={{ height: isExpanded ? 'auto' : `${CHART_HEIGHT}px` }}
>
{chartData.map((item, index) => (
<div
key={`${resolvedFunctionName}-${index}-${item.label}`}
className="flex flex-col"
>
{/* Text above the bar */}
<div
className={`mb-2 flex flex-row justify-between text-sm font-mono uppercase tracking-widest tabular-nums transition-colors duration-300 ${
item.value === maxValue ? accentBarText : 'text-foreground'
}`}
>
<span>{item.label}</span>
<span>{item.value < 1 ? '<1%' : `${item.value}%`}</span>
</div>
{chartData.map((item, index) => (
<div key={`${column}-${index}-${item.label}`} className="flex flex-col">
{/* Text above the bar */}
<div
className={`mb-2 flex flex-row justify-between text-sm font-mono uppercase tracking-widest tabular-nums transition-colors duration-300 ${
item.value === maxValue ? accentBarText : 'text-foreground'
}`}
>
<span>{item.label}</span>
<span>{item.value < 1 ? '<1%' : `${item.value}%`}</span>
</div>
{/* Progress bar */}
<div
className="h-4 relative overflow-hidden"
style={
{
'--bar-value': item.value,
'--reference': maxValue,
} as React.CSSProperties
}
>
{/* Background pattern for the entire bar */}
<div
className="absolute inset-0 pointer-events-none bg-foreground-muted/60"
style={{
maskImage: 'url("/images/state-of-startups/pattern-stipple.svg")',
maskSize: '4px',
maskRepeat: 'repeat',
maskPosition: 'center',
}}
/>
{/* Filled portion of the bar */}
<div
className="h-full relative bg-surface-100"
style={{
width: `calc(max(0.5%, (var(--bar-value) / 100) * 100%))`,
}}
>
{/* Foreground pattern for the filled portion */}
<div
className={`absolute inset-0 pointer-events-none ${item.value === maxValue ? accentBarFg : 'bg-foreground-light'}`}
style={{
maskImage: 'url("/images/state-of-startups/pattern-checker.svg")',
maskSize: '4px',
maskRepeat: 'repeat',
maskPosition: 'top left',
}}
/>
</div>
</div>
</div>
))}
</div>
) : (
<div className="flex-1 pt-8 flex flex-col items-center justify-center gap-4">
<p className="text-foreground-lighter text-balance text-center">
No responses match those filters. Maybe next year?
</p>
<Button
variant="primary"
size="tiny"
onClick={() =>
setActiveFilters(
filterColumns.reduce(
(acc: Record<string, string>, col: string) => ({
...acc,
[col]: NO_FILTER,
}),
{}
)
)
{/* Progress bar */}
<div
className="h-4 relative overflow-hidden"
style={
{
'--bar-value': item.value,
'--reference': maxValue,
} as React.CSSProperties
}
>
Clear filters
</Button>
<div
className="absolute inset-0 pointer-events-none bg-foreground-muted/60"
style={{
maskImage: 'url("/images/state-of-startups/pattern-stipple.svg")',
maskSize: '4px',
maskRepeat: 'repeat',
maskPosition: 'center',
}}
/>
<div
className="h-full relative bg-surface-100"
style={{ width: `calc(max(0.5%, (var(--bar-value) / 100) * 100%))` }}
>
<div
className={`absolute inset-0 pointer-events-none ${item.value === maxValue ? accentBarFg : 'bg-foreground-light'}`}
style={{
maskImage: 'url("/images/state-of-startups/pattern-checker.svg")',
maskSize: '4px',
maskRepeat: 'repeat',
maskPosition: 'top left',
}}
/>
</div>
</div>
</div>
)}
))}
</div>
) : (
<div className="px-8 pt-4 pb-8">
{generateSQLQuery ? (
<CodeBlock lang="sql">{generateSQLQuery(activeFilters)}</CodeBlock>
) : (
<CodeBlock lang="ts">
{`// Function call: ${resolvedFunctionName}(${JSON.stringify(buildFunctionParams(activeFilters), null, 2)})`}
</CodeBlock>
)}
</div>
)}
{/* Expand button overlay - only show for chart view */}
{view === 'chart' && !isExpanded && !dataError && chartData.length > 3 && (
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-center py-4 bg-linear-to-b from-transparent to-background">
<Button
variant="default"
size="tiny"
onClick={() => setIsExpanded(true)}
className="shadow-xs"
>
Show more
</Button>
</div>
)}
</motion.div>
</div>
</div>
)
}
// Helper to build SQL WHERE clauses from active filters
// Accepts optional initialClauses for charts that need extra constraints
// (e.g., "column IS NOT NULL")
// Note: This function is used by chart components that generate SQL queries
export function buildWhereClause(
activeFilters: Record<string, string>,
initialClauses: string[] = []
) {
const whereClauses: string[] = [...initialClauses]
for (const [column, value] of Object.entries(activeFilters)) {
if (value && value !== NO_FILTER) {
whereClauses.push(`${column} = '${value}'`)
}
}
return whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : ''
}
// Dropdown filter component
function SurveyFilter({
filterKey,
filterConfig,
selectedValue,
setFilterValue,
}: {
filterKey: string
filterConfig: {
label: string
options: { value: string; label: string }[]
}
selectedValue: string
setFilterValue: (filterKey: string, value: string) => void
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="default"
size="tiny"
iconRight={<ChevronsUpDown className="text-foreground-muted" strokeWidth={2} size={14} />}
>
<div className="w-full flex gap-1">
<p className="text-foreground-lighter">{filterConfig.label}</p>
{selectedValue !== NO_FILTER && <p className="text-foreground">{selectedValue}</p>}
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{filterConfig.options
.filter((opt) => opt.value !== NO_FILTER)
.map((option) => (
<DropdownMenuItem
key={option.value}
onClick={() => setFilterValue(filterKey, option.value)}
className={selectedValue === option.value ? 'text-brand-600' : ''}
>
{option.label}
</DropdownMenuItem>
))}
{selectedValue !== NO_FILTER && (
<div className="border-t border-border mt-1 pt-1">
<DropdownMenuItem
onClick={() => setFilterValue(filterKey, NO_FILTER)}
className="text-foreground-lighter"
>
Clear
</DropdownMenuItem>
) : (
<div className="px-8 pt-4 pb-8">
<CodeBlock lang="sql">{sql}</CodeBlock>
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
{view === 'chart' && !isExpanded && chartData.length > 3 && (
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-center py-4 bg-linear-to-b from-transparent to-background">
<Button
variant="default"
size="tiny"
onClick={() => setIsExpanded(true)}
className="shadow-xs"
>
Show more
</Button>
</div>
)}
</motion.div>
</SurveyChartShell>
)
}
@@ -0,0 +1,76 @@
import type { ReactNode } from 'react'
const CHART_GRADIENT =
'radial-gradient(circle at center -150%, hsl(var(--brand-300)), transparent 80%), radial-gradient(ellipse at center 230%, hsl(var(--background-surface-200)), transparent 75%)'
/** Shared frame for every survey chart: gradient container + Q&A/eyebrow header.
* When `isEmpty` is set (no data for the active year), renders the
* "added in 2026" placeholder instead of the chart body. */
export function SurveyChartShell({
eyebrow,
title,
note,
isEmpty = false,
emptyNoun = 'question',
children,
}: {
eyebrow?: string
title: string
/** Smaller parenthetical under the title clarifying how the question was asked. */
note?: string
isEmpty?: boolean
emptyNoun?: string
children: ReactNode
}) {
return (
<div className="w-full bg-200 border-t border-muted" style={{ background: CHART_GRADIENT }}>
<header className="px-8 py-8">
{eyebrow && (
<p className="text-foreground/30 text-sm font-mono uppercase tracking-widest">
{eyebrow}
</p>
)}
<h3 className="text-foreground text-xl tracking-tight text-balance">
{title}
{note && <span className="ml-2 text-sm font-normal text-foreground-light">({note})</span>}
</h3>
</header>
{isEmpty ? (
<div className="flex flex-col items-center justify-center gap-2 py-16 px-8 text-center">
<p className="text-foreground-light text-balance">
This {emptyNoun} was added to the 2026 survey.
</p>
<p className="text-foreground-lighter text-sm text-balance">
Switch the year to 2026 to view results.
</p>
</div>
) : (
children
)}
</div>
)
}
/** Legend swatch + caption row, shared by the cross-tab and channel-mix charts. */
export function ChartLegend({
items,
}: {
items: { label: string; tone: 'accent' | 'muted'; variant: 'line' | 'dot' }[]
}) {
return (
<div className="flex flex-wrap gap-x-6 gap-y-2">
{items.map((item) => (
<div key={item.label} className="flex items-center gap-2">
<span
className={`inline-block ${item.variant === 'line' ? 'w-4 h-[3px]' : 'w-3 h-3'} ${
item.tone === 'accent' ? 'bg-brand' : 'bg-foreground-light'
}`}
/>
<span className="text-foreground-light text-xs font-mono uppercase tracking-widest">
{item.label}
</span>
</div>
))}
</div>
)
}
@@ -0,0 +1,38 @@
'use client'
import type { DistributionQuery } from '../lib/survey-key'
import { useDistributionPercent } from './use-distribution'
function CompareValue({ caption, query }: { caption: string; query: DistributionQuery }) {
const percent = useDistributionPercent(query)
return (
<div className="flex flex-col gap-1">
<span className="text-foreground-light text-xs font-mono uppercase tracking-widest">
{caption}
</span>
<span className="text-brand text-4xl md:text-6xl font-mono tracking-tight tabular-nums">
{percent === null ? '—' : `${percent}%`}
</span>
</div>
)
}
export function SurveyCompareStatCard({
label,
a,
b,
}: {
label: string
a: { caption: string; query: DistributionQuery }
b: { caption: string; query: DistributionQuery }
}) {
return (
<div className="flex-1 px-8 py-8 flex flex-col gap-6">
<div className="grid grid-cols-2 gap-6">
<CompareValue caption={a.caption} query={a.query} />
<CompareValue caption={b.caption} query={b.query} />
</div>
<p className="text-foreground-light text-sm text-balance">{label}</p>
</div>
)
}
@@ -0,0 +1,179 @@
'use client'
import { useMemo } from 'react'
import type { Aggregation } from '../lib/survey-key'
import { distributionPercent, getDistribution } from '../lib/survey-keys'
import { ChartLegend, SurveyChartShell } from './SurveyChartShell'
import { useYear } from './year-context'
export interface CrossTabCohort {
label: string
filter: string
}
export interface CrossTabSeries {
caption: string
tone: 'accent' | 'muted'
query: { column: string; aggregation: Aggregation; target: string | string[] }
}
interface SurveyCrossTabChartProps {
title: string
eyebrow?: string
axisColumn: string
cohorts: CrossTabCohort[]
series: CrossTabSeries[]
xAxisLabel?: string
yAxisLabel?: string
}
// SVG geometry
const W = 640
const H = 300
const PAD_LEFT = 52
const PAD_RIGHT = 16
const PAD_TOP = 24
const PAD_BOTTOM = 72
const ACCENT = 'hsl(var(--brand-default))'
const MUTED = 'hsl(var(--foreground-light))'
export function SurveyCrossTabChart({
title,
eyebrow,
axisColumn,
cohorts,
series,
xAxisLabel,
yAxisLabel = '% of respondents',
}: SurveyCrossTabChartProps) {
const { year } = useYear()
const lines = useMemo(() => {
return series.map((s) => {
const points = cohorts.map((cohort) => {
const dist = getDistribution(year, s.query.column, s.query.aggregation, {
[axisColumn]: cohort.filter,
})
return distributionPercent(dist, s.query.target)
})
return { ...s, points }
})
}, [series, cohorts, axisColumn, year])
const hasData = lines.some((l) => l.points.some((p) => p !== null))
const innerW = W - PAD_LEFT - PAD_RIGHT
const innerH = H - PAD_TOP - PAD_BOTTOM
const x = (i: number) =>
cohorts.length <= 1 ? PAD_LEFT + innerW / 2 : PAD_LEFT + (i * innerW) / (cohorts.length - 1)
const y = (pct: number) => PAD_TOP + (1 - pct / 100) * innerH
return (
<SurveyChartShell eyebrow={eyebrow} title={title} isEmpty={!hasData} emptyNoun="comparison">
<div className="px-8 pb-10 flex flex-col gap-6">
<ChartLegend
items={lines.map((l) => ({ label: l.caption, tone: l.tone, variant: 'line' }))}
/>
<svg viewBox={`0 0 ${W} ${H}`} className="w-full h-auto" role="img" aria-label={title}>
{/* horizontal gridlines at 0/25/50/75/100% */}
{[0, 25, 50, 75, 100].map((g) => (
<g key={g}>
<line
x1={PAD_LEFT}
x2={W - PAD_RIGHT}
y1={y(g)}
y2={y(g)}
style={{ stroke: 'hsl(var(--border-muted))' }}
strokeWidth={1}
/>
<text
x={PAD_LEFT - 8}
y={y(g) + 3}
textAnchor="end"
className="fill-foreground-muted"
fontSize={10}
>
{g}%
</text>
</g>
))}
{/* y-axis title */}
<text
transform={`rotate(-90 14 ${PAD_TOP + innerH / 2})`}
x={14}
y={PAD_TOP + innerH / 2}
textAnchor="middle"
className="fill-foreground-light"
fontSize={11}
>
{yAxisLabel}
</text>
{/* series lines */}
{lines.map((l) => {
const pts = l.points
.map((p, i) => (p === null ? null : `${x(i)},${y(p)}`))
.filter(Boolean)
.join(' ')
const stroke = l.tone === 'accent' ? ACCENT : MUTED
return (
<g key={l.caption}>
<polyline
points={pts}
fill="none"
style={{ stroke }}
strokeWidth={l.tone === 'accent' ? 3 : 2.5}
strokeLinejoin="round"
strokeLinecap="round"
strokeDasharray={l.tone === 'muted' ? '7 4' : undefined}
/>
{l.points.map((p, i) =>
p === null ? null : (
<circle
key={i}
cx={x(i)}
cy={y(p)}
r={l.tone === 'accent' ? 4 : 3.5}
style={{ fill: stroke }}
/>
)
)}
</g>
)
})}
{/* x-axis cohort labels */}
{cohorts.map((c, i) => (
<text
key={c.label}
x={x(i)}
y={H - PAD_BOTTOM + 22}
textAnchor="middle"
className="fill-foreground-light"
fontSize={11}
>
{c.label}
</text>
))}
{/* x-axis title */}
{xAxisLabel && (
<text
x={PAD_LEFT + innerW / 2}
y={H - 14}
textAnchor="middle"
className="fill-foreground-muted"
fontSize={11}
>
{xAxisLabel}
</text>
)}
</svg>
</div>
</SurveyChartShell>
)
}
@@ -0,0 +1,50 @@
import { SurveySectionBreak } from './SurveySectionBreak'
export interface PullQuoteGridItem {
quote: string
author: string
authorPosition?: string
theme?: string
}
export function SurveyPullQuoteGrid({ quotes }: { quotes: PullQuoteGridItem[] }) {
return (
<>
<aside className="relative border-b border-muted md:border-b-0">
<div
className="absolute inset-0 pointer-events-none bg-surface-400 dark:bg-surface-75"
style={{
maskImage: 'url("/images/state-of-startups/pattern-stipple.svg")',
maskSize: '4px',
maskRepeat: 'repeat',
maskPosition: 'top left',
}}
/>
<div className="relative max-w-240 mx-auto md:border-x border-muted bg-alternative grid grid-cols-1 md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-muted [&>*:nth-child(1)]:md:border-b [&>*:nth-child(2)]:md:border-b [&>*]:md:border-muted">
{quotes.map((q, i) => (
<div key={i} className="flex flex-col gap-4 px-8 py-12">
{q.theme && (
<span className="text-brand-link dark:text-brand text-xs font-mono uppercase tracking-widest">
{q.theme}
</span>
)}
<p className="text-foreground-lighter text-xl tracking-tight text-balance">
“{q.quote}”
</p>
<p className="mt-auto">
{q.author}
{q.authorPosition && (
<>
<br />
<span className="text-foreground-muted text-sm">{q.authorPosition}</span>
</>
)}
</p>
</div>
))}
</div>
</aside>
<SurveySectionBreak className="hidden md:block" />
</>
)
}
@@ -1,75 +1,42 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useAccent } from './accent-context'
import { useSurveyDataCache } from './survey-data-context'
import type { DistributionQuery, SurveyFilters } from '../lib/survey-key'
import { useDistributionPercent } from './use-distribution'
import { useYear } from './year-context'
const ANIMATION_DURATION = 600
// Stats are written against the 2026 narrative. If a stat declares a
// `source` ({ column, aggregation, target }), the card looks up the live
// percent for the active year in the preloaded stat cache. Otherwise it
// falls back to the static `percent` for 2026 and "—" for other years.
const NARRATIVE_YEAR = 2026
export interface SurveyStatSource {
column: string
/**
* 'single' | 'multi' | 'boolean'. Kept as `string` here so narrative
* literals don't need `as const` casts; preload-survey-data.ts dispatches
* on the literal value when calling the matching generic RPC.
*/
aggregation: string
/** Bucket label(s) to match in the cached stat rows. Sums when multiple. */
target: string | string[]
export interface StatQuery extends DistributionQuery {
newIn2026?: boolean
}
interface SurveyStatCardProps {
label: string
percent: number
source?: SurveyStatSource
/** Data-driven percent. Omit when `value` is provided. */
query?: StatQuery
/** Static percent to display as-is (for derived/combined figures that don't
* map to a single column). Overrides `query`. */
value?: number
/** Section-level cohort toggle filter, merged on top of the query filters. */
cohortFilter?: SurveyFilters
}
export function SurveyStatCard({ label, percent, source }: SurveyStatCardProps) {
export function SurveyStatCard({ label, query, value, cohortFilter }: SurveyStatCardProps) {
const cardRef = useRef<HTMLDivElement>(null)
const accent = useAccent()
const { year } = useYear()
const { getStat } = useSurveyDataCache()
const accentBg = 'bg-brand'
const accentText = 'text-brand'
// Resolve the percent to display: live value when a source is declared and
// present in the cache, otherwise fall back to the static 2026 baseline
// (with "—" on prior years).
const resolvedPercent: number | null = useMemo(() => {
if (source) {
const data = getStat(year, source.column)
if (data && data.respondentCount > 0) {
const targets = Array.isArray(source.target) ? source.target : [source.target]
let count = 0
let matched = 0
for (const row of data.rows) {
if (targets.includes(row.label)) {
count += row.count
matched += 1
}
}
if (matched > 0) {
return Math.round((count / data.respondentCount) * 100)
}
// Cache present but target not in this year's rows → option didn't
// exist that year. Render "—".
return null
}
// No cache entry for this (year, column) — column likely didn't exist
// that year, or the preload errored. Fall through to the static
// fallback so the 2026 page still shows something pre-deploy.
}
return year === NARRATIVE_YEAR ? percent : null
}, [source, year, getStat, percent])
// A static `value` wins; otherwise resolve from the embedded dataset for the
// active year. Returns null when the option/column has no data that year
// (e.g. a new-in-2026 question viewed at 2025), which renders an em dash.
const queried = useDistributionPercent(query, cohortFilter)
const resolvedPercent = value ?? queried
const isNewThisYear = resolvedPercent === null && query?.newIn2026 && year < 2026
const [displayValue, setDisplayValue] = useState(0)
const [hasAnimated, setHasAnimated] = useState(false)
@@ -115,7 +82,7 @@ export function SurveyStatCard({ label, percent, source }: SurveyStatCardProps)
}, [resolvedPercent, hasAnimated])
// After the initial animation, snap to the latest resolved value when the
// year toggle (or live data) changes.
// year toggle (or cohort toggle) changes.
useEffect(() => {
if (!hasAnimated) return
if (resolvedPercent === null) {
@@ -186,9 +153,13 @@ export function SurveyStatCard({ label, percent, source }: SurveyStatCardProps)
) : (
<p
className="md:-ml-1 md:mt-8 text-2xl md:text-6xl font-mono tracking-tight inline-block flex flex-row items-baseline text-foreground-muted"
aria-label={`No ${year} baseline for this stat`}
aria-label={isNewThisYear ? 'New in 2026' : `No ${year} baseline for this stat`}
>
—
{isNewThisYear ? (
<span className="text-base md:text-2xl self-center">New in 2026</span>
) : (
'—'
)}
</p>
)}
<p className="text-foreground-light text-sm text-balance md:mr-6">{label}</p>
@@ -1,7 +1,9 @@
'use client'
import { useEffect, useState } from 'react'
import { useAccent } from './accent-context'
import './surveyResults.css'
const ROTATION_DURATION = 4000
@@ -1,6 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { useAccent } from './accent-context'
const TIMER_DURATION = 3000
@@ -1,33 +0,0 @@
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateAICodebasePercentSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters, ['ai_generated_codebase_percent IS NOT NULL'])
return `SELECT
ai_generated_codebase_percent AS band,
COUNT(*) AS total
FROM responses_2026
${whereClause}
GROUP BY ai_generated_codebase_percent
ORDER BY CASE
WHEN ai_generated_codebase_percent = '0%' THEN 1
WHEN ai_generated_codebase_percent = '1-25%' THEN 2
WHEN ai_generated_codebase_percent = '26-50%' THEN 3
WHEN ai_generated_codebase_percent = '51-75%' THEN 4
WHEN ai_generated_codebase_percent = '76-100%' THEN 5
ELSE 6
END;`
}
export function AICodebasePercentChart() {
return (
<SurveyChart
title="What percentage of your codebase was generated by AI?"
targetColumn="ai_generated_codebase_percent"
filterColumns={['person_age', 'team_size', 'money_raised']}
functionName="get_ai_generated_codebase_percent_stats"
newInYear={2026}
generateSQLQuery={generateAICodebasePercentSQL}
/>
)
}
@@ -1,53 +0,0 @@
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateAICodingToolsSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters)
return `WITH ai_coding_tools_mapping AS (
SELECT
id,
CASE
WHEN technology IN (
'Cursor',
'Windsurf',
'Cline',
'Visual Studio Code',
'Lovable',
'Bolt',
'v0',
'Tempo',
'None'
) THEN technology
WHEN LOWER(technology) LIKE '%antigravity%' THEN 'Antigravity'
WHEN LOWER(technology) LIKE '%claude%' THEN 'Claude or Claude Code'
WHEN LOWER(technology) LIKE '%codex%'
OR LOWER(technology) LIKE '%chatgpt%'
OR LOWER(technology) LIKE '%chat gpt%'
OR LOWER(technology) LIKE '%openai%' THEN 'Codex'
END AS technology_clean
FROM (
SELECT id, unnest(ai_coding_tools) AS technology
FROM responses_2026
${whereClause}
) sub
)
SELECT
technology_clean AS technology,
COUNT(DISTINCT id) AS total
FROM ai_coding_tools_mapping
WHERE technology_clean IS NOT NULL
GROUP BY technology_clean
ORDER BY total DESC;`
}
export function AICodingToolsChart() {
return (
<SurveyChart
title="Which AI coding tools do you use?"
targetColumn="ai_coding_tools"
filterColumns={['person_age', 'team_size', 'money_raised']}
functionName="get_ai_coding_tools_stats"
generateSQLQuery={generateAICodingToolsSQL}
/>
)
}
@@ -1,48 +0,0 @@
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateAIModelsSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters)
return `WITH ai_models_used_mapping AS (
SELECT
id,
CASE
WHEN technology IN (
'OpenAI',
'Anthropic/Claude',
'Hugging Face',
'Custom models',
'SageMaker',
'Bedrock',
'Cohere',
'Mistral'
) THEN technology
WHEN LOWER(technology) LIKE '%gemini%' THEN 'Gemini'
WHEN LOWER(technology) = 'deepseek' THEN 'DeepSeek'
ELSE 'Other'
END AS technology_clean
FROM (
SELECT id, unnest(ai_models_used) AS technology
FROM responses_2026
${whereClause}
) sub
)
SELECT
technology_clean AS technology,
COUNT(DISTINCT id) AS total
FROM ai_models_used_mapping
GROUP BY technology_clean
ORDER BY total DESC;`
}
export function AIModelsChart() {
return (
<SurveyChart
title="Which AI models are you using or planning to use?"
targetColumn="ai_models_used"
filterColumns={['person_age', 'team_size', 'money_raised']}
functionName="get_ai_models_stats"
generateSQLQuery={generateAIModelsSQL}
/>
)
}
@@ -1,36 +0,0 @@
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateAcceleratorParticipationSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters, ['accelerator_participation IS NOT NULL'])
return `WITH accelerator_mapping AS (
SELECT
accelerator_participation,
CASE
WHEN accelerator_participation IN ('YC', 'Techstars', 'EF', '500 Global', 'Plug and Play', 'Antler') THEN accelerator_participation
WHEN accelerator_participation = 'Did not participate in an accelerator' THEN NULL
ELSE 'Other'
END AS accelerator_clean
FROM responses_2026
${whereClause ? '\n ' + whereClause : ''}
)
SELECT
accelerator_clean AS accelerator_participation,
COUNT(*) AS total
FROM accelerator_mapping
WHERE accelerator_clean IS NOT NULL
GROUP BY accelerator_clean
ORDER BY total DESC;`
}
export function AcceleratorParticipationChart() {
return (
<SurveyChart
title="If your startup has participated in an accelerator, which one?"
targetColumn="accelerator_participation"
filterColumns={['person_age', 'location', 'money_raised']}
functionName="get_accelerator_stats"
generateSQLQuery={generateAcceleratorParticipationSQL}
/>
)
}
@@ -1,48 +0,0 @@
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateAuthProviderSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters)
return `WITH auth_provider_mapping AS (
SELECT
id,
CASE
WHEN provider IN (
'Supabase Auth',
'Auth0',
'Clerk',
'NextAuth / Auth.js',
'Firebase Auth',
'AWS Cognito',
'Stytch',
'WorkOS',
'Rolled our own'
) THEN provider
ELSE 'Other'
END AS provider_clean
FROM (
SELECT id, unnest(auth_provider) AS provider
FROM responses_2026
${whereClause}
) sub
)
SELECT
provider_clean AS provider,
COUNT(DISTINCT id) AS total
FROM auth_provider_mapping
GROUP BY provider_clean
ORDER BY total DESC;`
}
export function AuthProviderChart() {
return (
<SurveyChart
title="What authentication provider do you use?"
targetColumn="auth_provider"
filterColumns={['person_age', 'team_size', 'money_raised']}
functionName="get_auth_provider_stats"
newInYear={2026}
generateSQLQuery={generateAuthProviderSQL}
/>
)
}
@@ -1,34 +0,0 @@
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateBiggestChallengeSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters, ['biggest_challenge IS NOT NULL'])
return `WITH biggest_challenge_mapping AS (
SELECT
biggest_challenge,
CASE
WHEN biggest_challenge IN ('Customer acquisition', 'Technical complexity', 'Product-market fit', 'Product-market fit', 'Fundraising', 'Hiring', 'Other') THEN biggest_challenge
ELSE 'Other'
END AS biggest_challenge_clean
FROM responses_2026
${whereClause}
)
SELECT
biggest_challenge_clean AS biggest_challenge,
COUNT(*) AS total
FROM biggest_challenge_mapping
GROUP BY biggest_challenge_clean
ORDER BY total DESC;`
}
export function BiggestChallengeChart() {
return (
<SurveyChart
title="What’s the biggest business challenge your startup is facing today?"
targetColumn="biggest_challenge"
filterColumns={['person_age', 'location', 'money_raised']}
functionName="get_biggest_challenge_stats"
generateSQLQuery={generateBiggestChallengeSQL}
/>
)
}
@@ -1,26 +0,0 @@
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateBuildingAgentsSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters, ['building_ai_agents IS NOT NULL'])
return `SELECT
building_ai_agents AS answer,
COUNT(*) AS total
FROM responses_2026
${whereClause}
GROUP BY building_ai_agents
ORDER BY total DESC;`
}
export function BuildingAgentsChart() {
return (
<SurveyChart
title="Are you building or planning to build AI agents?"
targetColumn="building_ai_agents"
filterColumns={['person_age', 'team_size', 'money_raised']}
functionName="get_building_ai_agents_stats"
newInYear={2026}
generateSQLQuery={generateBuildingAgentsSQL}
/>
)
}
@@ -1,45 +0,0 @@
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateDatabasesSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters)
return `WITH database_mapping AS (
SELECT
id,
CASE
WHEN technology IN (
'Supabase',
'PostgreSQL',
'MySQL',
'MongoDB',
'Redis',
'Firebase',
'SQLite'
) THEN technology
ELSE 'Other'
END AS technology_clean
FROM (
SELECT id, unnest(databases) AS technology
FROM responses_2026
${whereClause}
) sub
)
SELECT
technology_clean AS technology,
COUNT(DISTINCT id) AS total
FROM database_mapping
GROUP BY technology_clean
ORDER BY total DESC;`
}
export function DatabasesChart() {
return (
<SurveyChart
title="Which database(s) is your startup using?"
targetColumn="databases"
filterColumns={['person_age', 'team_size', 'money_raised']}
functionName="get_databases_stats"
generateSQLQuery={generateDatabasesSQL}
/>
)
}
@@ -1,33 +0,0 @@
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateFundingStageSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters)
return `SELECT
funding_stage,
COUNT(*) AS total
FROM responses_2026${whereClause ? '\n' + whereClause : ''}
GROUP BY funding_stage
ORDER BY CASE
WHEN funding_stage = 'Bootstrapped' THEN 1
WHEN funding_stage = 'Pre-seed' THEN 2
WHEN funding_stage = 'Seed' THEN 3
WHEN funding_stage = 'Series A' THEN 4
WHEN funding_stage = 'Series B' THEN 5
WHEN funding_stage = 'Series C' THEN 6
WHEN funding_stage = 'Series D or later' THEN 7
ELSE 8
END;`
}
export function FundingStageChart() {
return (
<SurveyChart
title="What stage of funding is your startup in?"
targetColumn="funding_stage"
filterColumns={['person_age', 'location', 'team_size']}
functionName="get_funding_stage_stats"
generateSQLQuery={generateFundingStageSQL}
/>
)
}
@@ -1,36 +0,0 @@
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateIndustrySQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters, ['industry IS NOT NULL'])
return `WITH industry_mapping AS (
SELECT
industry,
CASE
WHEN industry = 'Developer tools and platforms' THEN 'Dev tools'
WHEN industry = 'AI / ML tools' THEN 'AI / ML'
WHEN industry IN ('SaaS', 'Dev tools', 'AI / ML', 'Consumer', 'Education', 'eCommerce', 'Fintech', 'Healthtech') THEN industry
ELSE 'Other'
END AS industry_clean
FROM responses_2026
${whereClause}
)
SELECT
industry_clean AS industry,
COUNT(*) AS total
FROM industry_mapping
GROUP BY industry_clean
ORDER BY total DESC;`
}
export function IndustryChart() {
return (
<SurveyChart
title="What is your startup’s primary industry or target customer segment?"
targetColumn="industry"
filterColumns={['person_age', 'location', 'money_raised']}
functionName="get_industry_stats"
generateSQLQuery={generateIndustrySQL}
/>
)
}
@@ -1,46 +0,0 @@
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateInitialPayingCustomersSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters)
return `WITH customer_source_mapping AS (
SELECT
id,
CASE
WHEN source IN (
'Personal/professional network',
'Inbound from social media (Twitter, LinkedIn, etc.)',
'Cold outreach or sales',
'Content (blog, newsletter, SEO)',
'Developer communities (Discord, Slack, Reddit, etc.)',
'Open source users who converted',
'Accelerators/incubators',
'Hacker News or Product Hunt'
) THEN source
ELSE 'Other'
END AS source_clean
FROM (
SELECT id, unnest(initial_paying_customers) AS source
FROM responses_2026
${whereClause}
) sub
)
SELECT
source_clean AS source,
COUNT(DISTINCT id) AS respondents
FROM customer_source_mapping
GROUP BY source_clean
ORDER BY respondents DESC;`
}
export function InitialPayingCustomersChart() {
return (
<SurveyChart
title="Where did your startup’s initial paying customers come from?"
targetColumn="initial_paying_customers"
filterColumns={['person_age', 'location', 'team_size']}
functionName="get_initial_paying_customers_stats"
generateSQLQuery={generateInitialPayingCustomersSQL}
/>
)
}
@@ -1,24 +0,0 @@
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateLocationSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters)
return `SELECT
location,
COUNT(*) AS total
FROM responses_2026${whereClause ? '\n' + whereClause : ''}
GROUP BY location
ORDER BY total DESC;`
}
export function LocationChart() {
return (
<SurveyChart
title="Where is your startup headquartered?"
targetColumn="location"
filterColumns={['person_age', 'team_size', 'money_raised']}
functionName="get_location_stats"
generateSQLQuery={generateLocationSQL}
/>
)
}
@@ -1,26 +0,0 @@
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateMCPAdoptionSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters, ['mcp_adoption IS NOT NULL'])
return `SELECT
mcp_adoption AS answer,
COUNT(*) AS total
FROM responses_2026
${whereClause}
GROUP BY mcp_adoption
ORDER BY total DESC;`
}
export function MCPAdoptionChart() {
return (
<SurveyChart
title="Have you adopted any MCP servers or tools?"
targetColumn="mcp_adoption"
filterColumns={['person_age', 'team_size', 'money_raised']}
functionName="get_mcp_adoption_stats"
newInYear={2026}
generateSQLQuery={generateMCPAdoptionSQL}
/>
)
}
@@ -1,48 +0,0 @@
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateNewIdeasSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters)
return `WITH new_ideas_mapping AS (
SELECT
id,
CASE
WHEN avenue IN (
'Hacker News',
'GitHub',
'Product Hunt',
'Twitter/X',
'Reddit',
'YouTube',
'Podcasts',
'Blogs / Newsletters',
'Discord / Slack communities',
'Conferences / Meetups'
) THEN avenue
ELSE 'Other'
END AS avenue_clean
FROM (
SELECT id, unnest(new_ideas) AS avenue
FROM responses_2026
${whereClause}
) sub
)
SELECT
avenue_clean AS avenue,
COUNT(DISTINCT id) AS total
FROM new_ideas_mapping
GROUP BY avenue_clean
ORDER BY total DESC;`
}
export function NewIdeasChart() {
return (
<SurveyChart
title="Where do you usually discover new dev tools or startup ideas?"
targetColumn="new_ideas"
filterColumns={['person_age', 'location', 'money_raised']}
functionName="get_new_ideas_stats"
generateSQLQuery={generateNewIdeasSQL}
/>
)
}
@@ -1,48 +0,0 @@
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generatePaidSubscriptionsSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters)
return `WITH subscriptions_mapping AS (
SELECT
id,
CASE
WHEN subscription IN (
'Claude',
'OpenAI / ChatGPT',
'Cursor',
'GitHub Copilot',
'Gemini',
'Lovable',
'Perplexity',
'Vercel',
'Supabase'
) THEN subscription
ELSE 'Other'
END AS subscription_clean
FROM (
SELECT id, unnest(subscriptions) AS subscription
FROM responses_2026
${whereClause}
) sub
)
SELECT
subscription_clean AS subscription,
COUNT(DISTINCT id) AS total
FROM subscriptions_mapping
GROUP BY subscription_clean
ORDER BY total DESC;`
}
export function PaidSubscriptionsChart() {
return (
<SurveyChart
title="Which subscriptions does your startup pay for?"
targetColumn="subscriptions"
filterColumns={['person_age', 'team_size', 'money_raised']}
functionName="get_subscriptions_stats"
newInYear={2026}
generateSQLQuery={generatePaidSubscriptionsSQL}
/>
)
}
@@ -1,49 +0,0 @@
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateRegularSocialMediaUseSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters)
return `WITH regular_social_media_use_mapping AS (
SELECT
id,
CASE
WHEN platform IN (
'X (Twitter)',
'Threads',
'BlueSky',
'LinkedIn',
'Reddit',
'TikTok',
'Instagram',
'YouTube',
'Mastodon',
'Discord',
'I’ve given up social media'
) THEN platform
ELSE 'Other'
END AS platform_clean
FROM (
SELECT id, unnest(regular_social_media_use) AS platform
FROM responses_2026
${whereClause}
) sub
)
SELECT
platform_clean AS platform,
COUNT(DISTINCT id) AS total
FROM regular_social_media_use_mapping
GROUP BY platform_clean
ORDER BY total DESC;`
}
export function RegularSocialMediaUseChart() {
return (
<SurveyChart
title="Which social media platforms do you use at least 3× per week?"
targetColumn="regular_social_media_use"
filterColumns={['person_age', 'location', 'money_raised']}
functionName="get_regular_social_media_use_stats"
generateSQLQuery={generateRegularSocialMediaUseSQL}
/>
)
}
@@ -1,32 +0,0 @@
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateRoleSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters)
return `SELECT
CASE
WHEN role = 'Founder / Co-founder' THEN 'Founder'
WHEN role IN ('Engineer', 'Founder / Co-founder') THEN role
ELSE 'Other'
END AS role,
COUNT(*) AS total
FROM responses_2026${whereClause ? '\n' + whereClause : ''}
GROUP BY CASE
WHEN role = 'Founder / Co-founder' THEN 'Founder'
WHEN role IN ('Engineer', 'Founder / Co-founder') THEN role
ELSE 'Other'
END
ORDER BY total DESC;`
}
export function RoleChart() {
return (
<SurveyChart
title="What is your functional role at your startup?"
targetColumn="role"
filterColumns={['person_age', 'location', 'money_raised']}
functionName="get_role_stats"
generateSQLQuery={generateRoleSQL}
/>
)
}
@@ -1,45 +0,0 @@
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateSalesToolsSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters)
return `WITH customer_tool_mapping AS (
SELECT
id,
CASE
WHEN tool IN (
'HubSpot',
'Salesforce',
'Pipedrive',
'Close.com',
'Notion / Airtable',
'Google Sheets',
'We don’t have a formal CRM or sales tool yet'
) THEN tool
ELSE 'Other'
END AS tool_clean
FROM (
SELECT id, unnest(sales_tools) AS tool
FROM responses_2026
${whereClause}
) sub
)
SELECT
tool_clean AS tool,
COUNT(DISTINCT id) AS respondents
FROM customer_tool_mapping
GROUP BY tool_clean
ORDER BY respondents DESC;`
}
export function SalesToolsChart() {
return (
<SurveyChart
title="What tools are you using to manage your sales process?"
targetColumn="sales_tools"
filterColumns={['person_age', 'location', 'team_size']}
functionName="get_sales_tools_stats"
generateSQLQuery={generateSalesToolsSQL}
/>
)
}
@@ -1,24 +0,0 @@
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateWorldOutlookSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters)
return `SELECT
world_outlook,
COUNT(*) AS total
FROM responses_2026${whereClause ? '\n' + whereClause : ''}
GROUP BY world_outlook
ORDER BY total DESC;`
}
export function WorldOutlookChart() {
return (
<SurveyChart
title="Given the state of the world, are you…"
targetColumn="world_outlook"
filterColumns={['person_age', 'location', 'money_raised']}
functionName="get_world_outlook_stats"
generateSQLQuery={generateWorldOutlookSQL}
/>
)
}
@@ -1,141 +0,0 @@
'use client'
import { createClient } from '@supabase/supabase-js'
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import { statKey, transformSurveyRows } from '../lib/preload-survey-data'
import type { SurveyYear } from './year-context'
export interface ChartDataItem {
label: string
value: number
rawValue: number
}
export type StatAggregation = 'single' | 'multi' | 'boolean'
export interface StatColumnRow {
label: string
count: number
}
export interface StatColumnData {
rows: StatColumnRow[]
respondentCount: number
}
export type SurveyDataCache = Record<string, ChartDataItem[]>
export type SurveyStatCache = Record<string, StatColumnData>
interface SurveyDataContextValue {
/** Returns cached chart data for an (rpcName, params) combo, or undefined. */
get: (rpcName: string, params: Record<string, any>) => ChartDataItem[] | undefined
/** Returns cached stat-column data for a (year, column) pair, or undefined. */
getStat: (year: SurveyYear, column: string) => StatColumnData | undefined
/** Fetches and caches a non-default chart filter combo. */
fetchAndCache: (rpcName: string, params: Record<string, any>) => Promise<ChartDataItem[]>
}
const SurveyDataContext = createContext<SurveyDataContextValue | null>(null)
const surveyClient = createClient(
process.env.NEXT_PUBLIC_SURVEY_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SURVEY_SUPABASE_ANON_KEY!
)
function paramsKey(params: Record<string, any>): string {
const keys = Object.keys(params).sort()
if (keys.length === 0) return ''
return JSON.stringify(keys.map((k) => [k, params[k]]))
}
function cacheKey(rpcName: string, params: Record<string, any>): string {
return `${rpcName}::${paramsKey(params)}`
}
export function SurveyDataProvider({
preloadedData,
preloadedStats,
children,
}: {
preloadedData: SurveyDataCache
preloadedStats: SurveyStatCache
children: ReactNode
}) {
const [cache, setCache] = useState<Map<string, ChartDataItem[]>>(() => {
const initial = new Map<string, ChartDataItem[]>()
for (const [rpcName, data] of Object.entries(preloadedData)) {
initial.set(cacheKey(rpcName, {}), data)
}
return initial
})
const statCache = useMemo(() => {
const map = new Map<string, StatColumnData>()
for (const [key, value] of Object.entries(preloadedStats)) {
map.set(key, value)
}
return map
}, [preloadedStats])
const inflight = useRef<Map<string, Promise<ChartDataItem[]>>>(new Map())
const get = useCallback(
(rpcName: string, params: Record<string, any>) => cache.get(cacheKey(rpcName, params)),
[cache]
)
const getStat = useCallback(
(year: SurveyYear, column: string) => statCache.get(statKey(year, column)),
[statCache]
)
const fetchAndCache = useCallback(
async (rpcName: string, params: Record<string, any>) => {
const key = cacheKey(rpcName, params)
const cached = cache.get(key)
if (cached) return cached
const existing = inflight.current.get(key)
if (existing) return existing
const promise = (async () => {
const { data, error } = await surveyClient.rpc(rpcName, params)
if (error) throw error
const transformed = transformSurveyRows(data ?? [])
setCache((prev) => {
const next = new Map(prev)
next.set(key, transformed)
return next
})
return transformed
})().finally(() => {
inflight.current.delete(key)
})
inflight.current.set(key, promise)
return promise
},
[cache]
)
const value = useMemo(() => ({ get, getStat, fetchAndCache }), [get, getStat, fetchAndCache])
return <SurveyDataContext.Provider value={value}>{children}</SurveyDataContext.Provider>
}
export function useSurveyDataCache() {
const ctx = useContext(SurveyDataContext)
if (!ctx) {
throw new Error('useSurveyDataCache must be used within a SurveyDataProvider')
}
return ctx
}
@@ -0,0 +1,25 @@
'use client'
import { useMemo } from 'react'
import type { DistributionQuery, SurveyFilters } from '../lib/survey-key'
import { distributionPercent, getDistribution, mergeFilters } from '../lib/survey-keys'
import { useYear } from './year-context'
/** Resolves a query to its percentage for the active year, merging an optional
* section-level cohort filter on top of the query filters. Returns null when
* the option/column has no data that year. */
export function useDistributionPercent(
query: DistributionQuery | undefined,
cohortFilter?: SurveyFilters
): number | null {
const { year } = useYear()
return useMemo(() => {
if (!query) return null
const filters = mergeFilters(query.filters, cohortFilter)
return distributionPercent(
getDistribution(year, query.column, query.aggregation, filters),
query.target
)
}, [year, query?.column, query?.aggregation, query?.target, query?.filters, cohortFilter])
}
@@ -31,7 +31,3 @@ export function useYear() {
}
return ctx
}
export function rpcNameForYear(baseName: string, year: SurveyYear) {
return year === 2026 ? `${baseName}_2026` : baseName
}
@@ -1,215 +0,0 @@
import { createClient } from '@supabase/supabase-js'
import type {
ChartDataItem,
StatAggregation,
StatColumnData,
StatColumnRow,
} from '../components/survey-data-context'
const CARRY_OVER_RPCS = [
'get_role_stats',
'get_location_stats',
'get_funding_stage_stats',
'get_accelerator_stats',
'get_industry_stats',
'get_biggest_challenge_stats',
'get_world_outlook_stats',
'get_databases_stats',
'get_ai_coding_tools_stats',
'get_ai_models_stats',
'get_initial_paying_customers_stats',
'get_sales_tools_stats',
'get_new_ideas_stats',
'get_regular_social_media_use_stats',
] as const
const NEW_IN_2026_RPCS = [
'get_ai_generated_codebase_percent_stats',
'get_auth_provider_stats',
'get_subscriptions_stats',
'get_building_ai_agents_stats',
'get_mcp_adoption_stats',
] as const
// Columns referenced by stat cards across the narrative. Preloaded via the
// generic `get_<aggregation>_stats` RPCs so the stat cards can compute live
// percentages for both 2025 and 2026 without an extra round trip in the
// browser. Adding a new stat to the narrative? Add its column here too.
const STAT_PRELOAD_QUERIES: Array<{ column: string; aggregation: StatAggregation }> = [
// single-select
{ column: 'founder_count', aggregation: 'single' },
{ column: 'person_age', aggregation: 'single' },
{ column: 'team_size', aggregation: 'single' },
{ column: 'startup_age', aggregation: 'single' },
{ column: 'funding_stage', aggregation: 'single' },
{ column: 'location', aggregation: 'single' },
{ column: 'role', aggregation: 'single' },
{ column: 'industry', aggregation: 'single' },
{ column: 'biggest_challenge', aggregation: 'single' },
{ column: 'world_outlook', aggregation: 'single' },
{ column: 'building_ai_agents', aggregation: 'single' },
{ column: 'ai_generated_codebase_percent', aggregation: 'single' },
{ column: 'mcp_adoption', aggregation: 'single' },
{ column: 'building_multi_agent_systems', aggregation: 'single' },
{ column: 'dev_community_built', aggregation: 'single' },
{ column: 'dedicated_sales_function', aggregation: 'single' },
{ column: 'describe_your_online_persona', aggregation: 'single' },
// multi-select
{ column: 'ai_coding_tools', aggregation: 'multi' },
{ column: 'subscriptions', aggregation: 'multi' },
{ column: 'ai_models_used', aggregation: 'multi' },
{ column: 'databases', aggregation: 'multi' },
{ column: 'backend_stack', aggregation: 'multi' },
{ column: 'frontend_stack', aggregation: 'multi' },
{ column: 'auth_provider', aggregation: 'multi' },
{ column: 'cloud_providers', aggregation: 'multi' },
{ column: 'ai_agents_problems', aggregation: 'multi' },
{ column: 'ai_evaluation_testing', aggregation: 'multi' },
{ column: 'sales_tools', aggregation: 'multi' },
{ column: 'observability', aggregation: 'multi' },
{ column: 'growth_tools', aggregation: 'multi' },
{ column: 'initial_paying_customers', aggregation: 'multi' },
{ column: 'user_engagement', aggregation: 'multi' },
{ column: 'pricing', aggregation: 'multi' },
{ column: 'market_model', aggregation: 'multi' },
{ column: 'regular_social_media_use', aggregation: 'multi' },
{ column: 'events', aggregation: 'multi' },
// boolean
{ column: 'founders_are_technical', aggregation: 'boolean' },
{ column: 'previous_company', aggregation: 'boolean' },
]
const STAT_YEARS = [2025, 2026] as const
export type PreloadedSurveyData = Record<string, ChartDataItem[]>
export type PreloadedStatData = Record<string, StatColumnData>
export function transformSurveyRows(rows: any[]): ChartDataItem[] {
if (!Array.isArray(rows) || rows.length === 0) return []
const total = rows.reduce((sum, row) => sum + parseInt(row.count ?? row.total ?? '0'), 0)
return rows.map((row) => {
const count = parseInt(row.count ?? row.total ?? '0')
const rawPercentage = total > 0 ? (count / total) * 100 : 0
return {
label: row.label || row.value || row[Object.keys(row)[0]],
value: Math.round(rawPercentage),
rawValue: rawPercentage,
}
})
}
function statKey(year: number, column: string): string {
return `${year}::${column}`
}
export { statKey }
export async function preloadSurveyData(): Promise<PreloadedSurveyData> {
const url = process.env.NEXT_PUBLIC_SURVEY_SUPABASE_URL
const anonKey = process.env.NEXT_PUBLIC_SURVEY_SUPABASE_ANON_KEY
if (!url || !anonKey) {
console.warn('[preloadSurveyData] survey supabase env vars missing — returning empty cache')
return {}
}
const client = createClient(url, anonKey)
const rpcNames: string[] = [
...CARRY_OVER_RPCS,
...CARRY_OVER_RPCS.map((name) => `${name}_2026`),
...NEW_IN_2026_RPCS.map((name) => `${name}_2026`),
]
const entries = await Promise.all(
rpcNames.map(async (rpcName) => {
try {
const { data, error } = await client.rpc(rpcName, {})
if (error) {
console.error(`[preloadSurveyData] ${rpcName} failed:`, error.message)
return [rpcName, [] as ChartDataItem[]] as const
}
return [rpcName, transformSurveyRows(data ?? [])] as const
} catch (err: any) {
console.error(`[preloadSurveyData] ${rpcName} threw:`, err?.message ?? err)
return [rpcName, [] as ChartDataItem[]] as const
}
})
)
return Object.fromEntries(entries)
}
export async function preloadStatData(): Promise<PreloadedStatData> {
const url = process.env.NEXT_PUBLIC_SURVEY_SUPABASE_URL
const anonKey = process.env.NEXT_PUBLIC_SURVEY_SUPABASE_ANON_KEY
if (!url || !anonKey) {
console.warn('[preloadStatData] survey supabase env vars missing — returning empty cache')
return {}
}
const client = createClient(url, anonKey)
const tasks = STAT_PRELOAD_QUERIES.flatMap(({ column, aggregation }) =>
STAT_YEARS.map(async (year) => {
const key = statKey(year, column)
try {
const rpcName =
aggregation === 'multi'
? 'get_multi_select_stats'
: aggregation === 'boolean'
? 'get_boolean_stats'
: 'get_single_select_stats'
const { data, error } = await client.rpc(rpcName, {
survey_year: year,
column_name: column,
})
if (error) {
// Column doesn't exist for this year (e.g. new-in-2026 column on 2025).
// Silently skip; stat card will fall back to its static value or "—".
return null
}
const rows: StatColumnRow[] = (data ?? []).map((r: any) => ({
label: String(r.label),
count: Number(r.count ?? 0),
}))
let respondentCount: number
if (aggregation === 'multi') {
const { data: rcData, error: rcError } = await client.rpc(
'get_respondent_count_for_column',
{ survey_year: year, column_name: column }
)
if (rcError) {
console.error(
`[preloadStatData] respondent_count(${year},${column}) failed:`,
rcError.message
)
return null
}
respondentCount = Number(rcData ?? 0)
} else {
respondentCount = rows.reduce((sum, r) => sum + r.count, 0)
}
return [key, { rows, respondentCount }] as const
} catch (err: any) {
console.error(
`[preloadStatData] ${aggregation} ${column} ${year} threw:`,
err?.message ?? err
)
return null
}
})
)
const settled = await Promise.all(tasks)
return Object.fromEntries(
settled.filter((e): e is readonly [string, StatColumnData] => e !== null)
)
}
@@ -0,0 +1,49 @@
/**
* Canonical survey key + filter types. Dependency-free on purpose: imported by
* both the runtime data layer (survey-keys.ts) and the build-time generator
* (scripts/generate-state-of-startups-data.ts), so a single definition keeps
* generated keys and lookup keys byte-identical.
*/
export type Aggregation = 'single' | 'multi' | 'boolean'
/** A filter value is an exact match (string) or an IN-list (array of strings). */
export type FilterValue = string | readonly string[]
export type SurveyFilters = Record<string, FilterValue>
/** A single distribution lookup: which option(s) of which column/aggregation,
* optionally restricted by filters. Shared by stat cards, compare cards, and
* cross-tab series. */
export interface DistributionQuery {
column: string
aggregation: Aggregation
target: string | string[]
filters?: SurveyFilters
}
function filterToken(filters?: SurveyFilters): string {
if (!filters) return ''
const keys = Object.keys(filters).sort()
if (keys.length === 0) return ''
return JSON.stringify(keys.map((k) => [k, filters[k]]))
}
export function surveyKey(
year: number,
column: string,
aggregation: Aggregation,
filters?: SurveyFilters
): string {
return `${year}|${column}|${aggregation}|${filterToken(filters)}`
}
/** Merges a base filter set with an extra cohort filter (the extra wins).
* Returns undefined when the result is empty so keys match the no-filter
* distributions. */
export function mergeFilters(
base?: SurveyFilters,
extra?: SurveyFilters
): SurveyFilters | undefined {
const merged = { ...(base ?? {}), ...(extra ?? {}) }
return Object.keys(merged).length > 0 ? merged : undefined
}
@@ -0,0 +1,87 @@
/**
* Static survey dataset access.
*
* The microsite ships fully static. All survey numbers come from the committed
* JSON generated by scripts/generate-state-of-startups-data.ts — no live
* Supabase at build or runtime. Every chart, stat, cross-tab, channel-mix, and
* cohort toggle resolves to one pre-aggregated "distribution" looked up by the
* canonical (year, column, aggregation, filters) key from survey-key.ts.
*/
import datasetJson from '../../../data/surveys/state-of-startups-data.json'
import type { SurveyYear } from '../components/year-context'
import { surveyKey, type Aggregation, type SurveyFilters } from './survey-key'
export { surveyKey, mergeFilters } from './survey-key'
export type { Aggregation, FilterValue, SurveyFilters } from './survey-key'
export interface DistributionRow {
label: string
count: number
}
export interface Distribution {
rows: DistributionRow[]
respondentCount: number
}
/** One bar in a chart: label + rounded percentage. */
export interface ChartDataItem {
label: string
value: number
}
interface SurveyDataset {
meta: { generatedFrom: string; years: Record<string, number>; slices: number }
distributions: Record<string, Distribution>
}
const dataset = datasetJson as SurveyDataset
const DISTRIBUTIONS = dataset.distributions
export const surveyMeta = dataset.meta
/** Returns the pre-aggregated distribution, or undefined when the column did
* not exist that year (e.g. a new-in-2026 question viewed at 2025). */
export function getDistribution(
year: SurveyYear,
column: string,
aggregation: Aggregation,
filters?: SurveyFilters
): Distribution | undefined {
return DISTRIBUTIONS[surveyKey(year, column, aggregation, filters)]
}
/** Sums the matching target rows over the respondent denominator. Returns null
* when there is no data for the year or the target option is absent. */
export function distributionPercent(
dist: Distribution | undefined,
target: string | readonly string[]
): number | null {
if (!dist || dist.respondentCount <= 0) return null
const targets = Array.isArray(target) ? target : [target]
let count = 0
let matched = 0
for (const row of dist.rows) {
if (targets.includes(row.label)) {
count += row.count
matched += 1
}
}
if (matched === 0) return null
return Math.round((count / dist.respondentCount) * 100)
}
/** Maps a distribution to chart bars (respondent-count denominator), already
* sorted desc by the generator. Optionally caps the number of bars. */
export function distributionToBars(
dist: Distribution | undefined,
maxBars?: number
): ChartDataItem[] {
if (!dist || dist.respondentCount <= 0) return []
const bars = dist.rows.map((row) => ({
label: row.label,
value: Math.round((row.count / dist.respondentCount) * 100),
}))
return maxBars ? bars.slice(0, maxBars) : bars
}
@@ -0,0 +1,75 @@
/**
* Builds the real, pasteable aggregation SQL shown in each chart's "SQL" view.
* This mirrors what the generic RPCs actually run against responses_<year>, so
* a reader can copy it and run it themselves: multi-select unnests the array
* and counts distinct respondents over the respondent-count denominator;
* single/boolean use a window for the share.
*/
import type { Aggregation, SurveyFilters } from './survey-key'
function quote(v: string): string {
return `'${v.replace(/'/g, "''")}'`
}
function filterClauses(filters?: SurveyFilters): string[] {
if (!filters) return []
return Object.entries(filters).map(([col, val]) =>
Array.isArray(val)
? `${col} = any(array[${val.map(quote).join(', ')}])`
: `${col} = ${quote(val as string)}`
)
}
export function buildSurveySql(
year: number,
column: string,
aggregation: Aggregation,
filters?: SurveyFilters,
maxBars?: number
): string {
const table = `responses_${year}`
const extra = filterClauses(filters)
const limit = maxBars ? `\nlimit ${maxBars}` : ''
if (aggregation === 'multi') {
const where = [`r.${column} is not null`, ...extra].join('\n and ')
return `-- % of respondents who selected each option
with answered as (
select count(*)::numeric as total
from ${table} r
where ${where}
)
select
tag as label,
count(distinct r.id) as count,
round(100 * count(distinct r.id) /
(select total from answered), 1) as pct
from ${table} r, unnest(r.${column}) as tag
where ${where}
group by tag
order by count desc${limit};`
}
if (aggregation === 'boolean') {
const where = [`${column} is not null`, ...extra].join('\n and ')
return `select
case when ${column} then 'Yes' else 'No' end as label,
count(*) as count,
round(100 * count(*) / sum(count(*)) over (), 1) as pct
from ${table}
where ${where}
group by ${column}
order by label;`
}
const where = [`${column} is not null`, ...extra].join('\n and ')
return `select
${column} as label,
count(*) as count,
round(100 * count(*) / sum(count(*)) over (), 1) as pct
from ${table}
where ${where}
group by ${column}
order by count desc${limit};`
}
+4 -15
View File
@@ -1,17 +1,11 @@
import type { Metadata } from 'next'
import { preloadStatData, preloadSurveyData } from './lib/preload-survey-data'
import { RegisterContent } from './register/RegisterContent'
import StateOfStartups2026Content from './StateOfStartups2026Content'
// TODO (alan): set this to true once results are available.
const SHOW_RESULTS = true
// Re-aggregate survey RPCs on the server every hour. Keeps the chart payload
// inlined into the static HTML without forcing a full deploy whenever the
// upstream survey data changes.
export const revalidate = 3600
export const metadata: Metadata = SHOW_RESULTS
? {
title: 'State of Startups 2026 | Supabase',
@@ -45,14 +39,9 @@ export const metadata: Metadata = SHOW_RESULTS
},
}
export default async function StateOfStartupsPage() {
export default function StateOfStartupsPage() {
if (!SHOW_RESULTS) return <RegisterContent />
const [preloadedData, preloadedStats] = await Promise.all([
preloadSurveyData(),
preloadStatData(),
])
return (
<StateOfStartups2026Content preloadedData={preloadedData} preloadedStats={preloadedStats} />
)
// All survey numbers are embedded statically (data/surveys/
// state-of-startups-data.json). No live database, no build-time fetch.
return <StateOfStartups2026Content />
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,336 @@
// Participating companies for State of Startups 2026 (real survey respondents
// who opted to be credited). Preserved verbatim from the prior data file.
export interface Participant {
company: string
url: string
}
export const participants: Participant[] = [
{ company: '440Hz', url: 'https://440hz.uk' },
{ company: '67prototypes.com', url: 'https://67prototypes.com' },
{ company: 'Abriz', url: 'https://abriz.ai' },
{ company: 'Adoptex LLC', url: 'https://adoptex.ai' },
{ company: 'adventuro', url: 'https://adventuro.com' },
{ company: 'AIVO.mx', url: 'https://aivo.mx' },
{ company: 'AJAI', url: 'https://ajai.pro' },
{ company: 'Akrisae LLC', url: 'https://akrisae.com' },
{ company: 'Alertint.com', url: 'https://alertint.com' },
{ company: 'Algebro', url: 'https://algebro.ai' },
{ company: 'Apdate', url: 'https://apdate.dev' },
{ company: 'App name: Vowfolio / company Meisal Media', url: 'https://meisal.com' },
{ company: 'Artsnoa.com', url: 'https://artsnoa.com' },
{ company: 'askOdin', url: 'https://askodin.app' },
{ company: 'AstraDev', url: 'https://astradev.org' },
{ company: 'Aurelo Technologies.', url: 'https://technologies.aurelo.cc' },
{ company: 'automate.engineer', url: 'https://automate.engineer' },
{ company: 'Babelbeez', url: 'https://babelbeez.com' },
{ company: 'Baltanor', url: 'https://baltanor.com' },
{ company: 'BelowGames', url: 'https://belowgames.com' },
{ company: 'Beyond Better', url: 'https://beyondbetter.app' },
{ company: 'BizzKings', url: 'https://bizzkings.com' },
{ company: 'BlueHero B.V.', url: 'https://bluehero.io' },
{ company: 'Bontetsu', url: 'https://bontetsu.jp' },
{ company: 'BrandLift AI', url: 'https://brandlift.pe' },
{ company: 'BuboIQ', url: 'https://buboiq.com' },
{ company: 'Built LMS', url: 'https://builtlms.com' },
{ company: 'BundlBe', url: 'https://bundlbe.com' },
{ company: 'Business Initiative', url: 'https://businessinitiative.org' },
{ company: 'Caelus labs', url: 'https://caeluslabs.io' },
{ company: 'Celsa', url: 'https://celsa.app' },
{ company: 'Cenobix', url: 'https://cenobix.com' },
{ company: 'Chartmon', url: 'https://chartmon.com' },
{ company: 'Circuit', url: 'https://withcircuit.com' },
{ company: 'ClearCash.app', url: 'https://clearcash.app' },
{ company: 'Cloud Logics', url: 'https://cloudlogics.dev' },
{ company: 'Cloudflow', url: 'https://cloudflowtechnology.com' },
{ company: 'CloudMES.ai', url: 'https://cloudmes.ai' },
{ company: 'ClubCentric', url: 'https://clubcentric.org' },
{ company: 'CodePlanet', url: 'https://acodeplanet.tech' },
{ company: 'ColabWize', url: 'https://colabwize.com' },
{ company: 'CologneAI GmbH', url: 'https://cologneai.de' },
{ company: 'Complia', url: 'https://complia.tech' },
{ company: 'Cosverse AI', url: 'https://cosverse.ai' },
{ company: 'Cotalent AI', url: 'https://cotalentai.com' },
{ company: 'Courage Library', url: 'https://couragelibrary.in' },
{ company: 'CRONIA', url: 'https://cronia.co' },
{ company: 'Darouta Ltd', url: 'https://darouta.com' },
{ company: 'Datapods', url: 'https://datapods.app' },
{ company: 'Datatensei', url: 'https://datatensei.com' },
{ company: 'Debt Clarity Tools', url: 'https://debtclaritytools.com' },
{ company: 'DECODE GR', url: 'https://decode.gr' },
{ company: 'DepthNode', url: 'https://depthnode.ai' },
{ company: 'Desert Odes', url: 'https://desertodes.com' },
{ company: 'Dinerly', url: 'https://dinerly.com' },
{ company: 'Dispatch', url: 'https://dispatch.space' },
{ company: 'docforce', url: 'https://docforce.de' },
{ company: 'DoctorStatus', url: 'https://doctorstatus.in' },
{ company: 'Dragon Tech', url: 'https://dragon-tech.biz' },
{ company: 'DREAMSDEPT.COM', url: 'https://dreamsdept.com' },
{ company: 'DSAT16', url: 'https://dsat16.com' },
{ company: 'Dualmind Labs', url: 'https://dualmindlab.tech' },
{ company: 'Dzyn72', url: 'https://dzyn72.com' },
{ company: 'Ebihara Solutions', url: 'https://ebihara-solutions.com' },
{ company: 'EchoKana', url: 'https://echokana.com' },
{ company: 'ecoTriver', url: 'https://ecotriver.com' },
{ company: 'EdgarLens', url: 'https://edgarlens.ai' },
{ company: 'Edgaze', url: 'https://edgaze.ai' },
{ company: 'EdgeGhost', url: 'https://edgeghost.com' },
{ company: 'EduvisKar', url: 'https://eduviskar.com' },
{ company: 'Elokins Innovation SARL', url: 'https://elokins.com' },
{ company: 'Emerald Sky', url: 'https://emeraldskygroup.com' },
{ company: 'EnglishGPT', url: 'https://englishgpt.org' },
{ company: 'Entelechy Corporation', url: 'https://entel.com' },
{ company: 'Enzonic LLC', url: 'https://enzonic.com' },
{ company: 'epilinked.com', url: 'https://epilinked.com' },
{ company: 'Expanse', url: 'https://expanse.com' },
{ company: 'Farofa Labs', url: 'https://farofalabs.com' },
{ company: 'fibidy', url: 'https://fibidy.com' },
{ company: 'Fictionry', url: 'https://fictionry.com' },
{ company: 'FloSports', url: 'https://flosports.tv' },
{ company: 'Fluum', url: 'https://fluum.ai' },
{ company: 'Flyweel', url: 'https://flyweel.co' },
{ company: 'fog', url: 'https://fog.do' },
{ company: 'FoodWeb AI Ltd.', url: 'https://foodweb.ai' },
{ company: 'Forestate Technologies Inc.', url: 'https://forestate.ca' },
{ company: 'FriendlyBet (soon to be registered)', url: 'https://friendlybet.app' },
{ company: 'FTL', url: 'https://ftl.build' },
{ company: 'Future Wave Technologies', url: 'https://futurewave.in' },
{ company: 'futurefluencyindex', url: 'https://futurefluencyindex.com' },
{ company: 'Ganttra', url: 'https://ganttra.com' },
{ company: 'Gaslighting Check', url: 'https://gaslightingcheck.com' },
{ company: 'Gathr', url: 'https://joingathr.com' },
{ company: 'Geo Insights', url: 'https://geo-insights.nl' },
{ company: 'Geosyncracy, Inc.', url: 'https://geosyncracy.com' },
{ company: 'Get', url: 'https://getshitdone.cl' },
{ company: 'GetFit', url: 'https://getfitapp.io' },
{ company: 'Goldfish', url: 'https://goldfish.sh' },
{ company: 'Guetton Technologies', url: 'https://guetton.dev' },
{ company: 'Haizzy', url: 'https://haizzy.fr' },
{ company: 'Harbour AI', url: 'https://harbour-ai.com' },
{ company: 'Heartly Apps UG', url: 'https://heartly.io' },
{ company: 'Helix Ag Systems', url: 'https://helixagsystems.com' },
{ company: 'Hemma', url: 'https://gethemma.app' },
{ company: 'Hemsy', url: 'https://hemsy.ai' },
{ company: 'hoachatdesk.com', url: 'https://hoachatdesk.com' },
{ company: 'Hostly', url: 'https://hostly.so' },
{ company: 'HS Partner AS', url: 'https://hspartner.no' },
{ company: 'HyperRep', url: 'https://hyperrep.ai' },
{ company: 'ignitd.ai', url: 'https://ignitd.ai' },
{ company: 'In the Money AI', url: 'https://inthemoney.ai' },
{ company: 'Innovacio', url: 'https://innovacio.nl' },
{ company: 'InstaAgent', url: 'https://instaagent.co' },
{ company: 'Instantreply.co', url: 'https://instantreply.co' },
{ company: 'InsterSwap', url: 'https://insterswap.com' },
{ company: 'Intellstra Limited', url: 'https://intellstra.com' },
{ company: 'Intrepid ATG', url: 'https://intrepidatg.com' },
{ company: 'Intrinziq', url: 'https://intrinziq.com' },
{ company: 'Ionio.ai', url: 'https://ionio.io' },
{ company: 'IronCalc', url: 'https://ironcalc.com' },
{ company: 'isavisa', url: 'https://isavisa.com' },
{ company: 'iTTD Ltd', url: 'https://ittd.works' },
{ company: 'jashio.com', url: 'https://jashio.com' },
{ company: 'JustEnglish.lk', url: 'https://justenglish.lk' },
{ company: 'K K Create Pvt. Ltd', url: 'https://kkcreate.in' },
{ company: 'Kamoo.ai', url: 'https://kamoo.ai' },
{ company: 'Kensic LLC', url: 'https://kensic.app' },
{ company: 'KeyPort', url: 'https://keyport.sbs' },
{ company: 'Kids in Cyber', url: 'https://kidsincyber.com' },
{ company: 'Kindred', url: 'https://trykindred.com' },
{ company: 'Knovolo', url: 'https://knovolo.com' },
{ company: 'KNOWRON', url: 'https://knowron.com' },
{ company: 'KoalaFix', url: 'https://koalafix.com' },
{ company: 'koffiework', url: 'https://koffie.work' },
{ company: 'Kontxs', url: 'https://kontxs.com' },
{ company: 'LAiB and Asistime', url: 'https://asistime.ai' },
{ company: 'Layout.design', url: 'https://layout.design' },
{ company: 'Learn Biomimicry', url: 'https://learnbiomimicry.com' },
{ company: 'LectriCash', url: 'https://lectricash.com' },
{ company: 'LengkApp', url: 'https://lengk.app' },
{ company: 'LeoHub AI', url: 'https://leohub.cloud' },
{ company: 'ListingRisk', url: 'https://listingrisk.com' },
{ company: 'LiteSOC Sdn Bhd', url: 'https://litesoc.io' },
{ company: 'LitLife AI', url: 'https://litlife.ai' },
{ company: 'LocalClarity', url: 'https://localclarity.com' },
{ company: 'LOGICTIS', url: 'https://logictis.pl' },
{ company: 'machinetalk GmbH', url: 'https://machine-talk.ai' },
{ company: 'Maigie Ltd', url: 'https://maigie.com' },
{ company: 'Mana', url: 'https://meetmana.com' },
{ company: 'MapChat Social', url: 'https://mapchat.social' },
{ company: 'Mara', url: 'https://iammara.com' },
{ company: 'MarginLevers.com Co.', url: 'https://marginlevers.com' },
{ company: 'MediaCreators', url: 'https://mediacreators.io' },
{ company: 'Meguria inc.', url: 'https://meguria.com' },
{ company: 'Metadine GmbH', url: 'https://metadine.de' },
{ company: 'Metis Learning', url: 'https://metis-learn.io' },
{ company: 'Milivate', url: 'https://milivate.com' },
{ company: 'MindGazeAI', url: 'https://mindgazeai.com' },
{ company: 'MindMuse', url: 'https://mindmuse.care' },
{ company: 'MindQuant', url: 'https://mindquant.ai' },
{ company: 'Minonexus', url: 'https://minonexus.com' },
{ company: 'Minte.app', url: 'https://minte.app' },
{ company: 'Mistle', url: 'https://mistle.dev' },
{ company: 'Misura AI ltd', url: 'https://misura.ai' },
{ company: 'Mocus', url: 'https://mocus.vn' },
{ company: 'Monthly Club', url: 'https://monthlyclubhq.com' },
{ company: 'moorebridge.com', url: 'https://moorebridge.com' },
{ company: 'moss labs', url: 'https://mosslabs.kr' },
{ company: 'MoyaHost', url: 'https://moyahost.ai' },
{ company: 'MyLabelDesk', url: 'https://mylabeldesk.com' },
{ company: 'mytribe', url: 'https://mytribe.in' },
{ company: 'MyVisitVault', url: 'https://myvisitvault.com' },
{ company: 'N3OSystem', url: 'https://n3osystem.io' },
{ company: 'Narcoleptic Fox', url: 'https://narcolepticfox.com' },
{ company: 'Nataris AI', url: 'https://nataris.ai' },
{ company: 'Ninchi', url: 'https://ninchi.dev' },
{ company: 'NISD2.eu', url: 'https://nisd2.eu' },
{ company: 'NIUMI', url: 'https://niumi.org' },
{ company: 'Nixyum', url: 'https://nixyum.com' },
{ company: 'nstop', url: 'https://nstop.app' },
{ company: 'nyas.io', url: 'https://nyas.io' },
{ company: 'Omny', url: 'https://omnysecurity.com' },
{ company: 'ONE62 / MIRA', url: 'https://one62.nl' },
{ company: 'OneStopOnly', url: 'https://onestoponly.com' },
{ company: 'onorder', url: 'https://onorder.id' },
{ company: 'orderflow.ge', url: 'https://orderflow.ge' },
{ company: 'ORDO CFO', url: 'https://ordocfo.com' },
{ company: 'OSH Assembly', url: 'https://oshassembly.com' },
{ company: 'Outlex', url: 'https://outlex.ai' },
{ company: 'parascope.io', url: 'https://parascope.io' },
{ company: 'Parcelcraft', url: 'https://parcelcraft.com' },
{ company: 'Partysnap', url: 'https://partysnap.app' },
{ company: 'Pausa', url: 'https://pausaapp.com' },
{ company: 'PeakGTM', url: 'https://peakgtm.com' },
{ company: 'Pedagogue Systems Inc.', url: 'https://pedagogue.co' },
{ company: 'PenguinLearn', url: 'https://penguinlearn.com' },
{ company: 'Per4mer (Smith Rock Labs)', url: 'https://per4mer.io' },
{ company: 'PicFOMO', url: 'https://picfomo.com' },
{ company: 'Pillar', url: 'https://pillar.it' },
{ company: 'Plowdesk', url: 'https://plowdesk.com' },
{ company: 'PMXT', url: 'https://pmxt.dev' },
{ company: 'PocketPrice', url: 'https://thepocketprice.com' },
{ company: 'Polarise', url: 'https://polarise.io' },
{ company: 'PoliStock, Corp.', url: 'https://polistock.app' },
{ company: 'Prep IQ Education', url: 'https://prepiqedu.in' },
{ company: 'PrepDuel', url: 'https://prepduel.com' },
{ company: 'Prizology', url: 'https://prizology.org' },
{ company: 'Profigent', url: 'https://profigent.ai' },
{ company: 'PromptMetrics', url: 'https://promptmetrics.dev' },
{ company: 'Prosepal', url: 'https://prosepal.io' },
{ company: 'Qalia', url: 'https://qalia.app' },
{ company: 'QuadraMap.com', url: 'https://quadramap.com' },
{ company: 'Qual', url: 'https://qual.cx' },
{ company: 'Querypanel', url: 'https://querypanel.io' },
{ company: 'Radial', url: 'https://radialhq.ai' },
{ company: 'Rakoono', url: 'https://rakoono.com' },
{ company: 'RealMeet', url: 'https://realmeet.de' },
{ company: 'Red Sea Language Learning', url: 'https://redsea.study' },
{ company: 'Reechable', url: 'https://reechable.com' },
{ company: 'Reman.cloud', url: 'https://reman.cloud' },
{ company: 'Rental Shop Management', url: 'https://rentalshopmanagement.com' },
{ company: 'Repaint', url: 'https://repaint.com' },
{ company: 'Replay', url: 'https://replay.sale' },
{ company: 'ReplioHQ.com', url: 'https://repliohq.com' },
{ company: 'Ribbiot', url: 'https://ribbiot.com' },
{ company: 'Runedraw.com', url: 'https://runedraw.com' },
{ company: 'Ruxii Technologies Inc', url: 'https://ruxii.ca' },
{ company: 'saasquickstart.dev', url: 'https://saasquickstart.dev' },
{ company: 'Sandai.org', url: 'https://sandai.org' },
{ company: 'Savvy Gorilla Technologies', url: 'https://savvygorilla.tech' },
{ company: 'ScaleBit Solutions', url: 'https://scalebitsolutions.com' },
{ company: 'Scalekit', url: 'https://scalekit.com' },
{ company: 'Scenarity', url: 'https://scenarity.app' },
{ company: 'ScoutBox', url: 'https://scoutbox.se' },
{ company: 'Secure Ascent', url: 'https://secureascent.com' },
{ company: 'Senrez Technology', url: 'https://senrez.com' },
{ company: 'Sentaiment', url: 'https://sentaiment.com' },
{ company: 'Sentient Logic LLC', url: 'https://sentientlogic.io' },
{ company: 'Sentrix (Formerly Unik AI)', url: 'https://unik.ai' },
{ company: 'share.page', url: 'https://share.page' },
{ company: 'Sharely.ai', url: 'https://sharely.ai' },
{ company: 'Sharkforce', url: 'https://shark-force.com' },
{ company: 'Sherlock GmbH', url: 'https://sherlock.gmbh' },
{ company: 'sieve', url: 'https://usesieve.com' },
{ company: 'Sim Builder', url: 'https://sim-builder.com' },
{ company: 'Simple Report Insight (SR Insight)', url: 'https://srinsight.com' },
{ company: 'Simplify Possible LLC', url: 'https://simplifypossible.com' },
{ company: 'SimplyHRA', url: 'https://simplyhra.com' },
{ company: 'Sipcoin', url: 'https://sipcoin.app' },
{ company: 'Slop Machine', url: 'https://slopmachine.dev' },
{ company: 'Smart AI Design', url: 'https://smartaidesigns.io' },
{ company: 'Smart Conta APP', url: 'https://smartconta.app' },
{ company: 'SNA Labs', url: 'https://snalabs.com' },
{ company: 'Socialtec', url: 'https://socialtec.nl' },
{ company: 'Sparkurio', url: 'https://sparkurio.com' },
{ company: 'SparrowSure, Inc.', url: 'https://sparrowsure.com' },
{ company: 'Specio', url: 'https://specio.space' },
{ company: 'SportDataCo', url: 'https://sportdataco.com' },
{ company: 'SR Info Qube Ltd', url: 'https://srinfoqube.com' },
{ company: 'STEWDY', url: 'https://stewdy.com' },
{ company: 'StoryBuilt', url: 'https://getstorybuilt.com' },
{ company: 'Stowe Health; Effie Connect', url: 'https://stowehealth.com' },
{ company: 'StrikeCraft', url: 'https://strikecraft.app' },
{ company: 'Supabase', url: 'https://supabase.io' },
{ company: 'swftea app pvt ltd', url: 'https://swftea.com' },
{ company: 'Swipeondeck', url: 'https://swipeondeck.com' },
{ company: 'Swirls.ai', url: 'https://swirls.ai' },
{ company: 'Syntaxis', url: 'https://syntaxis.es' },
{ company: 'Talos', url: 'https://talosrun.com' },
{ company: 'TALOS Fleet Technologies', url: 'https://talosfleettechnologies.com' },
{ company: 'Taxina Mobility Pvt Ltd', url: 'https://taxina.in' },
{ company: 'teamvince', url: 'https://teamvince.com' },
{ company: 'Techsnap', url: 'https://techsnap.io' },
{ company: 'Tecnologias Espacio Rojo', url: 'https://espaciorojo.xyz' },
{ company: 'TECSARO LABS PRIVATE LIMITED (Product Tecsaro AI)', url: 'https://tecsaro.com' },
{ company: 'TGAassist', url: 'https://tgaassist.app' },
{ company: 'The Indy Learning Team', url: 'https://theindylearningteam.org' },
{ company: 'The Origination API, Inc.', url: 'https://theoriginationapi.com' },
{ company: 'TherapyHub', url: 'https://therapyhub.io' },
{ company: 'Thinair Computer Inc.', url: 'https://thinair.dev' },
{ company: 'threetone labs', url: 'https://threetone.in' },
{ company: 'Time Squeeze', url: 'https://timesqueeze.net' },
{ company: 'Timeslicer', url: 'https://timeslicer.app' },
{ company: 'Tomorro', url: 'https://tomorro.com' },
{ company: 'Track My Academy', url: 'https://trackmyacademy.com' },
{ company: 'Trail', url: 'https://trailapp.xyz' },
{ company: 'trainable', url: 'https://trainable.world' },
{ company: 'Trayn', url: 'https://trayn.com' },
{ company: 'Trellis', url: 'https://gettrellis.app' },
{ company: 'Trelll as', url: 'https://trelll.no' },
{ company: 'TriPowers LLC', url: 'https://tripowersllc.com' },
{ company: 'TruckFull', url: 'https://truckfull.food' },
{ company: 'Truked', url: 'https://truked.com' },
{ company: 'TrulyNorth', url: 'https://trulynorth.io' },
{ company: 'Tryneedl.ai', url: 'https://tryneedl.ai' },
{ company: 'TSSA (Traffic Stop Safety App)', url: 'https://tssa.tech' },
{ company: 'twynup', url: 'https://twynup.com' },
{ company: 'Type Advantage', url: 'https://typeadvantage.com' },
{ company: 'U1C', url: 'https://u1c.lv' },
{ company: 'Uhuu.io', url: 'https://uhuu.io' },
{ company: 'uniQubit', url: 'https://uniqubit.ca' },
{ company: 'Upgram', url: 'https://upgram.org' },
{ company: 'VAEON', url: 'https://vaeon.ai' },
{ company: 'Value Play Labs', url: 'https://valueplaylabs.com' },
{ company: 'Vandoko', url: 'https://vandoko.ai' },
{ company: 'Veeda.io', url: 'https://veeda.io' },
{ company: 'velto', url: 'https://velto.it' },
{ company: 'VERSE', url: 'https://verseisbetter.com' },
{ company: 'Versuno', url: 'https://versuno.ai' },
{ company: 'vimpl.com', url: 'https://vimpl.com' },
{ company: 'Vindo', url: 'https://vindo.app' },
{ company: 'Vital Dynamix', url: 'https://vitaldynamix.com' },
{ company: 'Vox Animus', url: 'https://vox-animus.com' },
{ company: 'wallace', url: 'https://wallacecorp.ai' },
{ company: 'Watchdog', url: 'https://watchdog.no' },
{ company: 'Wavery', url: 'https://getwavery.com' },
{ company: 'WeAreTeamRocket', url: 'https://weareteamrocket.com' },
{ company: 'WeekThink', url: 'https://weekthink.com' },
{ company: 'Wincrypto.AI', url: 'https://wincrypto.world' },
{ company: 'WITHEQUUS', url: 'https://withequus.com' },
{ company: 'X-ORBIT Proxy Services, LCC.', url: 'https://xorbit.org' },
{ company: 'XCHNGBL LLC', url: 'https://xchngbl.com' },
{ company: 'Xroga AI', url: 'https://xroga.com' },
{ company: 'ZachsApps', url: 'https://zachsapps.com' },
{ company: 'Zelova AI Ltd', url: 'https://zelova.ai' },
{ company: 'Zipr', url: 'https://zipr.in' },
]
@@ -0,0 +1,228 @@
/**
* Generates the embedded State of Startups survey dataset.
*
* The published microsite ships fully static: no live Supabase at build or
* runtime. This script is the one place that touches a database. It derives
* the exact set of (column, aggregation, filters) slices the narrative renders
* by walking the narrative data itself, queries a LOCAL survey Supabase (the
* `state-of-startups` repo's stack) through the generic aggregation RPCs, and
* writes a single committed JSON of pre-aggregated distributions keyed by the
* shared surveyKey().
*
* Because the slice list is derived from the narrative (not hand-maintained),
* adding a chart/stat/cohort to the narrative automatically gets its data on
* the next run — there is no parallel list to keep in sync.
*
* Usage (with the local survey stack running), run via tsx from apps/www:
* SURVEY_DB_URL=http://127.0.0.1:55321 \
* SURVEY_DB_KEY=sb_publishable_xxx \
* npx tsx scripts/generate-state-of-startups-data.ts
*/
import { writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createClient } from '@supabase/supabase-js'
import { mergeFilters, surveyKey } from '../app/state-of-startups/lib/survey-key'
import type { Aggregation, SurveyFilters } from '../app/state-of-startups/lib/survey-key'
import surveyData from '../data/surveys/state-of-startups-2026'
const __dirname = dirname(fileURLToPath(import.meta.url))
const OUT = join(__dirname, '..', 'data', 'surveys', 'state-of-startups-data.json')
// This is a standalone, manually-run generator (not a turbo task), so these
// env vars don't affect any build output and need not be declared in turbo.json.
// eslint-disable-next-line turbo/no-undeclared-env-vars
const URL = process.env.SURVEY_DB_URL ?? 'http://127.0.0.1:55321'
// eslint-disable-next-line turbo/no-undeclared-env-vars
const KEY = process.env.SURVEY_DB_KEY ?? 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH'
const YEARS = [2025, 2026] as const
const RPC: Record<Aggregation, string> = {
single: 'get_single_select_stats',
multi: 'get_multi_select_stats',
boolean: 'get_boolean_stats',
}
interface Tuple {
column: string
aggregation: Aggregation
filters?: SurveyFilters
}
// Manual consolidations: free-text survey options that should be counted as a
// single entry. The merged percentage is authoritative (provided by the survey
// owner) because a distinct-respondent union cannot be derived by summing the
// individual option counts. Source labels are matched case-insensitively.
const MULTI_MERGES: Record<
string,
{ into: string; from: string[]; pct: Record<number, number> }[]
> = {
ai_coding_tools: [
{ into: 'ChatGPT / Codex', from: ['chatgpt', 'codex', 'openai'], pct: { 2025: 11, 2026: 13 } },
],
}
function applyMerges(
year: number,
column: string,
dist: { rows: { label: string; count: number }[]; respondentCount: number }
) {
const merges = MULTI_MERGES[column]
if (!merges) return dist
let rows = dist.rows
for (const m of merges) {
const fam = new Set(m.from.map((s) => s.toLowerCase()))
rows = rows.filter((r) => !fam.has(r.label.trim().toLowerCase()))
const count = Math.round(((m.pct[year] ?? 0) / 100) * dist.respondentCount)
if (count > 0) rows.push({ label: m.into, count })
}
rows.sort((a, b) => b.count - a.count)
return { ...dist, rows }
}
/** Walk the narrative and emit every (column, aggregation, filters) it renders:
* top-line items, section stats and bar charts (each also under every cohort
* toggle option), cross-tab series per axis cohort, and channel-mix per
* cohort. Deduped by surveyKey. */
function collectTuples(): Tuple[] {
const byKey = new Map<string, Tuple>()
const push = (column: string, aggregation: Aggregation, filters?: SurveyFilters) => {
const t: Tuple = { column, aggregation, filters }
byKey.set(surveyKey(0, column, aggregation, filters), t)
}
const addQuery = (
qy: { column: string; aggregation: Aggregation; filters?: SurveyFilters },
extra?: SurveyFilters
) => push(qy.column, qy.aggregation, mergeFilters(qy.filters, extra))
type TLQuery = { column: string; aggregation: Aggregation; filters?: SurveyFilters }
const addTopLine = (item: {
kind?: string
query?: TLQuery
a?: { query: TLQuery }
b?: { query: TLQuery }
}) => {
if (item.kind === 'compare' && item.a && item.b) {
addQuery(item.a.query)
addQuery(item.b.query)
} else if (item.query) {
addQuery(item.query)
}
}
surveyData.topLineHero.forEach(addTopLine)
surveyData.topLineSecondary.items.forEach(addTopLine)
for (const chapter of surveyData.pageChapters) {
for (const section of chapter.sections) {
const cohortExtras: (SurveyFilters | undefined)[] = [undefined]
if (section.cohortToggle) {
for (const opt of section.cohortToggle.options) {
if (opt.filter !== null) cohortExtras.push({ [section.cohortToggle.key]: opt.filter })
}
}
for (const stat of section.stats) {
if (!stat.query) continue // static stat card; no distribution to fetch
for (const extra of cohortExtras) addQuery(stat.query, extra)
}
for (const chart of section.charts) {
if (chart.kind === 'cross-tab') {
for (const cohort of chart.cohorts) {
for (const series of chart.series) {
push(series.query.column, series.query.aggregation, {
[chart.axisColumn]: cohort.filter,
})
}
}
} else if (chart.kind === 'channel-mix') {
for (const cohort of chart.cohorts) {
push(chart.column, 'multi', { [chart.cohortColumn]: cohort.filter })
}
} else {
for (const extra of cohortExtras)
push(chart.column, chart.aggregation, mergeFilters(chart.filters, extra))
}
}
}
}
return [...byKey.values()]
}
const client = createClient(URL, KEY)
async function fetchDistribution(year: number, t: Tuple) {
const [statsRes, rcRes] = await Promise.all([
client.rpc(RPC[t.aggregation], {
survey_year: year,
column_name: t.column,
filters: t.filters ?? {},
}),
client.rpc('get_respondent_count_for_column', {
survey_year: year,
column_name: t.column,
filters: t.filters ?? {},
}),
])
if (statsRes.error || rcRes.error) return null // column absent for this year, etc.
const rows = ((statsRes.data ?? []) as { label: unknown; count: unknown }[]).map((r) => ({
label: String(r.label),
count: Number(r.count ?? 0),
}))
return applyMerges(year, t.column, { rows, respondentCount: Number(rcRes.data ?? 0) })
}
async function main() {
const tuples = collectTuples()
const meta: { generatedFrom: string; years: Record<string, number>; slices: number } = {
generatedFrom: URL,
years: {},
slices: 0,
}
for (const year of YEARS) {
const rc = await client.rpc('get_respondent_count_for_column', {
survey_year: year,
column_name: 'role',
filters: {},
})
meta.years[year] = Number(rc.data ?? 0)
}
// Bounded concurrency: the local stack drops requests under a large parallel
// fan-out, which would silently skip narrative slices. A small pool keeps it
// fast without overwhelming PostgREST.
const work = YEARS.flatMap((year) => tuples.map((t) => ({ year, t })))
const distributions: Record<string, unknown> = {}
let skipped = 0
const POOL = 8
for (let i = 0; i < work.length; i += POOL) {
const batch = work.slice(i, i + POOL)
const settled = await Promise.all(
batch.map(async ({ year, t }) => {
const dist = await fetchDistribution(year, t)
return dist ? ([surveyKey(year, t.column, t.aggregation, t.filters), dist] as const) : null
})
)
for (const entry of settled) {
if (entry) distributions[entry[0]] = entry[1]
else skipped++
}
}
meta.slices = Object.keys(distributions).length
writeFileSync(OUT, JSON.stringify({ meta, distributions }, null, 2) + '\n')
console.log(`wrote ${OUT}`)
console.log(
` ${meta.slices} distributions from ${tuples.length} narrative slices, ${skipped} skipped`
)
console.log(` respondents: 2025=${meta.years[2025]} 2026=${meta.years[2026]}`)
}
main().catch((e) => {
console.error(e)
process.exit(1)
})