diff --git a/.prettierignore b/.prettierignore
index e1ba10c8806..46c5219234d 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -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
diff --git a/apps/www/app/state-of-startups/StateOfStartups2026Content.tsx b/apps/www/app/state-of-startups/StateOfStartups2026Content.tsx
index a8dcde34920..e6f9ab1b691 100644
--- a/apps/www/app/state-of-startups/StateOfStartups2026Content.tsx
+++ b/apps/www/app/state-of-startups/StateOfStartups2026Content.tsx
@@ -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
@@ -29,15 +36,8 @@ function FloatingTableOfContents({
const currentChapter = pageData.pageChapters[activeChapter - 1]
return (
-
-
+
)
}
-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 (
-
-
- {showFloatingToc && (
-
- )}
-
- {/* Intro section */}
-
-
-
-
- {/* Intro text */}
-
-
{pageData.heroSection.subheader}
-
{pageData.heroSection.cta}
-
+
+
+
+
+ {showFloatingToc && (
+
+
+
+
+
+
+ )}
+
+ {/* Intro section */}
+
+
+
+
+ {/* Intro text */}
+
+
{pageData.heroSection.subheader}
+
{pageData.heroSection.cta}
+
- {/* Table of contents */}
-
- {pageData.pageChapters.map((chapter, chapterIndex) => (
- -
-
-
- {chapterIndex + 1}
- {' '}
- {chapter.shortTitle}
-
-
- ))}
-
-
+ {/* Table of contents */}
+
+ {pageData.pageChapters.map((chapter, chapterIndex) => (
+ -
+
+
+ {chapterIndex + 1}
+ {' '}
+ {chapter.shortTitle}
+
+
+ ))}
+
+
-
-
+
+
- {pageData.pageChapters.map((chapter, chapterIndex) => (
-
- {chapter.sections.map((section, sectionIndex) => (
-
+ {pageData.pageChapters.map((chapter, chapterIndex) => (
+
+ {chapter.sections.map((section, sectionIndex) => (
+
+ ))}
+
))}
-
- ))}
-
-
-
+
+
+
+
+
)
}
diff --git a/apps/www/app/state-of-startups/components/SurveyChapterSection.tsx b/apps/www/app/state-of-startups/components/SurveyChapterSection.tsx
index 6d86ac72c20..97a2e6ffb23 100644
--- a/apps/www/app/state-of-startups/components/SurveyChapterSection.tsx
+++ b/apps/www/app/state-of-startups/components/SurveyChapterSection.tsx
@@ -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 " 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({
- {stats && (
-
+ {isAvailableForYear ? (
+ <>
+ {stats && (
+
+ )}
+
+ {charts?.map((chartName, index) => {
+ const ChartComponent = chartComponents[chartName as keyof typeof chartComponents]
+ return ChartComponent ? : null
+ })}
+
+ {rankedAnswersPair && }
+
+ {wordCloud && }
+
+ {summarizedAnswer && (
+
+ )}
+
+ {children}
+ >
+ ) : (
+
+
Not available in {year}.
+
+ This question was added to the {newInYear} survey. Switch the year toggle to{' '}
+ {newInYear} to view.
+
+
)}
-
- {charts?.map((chartName, index) => {
- const ChartComponent = chartComponents[chartName as keyof typeof chartComponents]
- return ChartComponent ? : null
- })}
-
- {rankedAnswersPair && }
-
- {wordCloud && }
-
- {summarizedAnswer && (
-
- )}
-
- {children}
diff --git a/apps/www/app/state-of-startups/components/SurveyChart.tsx b/apps/www/app/state-of-startups/components/SurveyChart.tsx
index 390ae8dc8df..2127edd0f66 100644
--- a/apps/www/app/state-of-startups/components/SurveyChart.tsx
+++ b/apps/www/app/state-of-startups/components/SurveyChart.tsx
@@ -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) => Record,
- activeFilters: Record
-) {
- const [chartData, setChartData] = useState([])
- const [isLoading, setIsLoading] = useState(true)
- const [error, setError] = useState(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
+ /**
+ * Base RPC name without a year suffix (e.g. `get_role_stats`). The chart
+ * appends `_` 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(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) => {
+ const buildFunctionParams = useCallback((filterValues: Record) => {
const params: Record = {}
-
- // 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(cached ?? [])
+ const [isLoading, setIsLoading] = useState(!cached && isAvailableForYear)
+ const [dataError, setDataError] = useState(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 (
- {dataError ? (
+ {!isAvailableForYear ? (
+
+
+ This question was added to the {newInYear} survey.
+
+
+ Switch the year to {newInYear} to view results.
+
+
+ ) : dataError ? (
) : view === 'chart' ? (
-
+
{chartData.length > 0 ? (
- {displayData.map((item, index) => (
-
+ {chartData.map((item, index) => (
+
{/* Text above the bar */}
{item.label}
@@ -413,11 +337,9 @@ export function SurveyChart({
{/* Filled portion of the bar */}
{/* Foreground pattern for the filled portion */}
@@ -466,29 +388,25 @@ export function SurveyChart({
{generateSQLQuery(activeFilters)}
) : (
- {`// Function call: ${functionName}(${JSON.stringify(buildFunctionParams(activeFilters), null, 2)})`}
+ {`// Function call: ${resolvedFunctionName}(${JSON.stringify(buildFunctionParams(activeFilters), null, 2)})`}
)}
)}
{/* Expand button overlay - only show for chart view */}
- {view === 'chart' &&
- !isExpanded &&
- !dataLoading &&
- !dataError &&
- chartData.length > 3 && (
-
-
-
- )}
+ {view === 'chart' && !isExpanded && !dataError && chartData.length > 3 && (
+
+
+
+ )}
diff --git a/apps/www/app/state-of-startups/components/SurveyStatCard.tsx b/apps/www/app/state-of-startups/components/SurveyStatCard.tsx
index 83070e4b23e..0c1f88e544d 100644
--- a/apps/www/app/state-of-startups/components/SurveyStatCard.tsx
+++ b/apps/www/app/state-of-startups/components/SurveyStatCard.tsx
@@ -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
(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 (
@@ -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 */}
-
- {/* Foreground pattern for the filled portion */}
+ {/* Filled portion of the bar (only when there is a value to show) */}
+ {hasValue && (
-
+ >
+
+
+ )}
{/* Text */}
-
- {displayValue}
- %
-
+ {hasValue ? (
+
+ {displayValue}
+ %
+
+ ) : (
+
+ —
+
+ )}
{label}
diff --git a/apps/www/app/state-of-startups/components/YearToggle.tsx b/apps/www/app/state-of-startups/components/YearToggle.tsx
new file mode 100644
index 00000000000..8de3ca3317f
--- /dev/null
+++ b/apps/www/app/state-of-startups/components/YearToggle.tsx
@@ -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 (
+
+ {SURVEY_YEARS.map((option) => {
+ const active = option === year
+ return (
+
+ )
+ })}
+
+ )
+}
diff --git a/apps/www/app/state-of-startups/components/charts/AICodebasePercentChart.tsx b/apps/www/app/state-of-startups/components/charts/AICodebasePercentChart.tsx
new file mode 100644
index 00000000000..a2b3a78f9f7
--- /dev/null
+++ b/apps/www/app/state-of-startups/components/charts/AICodebasePercentChart.tsx
@@ -0,0 +1,33 @@
+import { buildWhereClause, SurveyChart } from '../SurveyChart'
+
+function generateAICodebasePercentSQL(activeFilters: Record
) {
+ 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 (
+
+ )
+}
diff --git a/apps/www/app/state-of-startups/components/charts/AICodingToolsChart.tsx b/apps/www/app/state-of-startups/components/charts/AICodingToolsChart.tsx
index 66e8acc83f8..4908714d640 100644
--- a/apps/www/app/state-of-startups/components/charts/AICodingToolsChart.tsx
+++ b/apps/www/app/state-of-startups/components/charts/AICodingToolsChart.tsx
@@ -1,4 +1,4 @@
-import { SurveyChart, buildWhereClause } from '../SurveyChart'
+import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateAICodingToolsSQL(activeFilters: Record) {
const whereClause = buildWhereClause(activeFilters)
@@ -24,7 +24,7 @@ function generateAICodingToolsSQL(activeFilters: Record) {
END AS technology_clean
FROM (
SELECT id, unnest(ai_coding_tools) AS technology
- FROM responses_2025
+ FROM responses_2026
${whereClause}
) sub
)
diff --git a/apps/www/app/state-of-startups/components/charts/AIModelsChart.tsx b/apps/www/app/state-of-startups/components/charts/AIModelsChart.tsx
index a7dbee1367c..897bbd1a3a9 100644
--- a/apps/www/app/state-of-startups/components/charts/AIModelsChart.tsx
+++ b/apps/www/app/state-of-startups/components/charts/AIModelsChart.tsx
@@ -1,4 +1,4 @@
-import { SurveyChart, buildWhereClause } from '../SurveyChart'
+import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateAIModelsSQL(activeFilters: Record) {
const whereClause = buildWhereClause(activeFilters)
@@ -23,7 +23,7 @@ function generateAIModelsSQL(activeFilters: Record) {
END AS technology_clean
FROM (
SELECT id, unnest(ai_models_used) AS technology
- FROM responses_2025
+ FROM responses_2026
${whereClause}
) sub
)
diff --git a/apps/www/app/state-of-startups/components/charts/AcceleratorParticipationChart.tsx b/apps/www/app/state-of-startups/components/charts/AcceleratorParticipationChart.tsx
index 6ee816aaf80..88a9e027824 100644
--- a/apps/www/app/state-of-startups/components/charts/AcceleratorParticipationChart.tsx
+++ b/apps/www/app/state-of-startups/components/charts/AcceleratorParticipationChart.tsx
@@ -1,4 +1,4 @@
-import { SurveyChart, buildWhereClause } from '../SurveyChart'
+import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateAcceleratorParticipationSQL(activeFilters: Record) {
const whereClause = buildWhereClause(activeFilters, ['accelerator_participation IS NOT NULL'])
@@ -11,7 +11,7 @@ function generateAcceleratorParticipationSQL(activeFilters: Record) {
+ 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 (
+
+ )
+}
diff --git a/apps/www/app/state-of-startups/components/charts/BiggestChallengeChart.tsx b/apps/www/app/state-of-startups/components/charts/BiggestChallengeChart.tsx
index 4a549ac61db..2337f098906 100644
--- a/apps/www/app/state-of-startups/components/charts/BiggestChallengeChart.tsx
+++ b/apps/www/app/state-of-startups/components/charts/BiggestChallengeChart.tsx
@@ -1,4 +1,4 @@
-import { SurveyChart, buildWhereClause } from '../SurveyChart'
+import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateBiggestChallengeSQL(activeFilters: Record) {
const whereClause = buildWhereClause(activeFilters, ['biggest_challenge IS NOT NULL'])
@@ -10,7 +10,7 @@ function generateBiggestChallengeSQL(activeFilters: Record) {
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
diff --git a/apps/www/app/state-of-startups/components/charts/BuildingAgentsChart.tsx b/apps/www/app/state-of-startups/components/charts/BuildingAgentsChart.tsx
new file mode 100644
index 00000000000..c278d1f848a
--- /dev/null
+++ b/apps/www/app/state-of-startups/components/charts/BuildingAgentsChart.tsx
@@ -0,0 +1,26 @@
+import { buildWhereClause, SurveyChart } from '../SurveyChart'
+
+function generateBuildingAgentsSQL(activeFilters: Record) {
+ 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 (
+
+ )
+}
diff --git a/apps/www/app/state-of-startups/components/charts/DatabasesChart.tsx b/apps/www/app/state-of-startups/components/charts/DatabasesChart.tsx
index e78e0f11f96..060f0f8a1bd 100644
--- a/apps/www/app/state-of-startups/components/charts/DatabasesChart.tsx
+++ b/apps/www/app/state-of-startups/components/charts/DatabasesChart.tsx
@@ -1,4 +1,4 @@
-import { SurveyChart, buildWhereClause } from '../SurveyChart'
+import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateDatabasesSQL(activeFilters: Record) {
const whereClause = buildWhereClause(activeFilters)
@@ -20,7 +20,7 @@ function generateDatabasesSQL(activeFilters: Record) {
END AS technology_clean
FROM (
SELECT id, unnest(databases) AS technology
- FROM responses_2025
+ FROM responses_2026
${whereClause}
) sub
)
diff --git a/apps/www/app/state-of-startups/components/charts/FundingStageChart.tsx b/apps/www/app/state-of-startups/components/charts/FundingStageChart.tsx
index fef480e165f..ddec045d03d 100644
--- a/apps/www/app/state-of-startups/components/charts/FundingStageChart.tsx
+++ b/apps/www/app/state-of-startups/components/charts/FundingStageChart.tsx
@@ -1,4 +1,4 @@
-import { SurveyChart, buildWhereClause } from '../SurveyChart'
+import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateFundingStageSQL(activeFilters: Record) {
const whereClause = buildWhereClause(activeFilters)
@@ -6,7 +6,7 @@ function generateFundingStageSQL(activeFilters: Record) {
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
diff --git a/apps/www/app/state-of-startups/components/charts/IndustryChart.tsx b/apps/www/app/state-of-startups/components/charts/IndustryChart.tsx
index 15d4d46df22..bc69b2fa076 100644
--- a/apps/www/app/state-of-startups/components/charts/IndustryChart.tsx
+++ b/apps/www/app/state-of-startups/components/charts/IndustryChart.tsx
@@ -1,4 +1,4 @@
-import { SurveyChart, buildWhereClause } from '../SurveyChart'
+import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateIndustrySQL(activeFilters: Record) {
const whereClause = buildWhereClause(activeFilters, ['industry IS NOT NULL'])
@@ -12,7 +12,7 @@ function generateIndustrySQL(activeFilters: Record) {
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
diff --git a/apps/www/app/state-of-startups/components/charts/InitialPayingCustomersChart.tsx b/apps/www/app/state-of-startups/components/charts/InitialPayingCustomersChart.tsx
index 35cdeb5a365..f60dd398832 100644
--- a/apps/www/app/state-of-startups/components/charts/InitialPayingCustomersChart.tsx
+++ b/apps/www/app/state-of-startups/components/charts/InitialPayingCustomersChart.tsx
@@ -1,4 +1,4 @@
-import { SurveyChart, buildWhereClause } from '../SurveyChart'
+import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateInitialPayingCustomersSQL(activeFilters: Record) {
const whereClause = buildWhereClause(activeFilters)
@@ -21,7 +21,7 @@ function generateInitialPayingCustomersSQL(activeFilters: Record
END AS source_clean
FROM (
SELECT id, unnest(initial_paying_customers) AS source
- FROM responses_2025
+ FROM responses_2026
${whereClause}
) sub
)
diff --git a/apps/www/app/state-of-startups/components/charts/LocationChart.tsx b/apps/www/app/state-of-startups/components/charts/LocationChart.tsx
index 2bec94cf5db..877233d7acb 100644
--- a/apps/www/app/state-of-startups/components/charts/LocationChart.tsx
+++ b/apps/www/app/state-of-startups/components/charts/LocationChart.tsx
@@ -1,4 +1,4 @@
-import { SurveyChart, buildWhereClause } from '../SurveyChart'
+import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateLocationSQL(activeFilters: Record) {
const whereClause = buildWhereClause(activeFilters)
@@ -6,7 +6,7 @@ function generateLocationSQL(activeFilters: Record) {
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;`
}
diff --git a/apps/www/app/state-of-startups/components/charts/MCPAdoptionChart.tsx b/apps/www/app/state-of-startups/components/charts/MCPAdoptionChart.tsx
new file mode 100644
index 00000000000..eddd45e4855
--- /dev/null
+++ b/apps/www/app/state-of-startups/components/charts/MCPAdoptionChart.tsx
@@ -0,0 +1,26 @@
+import { buildWhereClause, SurveyChart } from '../SurveyChart'
+
+function generateMCPAdoptionSQL(activeFilters: Record) {
+ 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 (
+
+ )
+}
diff --git a/apps/www/app/state-of-startups/components/charts/NewIdeasChart.tsx b/apps/www/app/state-of-startups/components/charts/NewIdeasChart.tsx
index 6be14f42f1e..da8ebc33666 100644
--- a/apps/www/app/state-of-startups/components/charts/NewIdeasChart.tsx
+++ b/apps/www/app/state-of-startups/components/charts/NewIdeasChart.tsx
@@ -1,4 +1,4 @@
-import { SurveyChart, buildWhereClause } from '../SurveyChart'
+import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateNewIdeasSQL(activeFilters: Record) {
const whereClause = buildWhereClause(activeFilters)
@@ -23,7 +23,7 @@ function generateNewIdeasSQL(activeFilters: Record) {
END AS avenue_clean
FROM (
SELECT id, unnest(new_ideas) AS avenue
- FROM responses_2025
+ FROM responses_2026
${whereClause}
) sub
)
diff --git a/apps/www/app/state-of-startups/components/charts/PaidSubscriptionsChart.tsx b/apps/www/app/state-of-startups/components/charts/PaidSubscriptionsChart.tsx
new file mode 100644
index 00000000000..c7a3af2f1db
--- /dev/null
+++ b/apps/www/app/state-of-startups/components/charts/PaidSubscriptionsChart.tsx
@@ -0,0 +1,48 @@
+import { buildWhereClause, SurveyChart } from '../SurveyChart'
+
+function generatePaidSubscriptionsSQL(activeFilters: Record) {
+ 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 (
+
+ )
+}
diff --git a/apps/www/app/state-of-startups/components/charts/RegularSocialMediaUseChart.tsx b/apps/www/app/state-of-startups/components/charts/RegularSocialMediaUseChart.tsx
index fd4395bd629..8a8751b994b 100644
--- a/apps/www/app/state-of-startups/components/charts/RegularSocialMediaUseChart.tsx
+++ b/apps/www/app/state-of-startups/components/charts/RegularSocialMediaUseChart.tsx
@@ -1,4 +1,4 @@
-import { SurveyChart, buildWhereClause } from '../SurveyChart'
+import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateRegularSocialMediaUseSQL(activeFilters: Record) {
const whereClause = buildWhereClause(activeFilters)
@@ -24,7 +24,7 @@ function generateRegularSocialMediaUseSQL(activeFilters: Record)
END AS platform_clean
FROM (
SELECT id, unnest(regular_social_media_use) AS platform
- FROM responses_2025
+ FROM responses_2026
${whereClause}
) sub
)
diff --git a/apps/www/app/state-of-startups/components/charts/RoleChart.tsx b/apps/www/app/state-of-startups/components/charts/RoleChart.tsx
index 6ed52273489..a0d9b80a6be 100644
--- a/apps/www/app/state-of-startups/components/charts/RoleChart.tsx
+++ b/apps/www/app/state-of-startups/components/charts/RoleChart.tsx
@@ -1,4 +1,4 @@
-import { SurveyChart, buildWhereClause } from '../SurveyChart'
+import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateRoleSQL(activeFilters: Record) {
const whereClause = buildWhereClause(activeFilters)
@@ -10,7 +10,7 @@ function generateRoleSQL(activeFilters: Record) {
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
diff --git a/apps/www/app/state-of-startups/components/charts/SalesToolsChart.tsx b/apps/www/app/state-of-startups/components/charts/SalesToolsChart.tsx
index c65d9ba91d2..3e155a814dc 100644
--- a/apps/www/app/state-of-startups/components/charts/SalesToolsChart.tsx
+++ b/apps/www/app/state-of-startups/components/charts/SalesToolsChart.tsx
@@ -1,4 +1,4 @@
-import { SurveyChart, buildWhereClause } from '../SurveyChart'
+import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateSalesToolsSQL(activeFilters: Record) {
const whereClause = buildWhereClause(activeFilters)
@@ -20,7 +20,7 @@ function generateSalesToolsSQL(activeFilters: Record) {
END AS tool_clean
FROM (
SELECT id, unnest(sales_tools) AS tool
- FROM responses_2025
+ FROM responses_2026
${whereClause}
) sub
)
diff --git a/apps/www/app/state-of-startups/components/charts/WorldOutlookChart.tsx b/apps/www/app/state-of-startups/components/charts/WorldOutlookChart.tsx
index e820e355603..5ab29ac84a8 100644
--- a/apps/www/app/state-of-startups/components/charts/WorldOutlookChart.tsx
+++ b/apps/www/app/state-of-startups/components/charts/WorldOutlookChart.tsx
@@ -1,4 +1,4 @@
-import { SurveyChart, buildWhereClause } from '../SurveyChart'
+import { buildWhereClause, SurveyChart } from '../SurveyChart'
function generateWorldOutlookSQL(activeFilters: Record) {
const whereClause = buildWhereClause(activeFilters)
@@ -6,7 +6,7 @@ function generateWorldOutlookSQL(activeFilters: Record) {
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;`
}
diff --git a/apps/www/app/state-of-startups/components/survey-data-context.tsx b/apps/www/app/state-of-startups/components/survey-data-context.tsx
new file mode 100644
index 00000000000..ac291992360
--- /dev/null
+++ b/apps/www/app/state-of-startups/components/survey-data-context.tsx
@@ -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
+export type SurveyStatCache = Record
+
+interface SurveyDataContextValue {
+ /** Returns cached chart data for an (rpcName, params) combo, or undefined. */
+ get: (rpcName: string, params: Record) => 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) => Promise
+}
+
+const SurveyDataContext = createContext(null)
+
+const surveyClient = createClient(
+ process.env.NEXT_PUBLIC_SURVEY_SUPABASE_URL!,
+ process.env.NEXT_PUBLIC_SURVEY_SUPABASE_ANON_KEY!
+)
+
+function paramsKey(params: Record): 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 {
+ return `${rpcName}::${paramsKey(params)}`
+}
+
+export function SurveyDataProvider({
+ preloadedData,
+ preloadedStats,
+ children,
+}: {
+ preloadedData: SurveyDataCache
+ preloadedStats: SurveyStatCache
+ children: ReactNode
+}) {
+ const [cache, setCache] = useState