Add demographic distribution cards and backend support for /stats page

- Implemented demographic breakdowns for profile fields like gender, age, orientation, and more.
- Added `DistributionCard` component to render ranked bar lists for frontend visualization.
- Introduced backend aggregation logic for demographic stats with thresholds to ensure data anonymity and validity.
- Updated API response to include `demographics` field for frontend integration.
This commit is contained in:
MartinBraquet
2026-07-24 01:47:37 +02:00
parent 4a1b0dbfc3
commit 5645007ff8
4 changed files with 525 additions and 206 deletions

View File

@@ -1,7 +1,7 @@
import {getMessagesCount} from 'api/get-messages-count'
import {CountryCount} from 'common/stats'
import {CountryCount, DEMOGRAPHIC_FIELDS, DemographicField, Distribution} from 'common/stats'
import {HOUR_MS} from 'common/util/time'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init'
import {APIHandler} from './helpers/endpoint'
@@ -14,18 +14,110 @@ const CACHE_DURATION_MS = HOUR_MS
// would be payload nobody renders; `countryCount` still reports the full spread.
const TOP_COUNTRIES = 8
// Below this many respondents a field is not published at all. A distribution drawn from a handful of
// people is noise, and naming the one member in a rare category is the kind of soft de-anonymisation a
// public transparency page should not do. Mirrors the spirit of MIN_COUNTRIES on the frontend.
const MIN_RESPONSES = 4
// The long tail past this is dropped — these are small fixed enums, so the head carries the shape.
const TOP_PER_FIELD = 7
// One breakdown for one profile column, labelled and ordered on the frontend. `$1~` is pg-promise name
// escaping, so the column name is injected as a quoted identifier, never string-concatenated; the values
// only ever come from DEMOGRAPHIC_FIELDS, never from the request.
async function fieldDistribution(
pg: SupabaseDirectClient,
field: DemographicField,
multi: boolean,
): Promise<Distribution | null> {
const rows = multi
? await pg.manyOrNone(
`with answered as (
select id, unnest($1~) as v
from profiles
where $1~ is not null and array_length($1~, 1) > 0
)
select v as value,
count(*)::int as count,
(select count(distinct id)::int from answered) as base
from answered
where v is not null and v <> ''
group by v
order by count desc, v asc`,
[field],
)
: await pg.manyOrNone(
`select $1~ as value,
count(*)::int as count,
(sum(count(*)) over ())::int as base
from profiles
where $1~ is not null and $1~ <> ''
group by $1~
order by count desc, value asc`,
[field],
)
const base = rows[0]?.base ?? 0
if (base < MIN_RESPONSES) return null
return {
base,
multi,
items: rows.slice(0, TOP_PER_FIELD).map((r: any) => ({value: r.value, count: r.count})),
}
}
// Age is the one numeric field, so it is bucketed into ordinal ranges rather than grouped raw, and kept
// in age order instead of by size — a histogram, not a ranking.
async function ageDistribution(pg: SupabaseDirectClient): Promise<Distribution | null> {
const rows = await pg.manyOrNone(
`select bucket as value,
count(*)::int as count,
(sum(count(*)) over ())::int as base
from (
select case
when age < 25 then '1824'
when age < 35 then '2534'
when age < 45 then '3544'
when age < 55 then '4554'
else '55+'
end as bucket,
case
when age < 25 then 1
when age < 35 then 2
when age < 45 then 3
when age < 55 then 4
else 5
end as ord
from profiles
where age is not null and age >= 18
) t
group by bucket, ord
order by ord`,
)
const base = rows[0]?.base ?? 0
if (base < MIN_RESPONSES) return null
return {base, multi: false, items: rows.map((r: any) => ({value: r.value, count: r.count}))}
}
export const stats: APIHandler<'stats'> = async (_, _auth) => {
const now = Date.now()
// Return cached data if still valid
if (cachedData && now - cacheTimestamp < CACHE_DURATION_MS) {
console.log('cached stats')
console.log(cachedData)
return cachedData
}
const pg = createSupabaseDirectClient()
// Each profile-field breakdown is its own query; run them alongside the headline counts. All of it
// sits behind the hour-long cache, so this whole block runs at most once an hour regardless of traffic.
const demographicFields = Object.entries(DEMOGRAPHIC_FIELDS) as [
DemographicField,
{multi: boolean},
][]
const [
userCount,
profileCount,
@@ -34,6 +126,7 @@ export const stats: APIHandler<'stats'> = async (_, _auth) => {
conversationCount,
genderStats,
countryStats,
demographicResults,
] = await Promise.all([
pg.one(`SELECT COUNT(*)::int as count FROM users`),
pg.one(`SELECT COUNT(*)::int as count FROM profiles`),
@@ -56,8 +149,20 @@ export const stats: APIHandler<'stats'> = async (_, _auth) => {
group by country
order by count desc, country asc`,
),
Promise.all(
demographicFields.map(([field, {multi}]) =>
field === 'age' ? ageDistribution(pg) : fieldDistribution(pg, field, multi),
),
),
])
// Drop the fields that came back null (too few respondents) and key the rest by field name.
const demographics: Partial<Record<DemographicField, Distribution>> = {}
demographicFields.forEach(([field], i) => {
const dist = demographicResults[i]
if (dist) demographics[field] = dist
})
// Calculate gender ratios
const genderRatio: Record<string, number> = {}
let totalWithGender = 0
@@ -89,6 +194,7 @@ export const stats: APIHandler<'stats'> = async (_, _auth) => {
genderCounts: genderRatio,
countries: countries.slice(0, TOP_COUNTRIES),
countryCount: countries.length,
demographics,
}
// Update cache

View File

@@ -4,6 +4,45 @@ export type CountryCount = {
count: number
}
// ─── Demographic distributions ──────────────────────────────────────────────
//
// The profile fields the /stats page breaks down ("who's on Compass"). Listed once here so the backend
// (which aggregates them) and the frontend (which labels and orders them) agree on the set and can't
// drift apart. `multi` marks the array-valued columns: their percentages are of *respondents*, not of
// the whole population, and don't sum to 100 because one profile can select several.
export const DEMOGRAPHIC_FIELDS = {
age: {multi: false},
gender: {multi: false},
education_level: {multi: false},
political_beliefs: {multi: true},
religion: {multi: true},
mbti: {multi: false},
diet: {multi: true},
ethnicity: {multi: true},
orientation: {multi: true},
pref_relation_styles: {multi: true},
relationship_status: {multi: true},
languages: {multi: true},
} as const
export type DemographicField = keyof typeof DEMOGRAPHIC_FIELDS
/** One bar of a distribution: a raw stored value (e.g. `'bachelors'`) and how many profiles have it. */
export type DistributionItem = {value: string; count: number}
export type Distribution = {
/**
* Profiles that answered this field — the denominator for every bar's percentage. For a multi-select
* field it is the number of *distinct* profiles with at least one selection, so the percentages read
* as "share of members who told us" rather than a fraction of a fraction.
*/
base: number
/** True for array-valued fields; percentages are of respondents and don't sum to 100. */
multi: boolean
/** Top values, most common first (age buckets are ordered by age instead). Long tail is dropped. */
items: DistributionItem[]
}
export type Stats = {
users: number
profiles: number
@@ -17,6 +56,12 @@ export type Stats = {
countries: CountryCount[]
/** Distinct countries represented, including any outside the `countries` top-N. */
countryCount: number
/**
* Per-field breakdowns of the member base. A field is omitted entirely when too few members answered
* it to publish (see the floor in the handler) — a distribution built on a handful of people is both
* noise and a soft privacy leak, so the card simply doesn't render rather than showing a weak bar.
*/
demographics: Partial<Record<DemographicField, Distribution>>
}
/**

View File

@@ -0,0 +1,160 @@
import clsx from 'clsx'
import {
EDUCATION_CHOICES,
INVERTED_DIET_CHOICES,
INVERTED_EDUCATION_CHOICES,
INVERTED_GENDERS,
INVERTED_LANGUAGE_CHOICES,
INVERTED_MBTI_CHOICES,
INVERTED_ORIENTATION_CHOICES,
INVERTED_POLITICAL_CHOICES,
INVERTED_RACE_CHOICES,
INVERTED_RELATIONSHIP_CHOICES,
INVERTED_RELATIONSHIP_STATUS_CHOICES,
INVERTED_RELIGION_CHOICES,
MBTI_TYPE_NAMES,
} from 'common/choices'
import {DemographicField, Distribution} from 'common/stats'
import {ComponentType, SVGProps} from 'react'
import {surface} from 'web/components/widgets/surface'
/**
* A single profile-field breakdown as a ranked bar list — the building block of the "Who's on Compass"
* section of /stats. Deliberately the same bar vocabulary as `country-spread.tsx`: a member scanning the
* page reads one distribution the same way as the next, so education, religion and languages all share a
* form and only the labels change.
*
* The raw stored values (`'bachelors'`, `'veg'`, `'intj'`) are turned into human labels here rather than
* on the wire, so the payload stays small and the label maps live next to the rest of the choice tables.
*
* Percentages are always of `dist.base` — the members who answered the field — not of the whole platform.
* For a multi-select field that base is the distinct answerers, so a member who picked three diets counts
* once in the denominator; that is why the multi-select bars can each be large yet not sum to 100%, and
* why the subtitle says so out loud.
*/
type IconType = ComponentType<SVGProps<SVGSVGElement>>
// value → English label, per field. Missing fields (age buckets are already human-readable) fall through
// to the raw value. Same maps the search filters use, so a label never disagrees between the two places.
const LABELS: Partial<Record<DemographicField, Record<string, string>>> = {
gender: INVERTED_GENDERS,
education_level: INVERTED_EDUCATION_CHOICES,
political_beliefs: INVERTED_POLITICAL_CHOICES,
religion: INVERTED_RELIGION_CHOICES,
diet: INVERTED_DIET_CHOICES,
ethnicity: INVERTED_RACE_CHOICES,
orientation: INVERTED_ORIENTATION_CHOICES,
pref_relation_styles: INVERTED_RELATIONSHIP_CHOICES,
relationship_status: INVERTED_RELATIONSHIP_STATUS_CHOICES,
languages: INVERTED_LANGUAGE_CHOICES,
}
function capitalize(s: string) {
return s.charAt(0).toUpperCase() + s.slice(1)
}
// Education reads best in level order rather than by popularity — the levels are an ordinal ladder, so
// showing PhD above High school (the choices dict, reversed) is clearer than "whichever is most common
// first". Everything else keeps the count ranking the backend already sorted it into.
const EDUCATION_ORDER: string[] = Object.values(EDUCATION_CHOICES).reverse()
function orderItems(field: DemographicField, items: Distribution['items']): Distribution['items'] {
if (field !== 'education_level') return items
return [...items].sort(
(a, b) => EDUCATION_ORDER.indexOf(a.value) - EDUCATION_ORDER.indexOf(b.value),
)
}
function labelFor(field: DemographicField, value: string): string {
// MBTI reads as the four-letter code plus its archetype — "INTJ" alone is jargon; "INTJ Architect"
// tells the reader what it means without a second lookup.
if (field === 'mbti') {
const code = INVERTED_MBTI_CHOICES[value] ?? value.toUpperCase()
const name = MBTI_TYPE_NAMES[code]
return name ? `${code} · ${name}` : code
}
return LABELS[field]?.[value] ?? capitalize(value)
}
function DistRow({
label,
pct,
widthPct,
rank,
}: {
label: string
pct: number
widthPct: number
rank: number
}) {
return (
<div className="flex items-center gap-3">
<div className="w-24 shrink-0 truncate text-[13px] text-ink-700 sm:w-28" title={label}>
{label}
</div>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-canvas-200">
<div
className="h-full rounded-full bg-primary-500 transition-[width] duration-700 ease-out"
// The bar shortens the ranking gently down the list — the top value stays full-strength amber
// and each lower row loses a little, so the order reads even where two counts are close.
style={{width: `${widthPct}%`, opacity: Math.max(0.45, 1 - rank * 0.11)}}
/>
</div>
<div className="w-9 shrink-0 text-right text-xs tabular-nums text-ink-500">{pct}%</div>
</div>
)
}
export function DistributionCard({
field,
title,
icon: Icon,
dist,
}: {
field: DemographicField
title: string
icon: IconType
dist: Distribution | undefined
}) {
if (!dist || !dist.items.length) return null
// Most fields arrive count-sorted from the backend; education is re-laid into level order here.
const items = orderItems(field, dist.items)
// Bar widths are relative to the largest bar, not to the base: against the base a field where the top
// answer is only 20% would render as a row of stubs and the ranking would be unreadable. Percentages
// (the number on the right) are still of the base — only the drawn width is normalised to the leader.
// Taken as the true max, not items[0], because age buckets (and education) aren't ordered by size.
const max = Math.max(...items.map((it) => it.count))
return (
<div className={clsx(surface, 'flex h-full flex-col p-5 sm:p-6')}>
<div className="mb-4 flex items-center gap-3">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-primary-100 ring-1 ring-primary-200">
<Icon className="h-[18px] w-[18px] text-primary-600" strokeWidth={1.8} />
</div>
<div className="min-w-0">
<h3 className="text-sm font-bold leading-tight text-ink-900">{title}</h3>
<p className="text-xs text-ink-500">
{dist.multi
? `of ${dist.base.toLocaleString()} who shared`
: `${dist.base.toLocaleString()} shared`}
</p>
</div>
</div>
<div className="flex flex-col gap-2.5">
{items.map((it, i) => (
<DistRow
key={it.value}
label={labelFor(field, it.value)}
pct={Math.max(1, Math.round((it.count / dist.base) * 100))}
widthPct={Math.max(4, Math.round((it.count / max) * 100))}
rank={i}
/>
))}
</div>
</div>
)
}

View File

@@ -1,27 +1,37 @@
import {
AcademicCapIcon,
ArrowTrendingUpIcon,
BoltIcon,
BuildingLibraryIcon,
ChatBubbleLeftRightIcon,
CakeIcon,
CalendarDaysIcon,
ChatBubbleOvalLeftIcon,
CheckCircleIcon,
ClipboardDocumentListIcon,
EnvelopeIcon,
FingerPrintIcon,
FlagIcon,
GlobeAltIcon,
HandRaisedIcon,
PuzzlePieceIcon,
HeartIcon,
LanguageIcon,
LinkIcon,
MagnifyingGlassIcon,
QuestionMarkCircleIcon,
ScaleIcon,
StarIcon,
UserIcon,
SunIcon,
UsersIcon,
} from '@heroicons/react/24/outline'
import clsx from 'clsx'
import {type Stats} from 'common/stats'
import {type DemographicField, type Stats} from 'common/stats'
import {ComponentType, ReactNode, SVGProps, useEffect, useState} from 'react'
import {SectionLabel} from 'web/components/about/section'
import {PageBase} from 'web/components/page-base'
import {SEO} from 'web/components/SEO'
import ChartMembers from 'web/components/widgets/charts'
import {CountrySpread, MIN_COUNTRIES} from 'web/components/widgets/country-spread'
import {Reveal} from 'web/components/widgets/reveal'
import {DistributionCard} from 'web/components/widgets/stat-distribution'
import {eyebrow, Section, surface, surfaceHover} from 'web/components/widgets/surface'
import {api} from 'web/lib/api'
import {useT} from 'web/lib/locale'
import {getCount} from 'web/lib/supabase/users'
@@ -35,11 +45,9 @@ interface StatCardProps {
label: string
icon: IconType
accent?: 'amber' | 'sage' | 'muted'
large?: boolean
}
interface StatGroupProps {
icon: IconType
title: string
/** Optional block sharing the group's row — the right column on large screens. */
aside?: ReactNode
@@ -56,44 +64,33 @@ function formatNumber(n: number): string {
// ─── Stat Card ────────────────────────────────────────────────────────────────
function StatCard({value, label, icon: Icon, accent = 'amber', large}: StatCardProps) {
function StatCard({value, label, icon: Icon, accent = 'amber'}: StatCardProps) {
if (value === null || value === undefined || value === 0) return null
const formatted = typeof value === 'number' ? formatNumber(value) : value
const accentClasses = {
amber: 'text-primary-500',
sage: 'text-green-500',
muted: 'text-ink-500',
amber: 'text-primary-600',
sage: 'text-green-600',
muted: 'text-ink-700',
}
return (
<div
className="
group relative overflow-hidden
bg-canvas-50 border-[1.5px] border-canvas-200 rounded-2xl p-6
transition-all duration-[120ms] ease-in
hover:shadow-[0_10px_30px_rgba(44,36,22,0.09)]
hover:border-primary-500
"
>
<div className="flex items-start justify-between mb-3">
<div className="w-9 h-9 rounded-lg bg-primary-100 border border-primary-200 flex items-center justify-center flex-shrink-0">
<Icon className="w-[18px] h-[18px] text-primary-600" strokeWidth={1.8} />
</div>
<div className={clsx(surface, surfaceHover, 'group p-5 sm:p-6')}>
<div className="mb-3 flex h-9 w-9 items-center justify-center rounded-lg bg-primary-100 ring-1 ring-primary-200">
<Icon className="h-[18px] w-[18px] text-primary-600" strokeWidth={1.8} />
</div>
<div
className={clsx(
'font-black tracking-tight leading-none mb-2',
large ? 'text-4xl' : 'text-3xl',
'mb-2 text-3xl font-black leading-none tracking-tight',
accentClasses[accent],
)}
>
{formatted}
</div>
<p className="text-xs font-semibold text-ink-500 uppercase tracking-wide leading-tight">
<p className="text-xs font-semibold uppercase leading-tight tracking-wide text-ink-500">
{label}
</p>
</div>
@@ -102,23 +99,17 @@ function StatCard({value, label, icon: Icon, accent = 'amber', large}: StatCardP
// ─── Stat Group ───────────────────────────────────────────────────────────────
function StatGroup({icon: Icon, title, aside, children}: StatGroupProps) {
function StatGroup({title, aside, children}: StatGroupProps) {
return (
<div className="mb-10">
<div className="flex items-center gap-3 mb-4">
<Icon className="w-4 h-4 text-primary-500" strokeWidth={1.8} />
<span className="text-[11px] font-bold tracking-[1.2px] uppercase text-ink-500">
{title}
</span>
<div className="flex-1 h-px bg-canvas-200" />
</div>
<>
<SectionLabel>{title}</SectionLabel>
{/* With an `aside`, the cards give up half the width and drop to a 2×2 block beside it on large
screens; without one they keep the full-width 4-up row. The aside stacks underneath below
`lg` rather than squeezing — its country bars need the horizontal room. */}
<div className={clsx('grid gap-3', aside && 'lg:grid-cols-2 items-start')}>
<div className={clsx('grid gap-3 sm:gap-4', aside && 'items-start lg:grid-cols-2')}>
<div
className={clsx(
'grid grid-cols-2 gap-3',
'grid grid-cols-2 gap-3 sm:gap-4',
aside ? 'md:grid-cols-2' : 'md:grid-cols-3 lg:grid-cols-4',
)}
>
@@ -126,7 +117,7 @@ function StatGroup({icon: Icon, title, aside, children}: StatGroupProps) {
</div>
{aside}
</div>
</div>
</>
)
}
@@ -135,18 +126,13 @@ function StatGroup({icon: Icon, title, aside, children}: StatGroupProps) {
function ChartCard() {
const t = useT()
return (
<div
className="
bg-canvas-50 border-[1.5px] border-canvas-200 rounded-2xl p-6 mb-10
shadow-[0_2px_8px_rgba(44,36,22,0.05)]
"
>
<div className="flex items-center gap-2 mb-1">
<div className="w-8 h-8 rounded-lg bg-primary-100 border border-primary-200 flex items-center justify-center">
<ArrowTrendingUpIcon className="w-4 h-4 text-primary-600" strokeWidth={1.8} />
<div className={clsx(surface, 'p-5 sm:p-6')}>
<div className="mb-1 flex items-center gap-2.5">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary-100 ring-1 ring-primary-200">
<ArrowTrendingUpIcon className="h-[18px] w-[18px] text-primary-600" strokeWidth={1.8} />
</div>
<div>
<h2 className="text-sm font-bold text-ink-900 leading-tight">
<h2 className="text-sm font-bold leading-tight text-ink-900">
{t('stats.chart.title', 'Member Growth')}
</h2>
<p className="text-xs text-ink-500">
@@ -159,16 +145,18 @@ function ChartCard() {
)
}
// ─── Highlight Row ────────────────────────────────────────────────────────────
// ─── Hero highlight band ────────────────────────────────────────────────────────
function HighlightRow({
members,
active,
messages,
countries,
}: {
members: number | null
active: number | null
messages: number | null | undefined
countries: number | null | undefined
}) {
const t = useT()
const items: {
@@ -195,44 +183,124 @@ function HighlightRow({
icon: EnvelopeIcon,
accent: 'amber' as const,
},
{
value: countries && countries > 1 ? countries : undefined,
label: t('stats.highlight.countries', 'Countries'),
icon: GlobeAltIcon,
accent: 'sage' as const,
},
].filter((i) => !!i.value)
if (!items.length) return null
return (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-10">
{items.map((item) => {
<div
className={clsx(
'grid grid-cols-2 gap-3 sm:gap-4',
items.length >= 4 ? 'lg:grid-cols-4' : 'sm:grid-cols-3',
)}
>
{items.map((item, i) => {
const Icon = item.icon
return (
<div
key={item.label}
className="
relative overflow-hidden
bg-canvas-950 rounded-2xl p-6
border-[1.5px] border-canvas-900
"
>
<div className="w-9 h-9 rounded-lg bg-canvas-900 flex items-center justify-center mb-4">
<Icon className="w-[18px] h-[18px] text-primary-400" strokeWidth={1.8} />
<Reveal key={item.label} delay={i * 60}>
<div className="relative h-full overflow-hidden rounded-2xl border-[1.5px] border-canvas-900 bg-canvas-950 p-5 sm:p-6">
<div
aria-hidden
className="pointer-events-none absolute -right-10 -top-10 h-32 w-32 rounded-full bg-primary-500/10 blur-2xl"
/>
<div className="relative mb-4 flex h-9 w-9 items-center justify-center rounded-lg bg-canvas-900">
<Icon className="h-[18px] w-[18px] text-primary-400" strokeWidth={1.8} />
</div>
<div
className={clsx(
'relative mb-2 text-3xl font-black leading-none tracking-tight sm:text-4xl',
item.accent === 'sage' ? 'text-green-500' : 'text-primary-400',
)}
>
{typeof item.value === 'number' ? formatNumber(item.value) : item.value}
</div>
<p className="relative text-xs font-semibold uppercase tracking-wide text-white/60">
{item.label}
</p>
</div>
<div
className={clsx(
'text-4xl font-black tracking-tight leading-none mb-2',
item.accent === 'sage' ? 'text-green-500' : 'text-primary-400',
)}
>
{typeof item.value === 'number' ? formatNumber(item.value) : item.value}
</div>
<p className="text-xs font-semibold text-white/60 uppercase tracking-wide">
{item.label}
</p>
</div>
</Reveal>
)
})}
</div>
)
}
// ─── Demographics ───────────────────────────────────────────────────────────────
/**
* The order and chrome of the "Who's on Compass" cards. The data (and whether a field appears at all)
* comes from the `stats` payload — a field the backend withheld for too few respondents simply has no
* entry here, and its `DistributionCard` renders nothing. Gender leads (with the country card beside it,
* inserted separately below), then the rest run roughly most-universal first: nearly everyone has an age
* and an education; a language or relationship-status breakdown is a deeper cut.
*/
const DEMOGRAPHIC_CARDS: {field: DemographicField; title: string; icon: IconType}[] = [
{field: 'gender', title: 'Gender', icon: ScaleIcon},
{field: 'age', title: 'Age', icon: CalendarDaysIcon},
{field: 'pref_relation_styles', title: 'Looking for', icon: MagnifyingGlassIcon},
{field: 'orientation', title: 'Orientation', icon: HeartIcon},
{field: 'relationship_status', title: 'Relationship status', icon: LinkIcon},
{field: 'ethnicity', title: 'Ethnicity', icon: GlobeAltIcon},
{field: 'education_level', title: 'Education', icon: AcademicCapIcon},
{field: 'political_beliefs', title: 'Politics', icon: FlagIcon},
{field: 'religion', title: 'Religion & spirituality', icon: SunIcon},
{field: 'diet', title: 'Diet', icon: CakeIcon},
{field: 'mbti', title: 'Personality (MBTI)', icon: FingerPrintIcon},
{field: 'languages', title: 'Languages spoken', icon: LanguageIcon},
]
// This section absorbed what used to be the standalone "Community" block: the gender split and the
// country spread are just two more ways of answering "who's on Compass", so they sit in the same grid as
// the profile-field breakdowns rather than in a section of their own. The bare member/active/endorsement
// counts that lived there are dropped — the hero band above already carries the headline totals.
function Demographics({stats}: {stats: Stats}) {
const t = useT()
const {demographics, countries, countryCount} = stats
const cards = DEMOGRAPHIC_CARDS.filter((c) => demographics[c.field])
const hasCountries = (countries?.length ?? 0) >= MIN_COUNTRIES
if (!cards.length && !hasCountries) return null
return (
<Section>
<SectionLabel>{t('stats.demographics.label', "Who's on Compass")}</SectionLabel>
<p className="mb-6 max-w-2xl text-sm leading-relaxed text-ink-600">
{t(
'stats.demographics.subtitle',
'Self-reported by members, shown as a share of everyone who answered. Multi-choice fields can add up to more than 100%.',
)}
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3">
{/* Countries is a raw-count bar list rather than a percentage split (the full population per
country isn't in the payload), so it keeps `CountrySpread` instead of a `DistributionCard` —
wrapped in the same surface so it reads as one more block in the grid. */}
{hasCountries && (
<Reveal>
<div className={clsx(surface, 'flex h-full flex-col p-5 sm:p-6')}>
<CountrySpread countries={countries} countryCount={countryCount} />
</div>
</Reveal>
)}
{cards.map((c, i) => (
<Reveal key={c.field} delay={((i + (hasCountries ? 1 : 0)) % 3) * 70}>
<DistributionCard
field={c.field}
title={t(`stats.demographics.field.${c.field}`, c.title)}
icon={c.icon}
dist={demographics[c.field]}
/>
</Reveal>
))}
</div>
</Section>
)
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function Stats() {
@@ -276,12 +344,6 @@ export default function Stats() {
void load()
}, [])
const hasCountrySpread = (statsData?.countries?.length ?? 0) >= MIN_COUNTRIES
const genderRatioLabel = statsData?.genderRatio
? `${statsData.genderRatio.male ?? 0} / ${statsData.genderRatio.female ?? 0}`
: null
return (
<PageBase trackPageView={'stats'}>
<SEO
@@ -293,34 +355,31 @@ export default function Stats() {
url="/stats"
/>
<div className="max-w-4xl mx-auto px-6 py-12 pb-20">
<div className="mx-auto max-w-6xl px-5 pb-24 pt-12 sm:px-8">
{/* ── Page header ── */}
<div className="mb-10">
<p className="text-xs font-bold tracking-[1.5px] uppercase text-primary-500 mb-3">
<p className={clsx(eyebrow, 'mb-4 text-primary-700')}>
{t('stats.eyebrow', 'Transparency')}
</p>
<h1 className="text-[clamp(28px,4vw,40px)] font-black text-ink-900 tracking-tight leading-tight mb-3">
<h1 className="mb-4 text-[clamp(30px,5vw,48px)] font-black leading-[1.08] tracking-tight text-ink-900">
{t('stats.title', 'Growth & Stats')}
</h1>
<p className="text-lg text-ink-500 max-w-3xl leading-relaxed">
<p className="max-w-2xl text-lg leading-relaxed text-ink-600">
{t(
'stats.subtitle',
"Real numbers. No spin. Compass is built in the open — here's exactly how we're growing.",
"Real numbers. No spin. Compass is built in the open — here's exactly who we are and how we're growing.",
)}
</p>
</div>
{/* ── Loading skeleton ── */}
{loading && (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mb-10">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4">
{Array.from({length: 6}).map((_, i) => (
<div
key={i}
className="bg-canvas-50 border-[1.5px] border-canvas-200 rounded-2xl p-6 animate-pulse"
>
<div className="w-9 h-9 rounded-lg bg-canvas-200 mb-4" />
<div className="h-8 w-20 bg-canvas-200 rounded mb-2" />
<div className="h-3 w-24 bg-canvas-200 rounded" />
<div key={i} className={clsx(surface, 'animate-pulse p-6')}>
<div className="mb-4 h-9 w-9 rounded-lg bg-canvas-200" />
<div className="mb-2 h-8 w-20 rounded bg-canvas-200" />
<div className="h-3 w-24 rounded bg-canvas-200" />
</div>
))}
</div>
@@ -328,127 +387,76 @@ export default function Stats() {
{!loading && (
<>
{/* ── Hero highlight row ── */}
{/* ── Hero highlight band ── */}
<HighlightRow
members={data.profiles}
active={data.active_members}
messages={statsData?.messages}
countries={statsData?.countryCount}
/>
{/* ── Growth chart ── */}
<ChartCard />
{/* ── Who's on Compass (gender, countries + demographic breakdowns) ── */}
{statsData && <Demographics stats={statsData} />}
{/* ── Community ── */}
{/* "Where members are" moved here from the about page, and sits inside Community rather
than in a section of its own: it answers "who are the members" like the four numbers
beside it, just split by place instead of counted. Fed from the payload this page
already fetched, so it does not fire a second `stats` request. Gated on the same
threshold the component uses: dropping the aside gives the four cards the full width
back, where merely hiding it would leave half the row blank. */}
<StatGroup
icon={UsersIcon}
title={t('stats.group.community', 'Community')}
aside={
hasCountrySpread && (
<div
className="
bg-canvas-50 border-[1.5px] border-canvas-200 rounded-2xl p-6
shadow-[0_2px_8px_rgba(44,36,22,0.05)]
"
>
<CountrySpread
countries={statsData?.countries}
countryCount={statsData?.countryCount}
/>
</div>
)
}
>
<StatCard
value={data.profiles}
label={t('stats.members', 'Members')}
icon={UserIcon}
accent="amber"
/>
<StatCard
value={data.active_members}
label={t('stats.active_members', 'Active (last month)')}
icon={BoltIcon}
accent="sage"
/>
<StatCard
value={genderRatioLabel}
label={t('stats.gender_ratio', 'Male / Female')}
icon={ScaleIcon}
accent="muted"
/>
<StatCard
value={data.profile_comments}
label={t('stats.endorsements', 'Endorsements')}
icon={StarIcon}
accent="amber"
/>
</StatGroup>
{/* ── Growth chart ── */}
<Section>
<SectionLabel>{t('stats.group.growth', 'Growth over time')}</SectionLabel>
<ChartCard />
</Section>
{/* ── Conversations ── */}
<StatGroup
icon={ChatBubbleLeftRightIcon}
title={t('stats.group.conversations', 'Conversations')}
>
<StatCard
value={data.private_user_message_channels}
label={t('stats.discussions', 'Discussions')}
icon={ChatBubbleOvalLeftIcon}
accent="amber"
/>
<StatCard
value={statsData?.messages}
label={t('stats.messages', 'Messages')}
icon={EnvelopeIcon}
accent="sage"
/>
</StatGroup>
<Section>
<StatGroup title={t('stats.group.conversations', 'Conversations')}>
<StatCard
value={data.private_user_message_channels}
label={t('stats.discussions', 'Discussions')}
icon={ChatBubbleOvalLeftIcon}
accent="amber"
/>
<StatCard
value={statsData?.messages}
label={t('stats.messages', 'Messages')}
icon={EnvelopeIcon}
accent="sage"
/>
</StatGroup>
</Section>
{/* ── Compatibility ── */}
<StatGroup
icon={PuzzlePieceIcon}
title={t('stats.group.compatibility', 'Compatibility')}
>
<StatCard
value={data.compatibility_prompts}
label={t('stats.compatibility_prompts', 'Prompts Created')}
icon={QuestionMarkCircleIcon}
accent="amber"
/>
<StatCard
value={data.compatibility_answers}
label={t('stats.prompts_answered', 'Prompts Answered')}
icon={CheckCircleIcon}
accent="sage"
/>
</StatGroup>
<Section>
<StatGroup title={t('stats.group.compatibility', 'Compatibility')}>
<StatCard
value={data.compatibility_prompts}
label={t('stats.compatibility_prompts', 'Prompts Created')}
icon={QuestionMarkCircleIcon}
accent="amber"
/>
<StatCard
value={data.compatibility_answers}
label={t('stats.prompts_answered', 'Prompts Answered')}
icon={CheckCircleIcon}
accent="sage"
/>
</StatGroup>
</Section>
{/* ── Democracy ── */}
<StatGroup icon={BuildingLibraryIcon} title={t('stats.group.democracy', 'Democracy')}>
<StatCard
value={data.votes}
label={t('stats.proposals', 'Proposals')}
icon={ClipboardDocumentListIcon}
accent="amber"
/>
<StatCard
value={data.vote_results}
label={t('stats.votes', 'Votes Cast')}
icon={HandRaisedIcon}
accent="sage"
/>
{/*<StatCard*/}
{/* value={data.bookmarked_searches}*/}
{/* label={t('stats.searches_bookmarked', 'Saved Searches')}*/}
{/* icon="🔖"*/}
{/* accent="muted"*/}
{/*/>*/}
</StatGroup>
<Section>
<StatGroup title={t('stats.group.democracy', 'Democracy')}>
<StatCard
value={data.votes}
label={t('stats.proposals', 'Proposals')}
icon={ClipboardDocumentListIcon}
accent="amber"
/>
<StatCard
value={data.vote_results}
label={t('stats.votes', 'Votes Cast')}
icon={HandRaisedIcon}
accent="sage"
/>
</StatGroup>
</Section>
</>
)}
</div>