diff --git a/backend/api/src/stats.ts b/backend/api/src/stats.ts index 17330247..dcd99ab6 100644 --- a/backend/api/src/stats.ts +++ b/backend/api/src/stats.ts @@ -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 { + 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 { + 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 '18–24' + when age < 35 then '25–34' + when age < 45 then '35–44' + when age < 55 then '45–54' + 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> = {} + demographicFields.forEach(([field], i) => { + const dist = demographicResults[i] + if (dist) demographics[field] = dist + }) + // Calculate gender ratios const genderRatio: Record = {} 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 diff --git a/common/src/stats.ts b/common/src/stats.ts index ee619fde..86e6dbde 100644 --- a/common/src/stats.ts +++ b/common/src/stats.ts @@ -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> } /** diff --git a/web/components/widgets/stat-distribution.tsx b/web/components/widgets/stat-distribution.tsx new file mode 100644 index 00000000..75686690 --- /dev/null +++ b/web/components/widgets/stat-distribution.tsx @@ -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> + +// 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>> = { + 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 ( +
+
+ {label} +
+
+
+
+
{pct}%
+
+ ) +} + +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 ( +
+
+
+ +
+
+

{title}

+

+ {dist.multi + ? `of ${dist.base.toLocaleString()} who shared` + : `${dist.base.toLocaleString()} shared`} +

+
+
+ +
+ {items.map((it, i) => ( + + ))} +
+
+ ) +} diff --git a/web/pages/stats.tsx b/web/pages/stats.tsx index d018dc01..3ff72476 100644 --- a/web/pages/stats.tsx +++ b/web/pages/stats.tsx @@ -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 ( -
-
-
- -
+
+
+
{formatted}
-

+

{label}

@@ -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 ( -
-
- - - {title} - -
-
+ <> + {title} {/* 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. */} -
+
@@ -126,7 +117,7 @@ function StatGroup({icon: Icon, title, aside, children}: StatGroupProps) {
{aside}
-
+ ) } @@ -135,18 +126,13 @@ function StatGroup({icon: Icon, title, aside, children}: StatGroupProps) { function ChartCard() { const t = useT() return ( -
-
-
- +
+
+
+
-

+

{t('stats.chart.title', 'Member Growth')}

@@ -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 ( -

- {items.map((item) => { +
= 4 ? 'lg:grid-cols-4' : 'sm:grid-cols-3', + )} + > + {items.map((item, i) => { const Icon = item.icon return ( -
-
- + +
+
+
+ +
+
+ {typeof item.value === 'number' ? formatNumber(item.value) : item.value} +
+

+ {item.label} +

-
- {typeof item.value === 'number' ? formatNumber(item.value) : item.value} -
-

- {item.label} -

-
+
) })}
) } +// ─── 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 ( +
+ {t('stats.demographics.label', "Who's on Compass")} +

+ {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%.', + )} +

+
+ {/* 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 && ( + +
+ +
+
+ )} + {cards.map((c, i) => ( + + + + ))} +
+
+ ) +} + // ─── 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 ( -
+
{/* ── Page header ── */}
-

+

{t('stats.eyebrow', 'Transparency')}

-

+

{t('stats.title', 'Growth & Stats')}

-

+

{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.", )}

{/* ── Loading skeleton ── */} {loading && ( -
+
{Array.from({length: 6}).map((_, i) => ( -
-
-
-
+
+
+
+
))}
@@ -328,127 +387,76 @@ export default function Stats() { {!loading && ( <> - {/* ── Hero highlight row ── */} + {/* ── Hero highlight band ── */} - {/* ── Growth chart ── */} - + {/* ── Who's on Compass (gender, countries + demographic breakdowns) ── */} + {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. */} - - -
- ) - } - > - - - - - + {/* ── Growth chart ── */} +
+ {t('stats.group.growth', 'Growth over time')} + +
{/* ── Conversations ── */} - - - - +
+ + + + +
{/* ── Compatibility ── */} - - - - +
+ + + + +
{/* ── Democracy ── */} - - - - {/**/} - +
+ + + + +
)}