mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
1 parent
a7489eb691
commit
56c34d20f8
30 files changed
+1762
-620
No files matched your search
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user