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