mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 17:59:13 -04:00
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:
@@ -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 '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<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
|
||||
|
||||
@@ -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>>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
160
web/components/widgets/stat-distribution.tsx
Normal file
160
web/components/widgets/stat-distribution.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user