feat(www): wire up State of Startups 2026 results page (#45231)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Feature — ports real 2026 survey data and narrative into the existing
`apps/www/app/state-of-startups` page.

## What is the current behavior?

The 2026 page renders the pre-launch `<RegisterContent />` signup form
(`SHOW_RESULTS = false`), and the 2026 chart components still query
`responses_2025` via the unsuffixed 2025 RPCs. The chapter narrative in
`state-of-startups-2026.tsx` is placeholder copy cloned from 2025, and
there are no charts for any of the new-in-2026 survey questions (auth
provider, share of AI-generated code, paid AI/product subscriptions,
agent building, MCP adoption).

## What is the new behavior?

- Flips `SHOW_RESULTS` to `true` so `/state-of-startups` renders
`<StateOfStartups2026Content />`.
- Repoints the 14 carry-over chart components to `responses_2026` and
calls `_2026`-suffixed RPC siblings (the original 2025 RPCs stay intact
so the 2025 page can live at its own URL).
- Adds 5 new chart components — `AuthProviderChart`,
`AICodebasePercentChart`, `PaidSubscriptionsChart`,
`BuildingAgentsChart`, `MCPAdoptionChart` — and wires them into
`SurveyChapterSection`'s chart registry.
- Rewrites `state-of-startups-2026.tsx` into 8 chapters with real 2026
narrative, pull quotes, and summarized answers from the source-of-truth
`supabase/state-of-startups` repo.
- Adds `.context/**` to `.prettierignore` so the scratch directory
holding the companion SQL migrations is ignored.

## Additional context

Backend changes live in the separate `supabase/state-of-startups` repo /
project `iddgenoqmqztnnefcbwp`: a `responses_2026` table, 5 new
2026-only RPCs, and 14 `_2026`-suffixed siblings of the existing RPCs
(drafts staged under `.context/sos-2026-survey-sql/` locally, not in
this PR). This PR's chart fetches will error until those migrations are
applied and `2026-responses.arrayified.csv` is loaded.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Released State of Startups 2026 results with a year selector,
preloaded results cache for faster viewing, and hourly revalidation.
* Added new interactive charts (AI codebase share, auth providers,
biggest challenges, building agents, MCP adoption, paid subscriptions,
location) and updated many charts for 2026; charts now show “added in
2026” when not available.

* **UX**
* Reworked page layout: floating table of contents relocated, year
toggle added, and animations simplified for smoother transitions.

* **Documentation**
* Restructured survey narrative into new thematic chapters, revised
chapter content, stats, and visuals.

* **Chores**
  * Updated formatting ignore rules to skip generated/context files.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Alan Daniel authored and GitHub committed 2026-05-26 15:17:00 +01:00
1 parent a7489eb691
commit 56c34d20f8
30 files changed
+1762 -620

No files matched your search

+1
View File
@@ -4,6 +4,7 @@ node_modules
pnpm-lock.yaml
docker*
apps/**/out
.context/**
# prettier-plugin-sql-cst only supports sqlite syntax
**/supabase/migrations/*.sql
apps/www/schema.sql
@@ -9,9 +9,16 @@ import { forwardRef, useEffect, useRef, useState, type RefObject } from 'react'
import { Button, cn } from 'ui'
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>
@@ -29,15 +36,8 @@ function FloatingTableOfContents({
const currentChapter = pageData.pageChapters[activeChapter - 1]
return (
<motion.div
ref={tocRef}
className="fixed top-20 left-1/2 transform -translate-x-1/2 z-50"
initial={{ opacity: 0, y: -8, x: '-50%' }}
animate={{ opacity: 1, y: 0, x: '-50%' }}
exit={{ opacity: 0, y: -8, x: '-50%' }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<div className="relative">
<motion.div ref={tocRef} layout className="relative">
<motion.div layout className="relative">
<AnimatePresence mode="wait">
{!isTocOpen ? (
<motion.button
@@ -115,12 +115,18 @@ function FloatingTableOfContents({
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
</motion.div>
)
}
export default function StateOfStartups2026Content() {
export default function StateOfStartups2026Content({
preloadedData,
preloadedStats,
}: {
preloadedData: SurveyDataCache
preloadedStats: SurveyStatCache
}) {
const [showFloatingToc, setShowFloatingToc] = useState(false)
const [isTocOpen, setIsTocOpen] = useState(false)
const [activeChapter, setActiveChapter] = useState(1)
@@ -192,75 +198,92 @@ export default function StateOfStartups2026Content() {
}, [isTocOpen])
return (
<DefaultLayout className="bg-alternative overflow-hidden">
<AnimatePresence>
{showFloatingToc && (
<FloatingTableOfContents
tocRef={tocRef}
isTocOpen={isTocOpen}
setIsTocOpen={setIsTocOpen}
activeChapter={activeChapter}
/>
)}
</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>
<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>
</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>
</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>
</div>
<SurveySectionBreak />
</section>
<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}
>
{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}
/>
{pageData.pageChapters.map((chapter, chapterIndex) => (
<SurveyChapter
key={chapterIndex + 1}
number={chapterIndex + 1}
shortTitle={chapter.shortTitle}
title={chapter.title}
description={chapter.description}
pullQuote={chapter.pullQuote}
>
{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}
newInYear={(section as { newInYear?: 2025 | 2026 }).newInYear}
/>
))}
</SurveyChapter>
))}
</SurveyChapter>
))}
<CTABanner ref={ctaBannerRef} />
<ParticipantsList />
</DefaultLayout>
<CTABanner ref={ctaBannerRef} />
<ParticipantsList />
</DefaultLayout>
</SurveyDataProvider>
</YearProvider>
)
}
@@ -1,30 +1,36 @@
'use client'
import { useAccent } from './accent-context'
import { SurveyStatCard } from './SurveyStatCard'
import { SurveyWordCloud } from './SurveyWordCloud'
import { SurveySummarizedAnswer } from './SurveySummarizedAnswer'
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 { SurveyRankedAnswersPair } from './SurveyRankedAnswersPair'
import { SurveySectionBreak } from './SurveySectionBreak'
import { AcceleratorParticipationChart } from './charts/AcceleratorParticipationChart'
import { RoleChart } from './charts/RoleChart'
import { IndustryChart } from './charts/IndustryChart'
import { FundingStageChart } from './charts/FundingStageChart'
import { DatabasesChart } from './charts/DatabasesChart'
import { AIModelsChart } from './charts/AIModelsChart'
import { LocationChart } from './charts/LocationChart'
import { SalesToolsChart } from './charts/SalesToolsChart'
import { AICodingToolsChart } from './charts/AICodingToolsChart'
import { RegularSocialMediaUseChart } from './charts/RegularSocialMediaUseChart'
import { NewIdeasChart } from './charts/NewIdeasChart'
import { InitialPayingCustomersChart } from './charts/InitialPayingCustomersChart'
import { WorldOutlookChart } from './charts/WorldOutlookChart'
import { BiggestChallengeChart } from './charts/BiggestChallengeChart'
import { SurveyStatCard, type SurveyStatSource } from './SurveyStatCard'
import { SurveySummarizedAnswer } from './SurveySummarizedAnswer'
import { SurveyWordCloud } from './SurveyWordCloud'
import { useYear, type SurveyYear } from './year-context'
interface SurveyChapterSectionProps {
title: string
description: string
stats?: Array<{ percent: number; label: string }>
stats?: Array<{ percent: number; label: string; source?: SurveyStatSource }>
charts?: string[]
wordCloud?: {
@@ -36,6 +42,13 @@ interface SurveyChapterSectionProps {
answers: string[]
}
rankedAnswersPair?: Array<{ label: string; answers: 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
}
@@ -47,27 +60,35 @@ export function SurveyChapterSection({
wordCloud,
summarizedAnswer,
rankedAnswersPair,
newInYear,
children,
}: SurveyChapterSectionProps) {
const accent = useAccent()
const { year } = useYear()
const isAvailableForYear = !newInYear || year >= newInYear
const eyebrowColor = 'text-brand-link dark:text-brand'
const chartComponents = {
RoleChart,
IndustryChart,
FundingStageChart,
AcceleratorParticipationChart,
DatabasesChart,
AICodebasePercentChart,
AICodingToolsChart,
AIModelsChart,
RegularSocialMediaUseChart,
NewIdeasChart,
AuthProviderChart,
BiggestChallengeChart,
BuildingAgentsChart,
DatabasesChart,
FundingStageChart,
IndustryChart,
InitialPayingCustomersChart,
LocationChart,
MCPAdoptionChart,
NewIdeasChart,
PaidSubscriptionsChart,
RegularSocialMediaUseChart,
RoleChart,
SalesToolsChart,
WorldOutlookChart,
BiggestChallengeChart,
LocationChart,
}
return (
@@ -84,31 +105,48 @@ export function SurveyChapterSection({
</p>
</header>
{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} />
))}
</aside>
{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.
</p>
</div>
)}
{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>
<SurveySectionBreak />
@@ -1,7 +1,9 @@
'use client'
import { useEffect, useState, useRef, useCallback } from 'react'
import { createClient } from '@supabase/supabase-js'
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,
@@ -9,16 +11,11 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from 'ui'
import { ChevronsUpDown } from 'lucide-react'
import TwoOptionToggle from './TwoOptionToggle'
import CodeBlock from '~/components/CodeBlock/CodeBlock'
import { useAccent } from './accent-context'
// Separate Supabase client for survey project
const externalSupabase = createClient(
process.env.NEXT_PUBLIC_SURVEY_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SURVEY_SUPABASE_ANON_KEY!
)
import { useAccent } from './accent-context'
import { useSurveyDataCache, type ChartDataItem } from './survey-data-context'
import TwoOptionToggle from './TwoOptionToggle'
import { rpcNameForYear, useYear, type SurveyYear } from './year-context'
// Sentinel for “no filter”
const NO_FILTER = 'unset'
@@ -37,12 +34,6 @@ interface Filters {
[key: string]: FilterConfig
}
interface ChartDataItem {
label: string
value: number
rawValue: number
}
interface FilterColumnConfig {
label: string
options: string[]
@@ -98,81 +89,22 @@ function useFilterOptions(filterColumns: string[]) {
return { filters }
}
// Fetch survey data using secure database functions
function useSurveyData(
shouldFetch: boolean,
functionName: string,
functionParams: (activeFilters: Record<string, string>) => Record<string, any>,
activeFilters: Record<string, string>
) {
const [chartData, setChartData] = useState<ChartDataItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!shouldFetch) return
async function fetchData() {
try {
setIsLoading(true)
setError(null)
let data, fetchError
const functionParamsData = functionParams(activeFilters)
const { data: functionData, error: functionError } = await externalSupabase.rpc(
functionName,
functionParamsData
)
data = functionData
fetchError = functionError
if (fetchError) {
console.error('Error executing SQL query:', fetchError)
setError(fetchError.message)
return
}
// Calculate total for percentage calculation
const total = data.reduce(
(sum: number, row: any) => sum + parseInt(row.count || row.total),
0
)
// Transform the data to match chart format
const processedData = data.map((row: any) => {
const count = parseInt(row.count || row.total)
const rawPercentage = total > 0 ? (count / total) * 100 : 0
const roundedPercentage = Math.round(rawPercentage)
return {
label: row.label || row.value || row[Object.keys(row)[0]], // Get the first column as label
value: roundedPercentage,
rawValue: rawPercentage, // Keep the raw value for bar scaling
}
})
setChartData(processedData)
} catch (err: any) {
console.error('Error in fetchData:', err)
setError(err.message)
} finally {
setIsLoading(false)
}
}
fetchData()
}, [shouldFetch, functionName, functionParams, activeFilters])
return { chartData, isLoading, error }
}
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
}
export function SurveyChart({
@@ -181,48 +113,17 @@ export function SurveyChart({
filterColumns,
generateSQLQuery,
functionName,
newInYear,
}: 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'
const [isInView, setIsInView] = useState(false)
const chartRef = useRef<HTMLDivElement>(null)
const [shouldAnimateBars, setShouldAnimateBars] = useState(false)
const [hasLoadedOnce, setHasLoadedOnce] = useState(false)
// Intersection observer to trigger chart data loading via database function
useEffect(() => {
const chartRefCurrent = chartRef.current
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !hasLoadedOnce) {
setIsInView(true)
setHasLoadedOnce(true)
observer.disconnect() // Only trigger once
}
})
},
{
threshold: 0.1,
rootMargin: '128px', // Start loading before the component comes into view
}
)
if (chartRefCurrent) {
observer.observe(chartRefCurrent)
}
return () => {
if (chartRefCurrent) {
observer.unobserve(chartRefCurrent)
}
}
}, [hasLoadedOnce])
// Each chart uses a subset of available filters, defined below
const { filters } = useFilterOptions(filterColumns)
@@ -235,49 +136,69 @@ export function SurveyChart({
)
// Build function parameters based on filterColumns
const buildFunctionParams = useCallback((activeFilters: Record<string, string>) => {
const buildFunctionParams = useCallback((filterValues: Record<string, string>) => {
const params: Record<string, any> = {}
// Convert single values to arrays for the function parameters
if (activeFilters.person_age && activeFilters.person_age !== 'unset') {
params.person_age_filter = [activeFilters.person_age]
if (filterValues.person_age && filterValues.person_age !== NO_FILTER) {
params.person_age_filter = [filterValues.person_age]
}
if (activeFilters.location && activeFilters.location !== 'unset') {
params.location_filter = [activeFilters.location]
if (filterValues.location && filterValues.location !== NO_FILTER) {
params.location_filter = [filterValues.location]
}
if (activeFilters.money_raised && activeFilters.money_raised !== 'unset') {
params.money_raised_filter = [activeFilters.money_raised]
if (filterValues.money_raised && filterValues.money_raised !== NO_FILTER) {
params.money_raised_filter = [filterValues.money_raised]
}
if (activeFilters.team_size && activeFilters.team_size !== 'unset') {
params.team_size_filter = [activeFilters.team_size]
if (filterValues.team_size && filterValues.team_size !== NO_FILTER) {
params.team_size_filter = [filterValues.team_size]
}
return params
}, [])
// Use the custom hook to fetch data
const {
chartData,
isLoading: dataLoading,
error: dataError,
} = useSurveyData(isInView, functionName, buildFunctionParams, activeFilters)
const params = useMemo(
() => buildFunctionParams(activeFilters),
[activeFilters, buildFunctionParams]
)
// Reset animation state when filters change
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(() => {
setShouldAnimateBars(false)
}, [activeFilters])
// Trigger bar animation when data loads
useEffect(() => {
if (!dataLoading && chartData.length > 0 && !shouldAnimateBars) {
// Small delay to ensure DOM is ready
const timer = setTimeout(() => {
setShouldAnimateBars(true)
}, 100)
return () => clearTimeout(timer)
if (!isAvailableForYear) {
setChartData([])
setIsLoading(false)
setDataError(null)
return
}
}, [dataLoading, chartData.length, shouldAnimateBars])
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 [view, setView] = useState<'chart' | 'sql'>('chart')
@@ -305,17 +226,8 @@ export function SurveyChart({
const BUTTON_AREA_HEIGHT = 40 // px
const CHART_HEIGHT = FIXED_HEIGHT - BUTTON_AREA_HEIGHT // px
const skeletonData = [
{ label: 'Loading', value: 0, rawValue: 0 },
{ label: 'Loading', value: 0, rawValue: 0 },
{ label: 'Loading', value: 0, rawValue: 0 },
]
const displayData = dataLoading ? skeletonData : chartData || []
return (
<div
ref={chartRef}
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%)`,
@@ -363,27 +275,39 @@ export function SurveyChart({
ease: 'easeInOut',
}}
>
{dataError ? (
{!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 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'
}`}
>
{chartData.length > 0 ? (
<div
className="flex flex-col gap-10"
style={{ height: isExpanded ? 'auto' : `${CHART_HEIGHT}px` }}
>
{displayData.map((item, index) => (
<div key={index} className="flex flex-col">
{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 ${
shouldAnimateBars
? item.value === maxValue
? accentBarText
: 'text-foreground'
: 'text-foreground-muted'
item.value === maxValue ? accentBarText : 'text-foreground'
}`}
>
<span>{item.label}</span>
@@ -413,11 +337,9 @@ export function SurveyChart({
{/* Filled portion of the bar */}
<div
className={`h-full relative bg-surface-100`}
className="h-full relative bg-surface-100"
style={{
width: `calc(max(0.5%, (var(--bar-value) / 100) * 100%))`,
clipPath: shouldAnimateBars ? 'inset(0 0 0 0)' : 'inset(0 100% 0 0)',
transition: `clip-path 0.5s steps(${Math.max(2, Math.floor((item.value / 100) * 12))}, end) ${index * 0.05}s`,
}}
>
{/* Foreground pattern for the filled portion */}
@@ -466,29 +388,25 @@ export function SurveyChart({
<CodeBlock lang="sql">{generateSQLQuery(activeFilters)}</CodeBlock>
) : (
<CodeBlock lang="ts">
{`// Function call: ${functionName}(${JSON.stringify(buildFunctionParams(activeFilters), null, 2)})`}
{`// Function call: ${resolvedFunctionName}(${JSON.stringify(buildFunctionParams(activeFilters), null, 2)})`}
</CodeBlock>
)}
</div>
)}
{/* Expand button overlay - only show for chart view */}
{view === 'chart' &&
!isExpanded &&
!dataLoading &&
!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
type="default"
size="tiny"
onClick={() => setIsExpanded(true)}
className="shadow-xs"
>
Show more
</Button>
</div>
)}
{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
type="default"
size="tiny"
onClick={() => setIsExpanded(true)}
className="shadow-xs"
>
Show more
</Button>
</div>
)}
</motion.div>
</div>
</div>
@@ -1,23 +1,85 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useAccent } from './accent-context'
import { useSurveyDataCache } from './survey-data-context'
import { useYear } from './year-context'
const ANIMATION_DURATION = 600
// These values are calculated via static SQL queries under 'Key Stats' and rounded for display in data/surveys/state-of-startups-2025.tsx file
export function SurveyStatCard({ label, percent }: { label: string; percent: number }) {
const [displayValue, setDisplayValue] = useState(0)
const [hasAnimated, setHasAnimated] = useState(false)
const [shouldAnimateBar, setShouldAnimateBar] = useState(false)
// 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[]
}
interface SurveyStatCardProps {
label: string
percent: number
source?: SurveyStatSource
}
export function SurveyStatCard({ label, percent, source }: 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])
const [displayValue, setDisplayValue] = useState(0)
const [hasAnimated, setHasAnimated] = useState(false)
const [shouldAnimateBar, setShouldAnimateBar] = useState(false)
// Initial scroll-in animation: count up from 0 to the resolved value the
// first time the card enters the viewport.
useEffect(() => {
if (!percent || hasAnimated) return
if (hasAnimated) return
if (resolvedPercent === null) return
const observer = new IntersectionObserver(
(entries) => {
@@ -26,25 +88,16 @@ export function SurveyStatCard({ label, percent }: { label: string; percent: num
setHasAnimated(true)
setShouldAnimateBar(true)
// Start counting animation
const startTime = Date.now()
const duration = ANIMATION_DURATION
const startValue = 0
const endValue = percent
const endValue = resolvedPercent
const animate = () => {
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / duration, 1)
// Easing function for smooth animation
const progress = Math.min(elapsed / ANIMATION_DURATION, 1)
const easeOutQuart = 1 - Math.pow(1 - progress, 4)
const currentValue = Math.round(startValue + (endValue - startValue) * easeOutQuart)
setDisplayValue(currentValue)
if (progress < 1) {
requestAnimationFrame(animate)
}
setDisplayValue(Math.round(startValue + (endValue - startValue) * easeOutQuart))
if (progress < 1) requestAnimationFrame(animate)
}
requestAnimationFrame(animate)
@@ -55,16 +108,27 @@ export function SurveyStatCard({ label, percent }: { label: string; percent: num
)
const currentCardRef = cardRef.current
if (currentCardRef) {
observer.observe(currentCardRef)
}
if (currentCardRef) observer.observe(currentCardRef)
return () => {
if (currentCardRef) {
observer.unobserve(currentCardRef)
}
if (currentCardRef) observer.unobserve(currentCardRef)
}
}, [percent, hasAnimated])
}, [resolvedPercent, hasAnimated])
// After the initial animation, snap to the latest resolved value when the
// year toggle (or live data) changes.
useEffect(() => {
if (!hasAnimated) return
if (resolvedPercent === null) {
setDisplayValue(0)
setShouldAnimateBar(false)
return
}
setDisplayValue(resolvedPercent)
setShouldAnimateBar(true)
}, [resolvedPercent, hasAnimated])
const hasValue = resolvedPercent !== null
const renderedBarValue = hasValue ? resolvedPercent : 0
return (
<div ref={cardRef} className="flex-1 px-8 py-8 flex flex-col gap-4">
@@ -73,7 +137,7 @@ export function SurveyStatCard({ label, percent }: { label: string; percent: num
className="h-2 relative overflow-hidden"
style={
{
'--bar-value': percent,
'--bar-value': renderedBarValue,
} as React.CSSProperties
}
>
@@ -88,35 +152,45 @@ export function SurveyStatCard({ label, percent }: { label: string; percent: num
}}
/>
{/* Filled portion of the bar */}
<div
className={`h-full relative bg-surface-100`}
style={{
width: `calc(max(0.5%, (var(--bar-value) / 100) * 100%))`,
clipPath: shouldAnimateBar ? 'inset(0 0 0 0)' : 'inset(0 100% 0 0)',
transition: `clip-path 0.5s steps(${Math.max(2, Math.floor((percent / 100) * 12))}, end)`,
}}
>
{/* Foreground pattern for the filled portion */}
{/* Filled portion of the bar (only when there is a value to show) */}
{hasValue && (
<div
className={`absolute inset-0 pointer-events-none ${accentBg}`}
className={`h-full relative bg-surface-100`}
style={{
maskImage: 'url("/images/state-of-startups/pattern-checker.svg")',
maskSize: '4px',
maskRepeat: 'repeat',
maskPosition: 'top left',
width: `calc(max(0.5%, (var(--bar-value) / 100) * 100%))`,
clipPath: shouldAnimateBar ? 'inset(0 0 0 0)' : 'inset(0 100% 0 0)',
transition: `clip-path 0.5s steps(${Math.max(2, Math.floor(((resolvedPercent ?? 0) / 100) * 12))}, end)`,
}}
/>
</div>
>
<div
className={`absolute inset-0 pointer-events-none ${accentBg}`}
style={{
maskImage: 'url("/images/state-of-startups/pattern-checker.svg")',
maskSize: '4px',
maskRepeat: 'repeat',
maskPosition: 'top left',
}}
/>
</div>
)}
</div>
{/* Text */}
<div className="flex flex-col gap-2">
<p
className={`md:-ml-1 md:mt-8 text-2xl md:text-6xl font-mono tracking-tight inline-block flex flex-row items-baseline ${hasAnimated ? accentText : 'text-foreground-muted'} transition-colors duration-1000`}
>
{displayValue}
<span className="md:text-4xl">%</span>
</p>
{hasValue ? (
<p
className={`md:-ml-1 md:mt-8 text-2xl md:text-6xl font-mono tracking-tight inline-block flex flex-row items-baseline ${hasAnimated ? accentText : 'text-foreground-muted'} transition-colors duration-1000`}
>
{displayValue}
<span className="md:text-4xl">%</span>
</p>
) : (
<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`}
>
—
</p>
)}
<p className="text-foreground-light text-sm text-balance md:mr-6">{label}</p>
</div>
</div>
@@ -0,0 +1,43 @@
'use client'
import { motion } from 'framer-motion'
import { cn } from 'ui'
import { SURVEY_YEARS, useYear, type SurveyYear } from './year-context'
export function YearToggle({ className }: { className?: string }) {
const { year, setYear } = useYear()
return (
<motion.div
layout
role="radiogroup"
aria-label="Survey year"
className={cn(
'inline-flex items-center rounded-full border border-overlay bg-overlay/80 p-0.5 font-mono uppercase tracking-widest backdrop-blur shadow-xl h-[30px] text-xs',
className
)}
>
{SURVEY_YEARS.map((option) => {
const active = option === year
return (
<button
key={option}
type="button"
role="radio"
aria-checked={active}
onClick={() => setYear(option as SurveyYear)}
className={cn(
'relative inline-flex h-full items-center rounded-full px-3 transition-colors',
active
? 'bg-foreground text-background'
: 'text-foreground-light hover:text-foreground'
)}
>
{option}
</button>
)
})}
</motion.div>
)
}
@@ -0,0 +1,33 @@
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,4 +1,4 @@
import { SurveyChart, buildWhereClause } from '../SurveyChart'
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateAICodingToolsSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters)
@@ -24,7 +24,7 @@ function generateAICodingToolsSQL(activeFilters: Record<string, string>) {
END AS technology_clean
FROM (
SELECT id, unnest(ai_coding_tools) AS technology
FROM responses_2025
FROM responses_2026
${whereClause}
) sub
)
@@ -1,4 +1,4 @@
import { SurveyChart, buildWhereClause } from '../SurveyChart'
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateAIModelsSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters)
@@ -23,7 +23,7 @@ function generateAIModelsSQL(activeFilters: Record<string, string>) {
END AS technology_clean
FROM (
SELECT id, unnest(ai_models_used) AS technology
FROM responses_2025
FROM responses_2026
${whereClause}
) sub
)
@@ -1,4 +1,4 @@
import { SurveyChart, buildWhereClause } from '../SurveyChart'
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateAcceleratorParticipationSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters, ['accelerator_participation IS NOT NULL'])
@@ -11,7 +11,7 @@ function generateAcceleratorParticipationSQL(activeFilters: Record<string, strin
WHEN accelerator_participation = 'Did not participate in an accelerator' THEN NULL
ELSE 'Other'
END AS accelerator_clean
FROM responses_2025
FROM responses_2026
${whereClause ? '\n ' + whereClause : ''}
)
SELECT
@@ -0,0 +1,48 @@
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,4 +1,4 @@
import { SurveyChart, buildWhereClause } from '../SurveyChart'
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateBiggestChallengeSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters, ['biggest_challenge IS NOT NULL'])
@@ -10,7 +10,7 @@ function generateBiggestChallengeSQL(activeFilters: Record<string, string>) {
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_2025
FROM responses_2026
${whereClause}
)
SELECT
@@ -0,0 +1,26 @@
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,4 +1,4 @@
import { SurveyChart, buildWhereClause } from '../SurveyChart'
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateDatabasesSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters)
@@ -20,7 +20,7 @@ function generateDatabasesSQL(activeFilters: Record<string, string>) {
END AS technology_clean
FROM (
SELECT id, unnest(databases) AS technology
FROM responses_2025
FROM responses_2026
${whereClause}
) sub
)
@@ -1,4 +1,4 @@
import { SurveyChart, buildWhereClause } from '../SurveyChart'
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateFundingStageSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters)
@@ -6,7 +6,7 @@ function generateFundingStageSQL(activeFilters: Record<string, string>) {
return `SELECT
funding_stage,
COUNT(*) AS total
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
FROM responses_2026${whereClause ? '\n' + whereClause : ''}
GROUP BY funding_stage
ORDER BY CASE
WHEN funding_stage = 'Bootstrapped' THEN 1
@@ -1,4 +1,4 @@
import { SurveyChart, buildWhereClause } from '../SurveyChart'
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateIndustrySQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters, ['industry IS NOT NULL'])
@@ -12,7 +12,7 @@ function generateIndustrySQL(activeFilters: Record<string, string>) {
WHEN industry IN ('SaaS', 'Dev tools', 'AI / ML', 'Consumer', 'Education', 'eCommerce', 'Fintech', 'Healthtech') THEN industry
ELSE 'Other'
END AS industry_clean
FROM responses_2025
FROM responses_2026
${whereClause}
)
SELECT
@@ -1,4 +1,4 @@
import { SurveyChart, buildWhereClause } from '../SurveyChart'
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateInitialPayingCustomersSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters)
@@ -21,7 +21,7 @@ function generateInitialPayingCustomersSQL(activeFilters: Record<string, string>
END AS source_clean
FROM (
SELECT id, unnest(initial_paying_customers) AS source
FROM responses_2025
FROM responses_2026
${whereClause}
) sub
)
@@ -1,4 +1,4 @@
import { SurveyChart, buildWhereClause } from '../SurveyChart'
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateLocationSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters)
@@ -6,7 +6,7 @@ function generateLocationSQL(activeFilters: Record<string, string>) {
return `SELECT
location,
COUNT(*) AS total
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
FROM responses_2026${whereClause ? '\n' + whereClause : ''}
GROUP BY location
ORDER BY total DESC;`
}
@@ -0,0 +1,26 @@
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,4 +1,4 @@
import { SurveyChart, buildWhereClause } from '../SurveyChart'
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateNewIdeasSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters)
@@ -23,7 +23,7 @@ function generateNewIdeasSQL(activeFilters: Record<string, string>) {
END AS avenue_clean
FROM (
SELECT id, unnest(new_ideas) AS avenue
FROM responses_2025
FROM responses_2026
${whereClause}
) sub
)
@@ -0,0 +1,48 @@
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,4 +1,4 @@
import { SurveyChart, buildWhereClause } from '../SurveyChart'
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateRegularSocialMediaUseSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters)
@@ -24,7 +24,7 @@ function generateRegularSocialMediaUseSQL(activeFilters: Record<string, string>)
END AS platform_clean
FROM (
SELECT id, unnest(regular_social_media_use) AS platform
FROM responses_2025
FROM responses_2026
${whereClause}
) sub
)
@@ -1,4 +1,4 @@
import { SurveyChart, buildWhereClause } from '../SurveyChart'
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateRoleSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters)
@@ -10,7 +10,7 @@ function generateRoleSQL(activeFilters: Record<string, string>) {
ELSE 'Other'
END AS role,
COUNT(*) AS total
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
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
@@ -1,4 +1,4 @@
import { SurveyChart, buildWhereClause } from '../SurveyChart'
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateSalesToolsSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters)
@@ -20,7 +20,7 @@ function generateSalesToolsSQL(activeFilters: Record<string, string>) {
END AS tool_clean
FROM (
SELECT id, unnest(sales_tools) AS tool
FROM responses_2025
FROM responses_2026
${whereClause}
) sub
)
@@ -1,4 +1,4 @@
import { SurveyChart, buildWhereClause } from '../SurveyChart'
import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateWorldOutlookSQL(activeFilters: Record<string, string>) {
const whereClause = buildWhereClause(activeFilters)
@@ -6,7 +6,7 @@ function generateWorldOutlookSQL(activeFilters: Record<string, string>) {
return `SELECT
world_outlook,
COUNT(*) AS total
FROM responses_2025${whereClause ? '\n' + whereClause : ''}
FROM responses_2026${whereClause ? '\n' + whereClause : ''}
GROUP BY world_outlook
ORDER BY total DESC;`
}
@@ -0,0 +1,141 @@
'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,37 @@
'use client'
import { createContext, useContext, useState, type ReactNode } from 'react'
export type SurveyYear = 2025 | 2026
export const SURVEY_YEARS: readonly SurveyYear[] = [2025, 2026] as const
interface YearContextValue {
year: SurveyYear
setYear: (year: SurveyYear) => void
}
const YearContext = createContext<YearContextValue | null>(null)
export function YearProvider({
children,
defaultYear = 2026,
}: {
children: ReactNode
defaultYear?: SurveyYear
}) {
const [year, setYear] = useState<SurveyYear>(defaultYear)
return <YearContext.Provider value={{ year, setYear }}>{children}</YearContext.Provider>
}
export function useYear() {
const ctx = useContext(YearContext)
if (!ctx) {
throw new Error('useYear must be used within a YearProvider')
}
return ctx
}
export function rpcNameForYear(baseName: string, year: SurveyYear) {
return year === 2026 ? `${baseName}_2026` : baseName
}
@@ -0,0 +1,215 @@
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)
)
}
+17 -3
View File
@@ -1,10 +1,16 @@
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 = false
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
? {
@@ -39,6 +45,14 @@ export const metadata: Metadata = SHOW_RESULTS
},
}
export default function StateOfStartupsPage() {
return SHOW_RESULTS ? <StateOfStartups2026Content /> : <RegisterContent />
export default async function StateOfStartupsPage() {
if (!SHOW_RESULTS) return <RegisterContent />
const [preloadedData, preloadedStats] = await Promise.all([
preloadSurveyData(),
preloadStatData(),
])
return (
<StateOfStartups2026Content preloadedData={preloadedData} preloadedStats={preloadedStats} />
)
}
File diff suppressed because it is too large. Load diff