mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 17:59:13 -04:00
Add StatDonut, WorldHeatmap, and support for full country lists on stats page
- Introduced `StatDonut` and `WorldHeatmap` components for improved data visualization. - Enhanced API to provide full country lists instead of a top-N subset for accurate map shading. - Removed hardcoded country truncation logic in favor of dynamic slicing in the frontend. - Updated age grouping logic to consolidate older age ranges and simplify backend queries. - Refactored stats page layout to reduce redundancy and improve clarity of data presentation.
This commit is contained in:
@@ -10,10 +10,6 @@ let cachedData: any = null
|
||||
let cacheTimestamp: number = 0
|
||||
const CACHE_DURATION_MS = HOUR_MS
|
||||
|
||||
// How many countries the breakdown returns. The about page shows a short ranked list, so a long tail
|
||||
// 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.
|
||||
@@ -78,15 +74,13 @@ async function ageDistribution(pg: SupabaseDirectClient): Promise<Distribution |
|
||||
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+'
|
||||
else '45+'
|
||||
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
|
||||
else 4
|
||||
end as ord
|
||||
from profiles
|
||||
where age is not null and age >= 18
|
||||
@@ -192,7 +186,9 @@ export const stats: APIHandler<'stats'> = async (_, _auth) => {
|
||||
conversations: conversationCount.count,
|
||||
genderRatio: genderPercentage,
|
||||
genderCounts: genderRatio,
|
||||
countries: countries.slice(0, TOP_COUNTRIES),
|
||||
// The full ranked list, not a top-N: the /stats world map colours every country with members, and
|
||||
// the ranked list under it slices its own head. Still a small payload (one row per country present).
|
||||
countries,
|
||||
countryCount: countries.length,
|
||||
demographics,
|
||||
}
|
||||
|
||||
@@ -52,9 +52,10 @@ export type Stats = {
|
||||
conversations: number
|
||||
genderRatio: Record<string, number>
|
||||
genderCounts: Record<string, number>
|
||||
/** Top countries by profile count, descending. Profiles with no country set are excluded. */
|
||||
/** Every country with at least one member, by profile count descending. Profiles with no country set
|
||||
* are excluded. The full list (not a top-N) so the map can shade every one; consumers slice their own. */
|
||||
countries: CountryCount[]
|
||||
/** Distinct countries represented, including any outside the `countries` top-N. */
|
||||
/** Distinct countries represented — same as `countries.length`, kept for callers that only need the number. */
|
||||
countryCount: number
|
||||
/**
|
||||
* Per-field breakdowns of the member base. A field is omitted entirely when too few members answered
|
||||
|
||||
@@ -389,9 +389,10 @@ export default function ChartMembers() {
|
||||
void load()
|
||||
}, [])
|
||||
|
||||
// Colors from palette
|
||||
// Colors from palette. The second series is a warm neutral taupe rather than a second loud hue —
|
||||
// "Completed" is a subset of "Total", so it reads as a quieter companion line, not a rival category.
|
||||
const AMBER = 'rgb(193 127 62)' // primary-500
|
||||
const SAGE = 'rgb(107 143 113)' // green-500
|
||||
const NEUTRAL = 'rgb(150 132 108)' // warm taupe (ink-600-ish), replaces the old green
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -425,8 +426,8 @@ export default function ChartMembers() {
|
||||
<stop offset="95%" stopColor={AMBER} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="gradSage" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={SAGE} stopOpacity={0.14} />
|
||||
<stop offset="95%" stopColor={SAGE} stopOpacity={0} />
|
||||
<stop offset="5%" stopColor={NEUTRAL} stopOpacity={0.14} />
|
||||
<stop offset="95%" stopColor={NEUTRAL} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
@@ -459,17 +460,17 @@ export default function ChartMembers() {
|
||||
|
||||
<Legend content={<CustomLegend />} />
|
||||
|
||||
{/* Completed (sage, behind) */}
|
||||
{/* Completed (warm neutral, behind) */}
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="profilesCompletedCreations"
|
||||
name={t('stats.with_bio', 'Completed')}
|
||||
stroke={SAGE}
|
||||
stroke={NEUTRAL}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 3"
|
||||
fill="url(#gradSage)"
|
||||
dot={false}
|
||||
activeDot={{r: 4, fill: SAGE, stroke: 'rgb(247 244 239)', strokeWidth: 2}}
|
||||
activeDot={{r: 4, fill: NEUTRAL, stroke: 'rgb(247 244 239)', strokeWidth: 2}}
|
||||
/>
|
||||
|
||||
{/* Total (amber, on top) */}
|
||||
|
||||
107
web/components/widgets/stat-donut.tsx
Normal file
107
web/components/widgets/stat-donut.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import clsx from 'clsx'
|
||||
import {ComponentType, SVGProps, useMemo} from 'react'
|
||||
import {Cell, Pie, PieChart, ResponsiveContainer} from 'recharts'
|
||||
import {surface} from 'web/components/widgets/surface'
|
||||
import {useThemeColors} from 'web/hooks/use-theme-colors'
|
||||
|
||||
/**
|
||||
* A donut for a small categorical or ordinal split (gender, age) — the counterpart to the bar-list
|
||||
* `DistributionCard`. It exists to break the page's rhythm: a run of identical bar lists reads as
|
||||
* wallpaper, so the two splits that are naturally few-valued get a different mark entirely.
|
||||
*
|
||||
* Identity is carried by the legend (label + count + %), not by colour alone — which is what lets the
|
||||
* slices stay on the brand's single warm ramp instead of pulling in loud categorical hues. Each segment
|
||||
* names its own colour token, so the colour follows the entity, never its rank.
|
||||
*/
|
||||
|
||||
type IconType = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
export type DonutSegment = {
|
||||
label: string
|
||||
value: number
|
||||
/** A Tailwind colour CSS variable name, e.g. `--color-primary-500`. */
|
||||
colorVar: string
|
||||
}
|
||||
|
||||
export function StatDonut({
|
||||
title,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
segments,
|
||||
}: {
|
||||
title: string
|
||||
subtitle: string
|
||||
icon: IconType
|
||||
segments: DonutSegment[]
|
||||
}) {
|
||||
const data = segments.filter((s) => s.value > 0)
|
||||
const total = data.reduce((sum, s) => sum + s.value, 0)
|
||||
|
||||
const varNames = useMemo(() => data.map((s) => s.colorVar), [data])
|
||||
const colors = useThemeColors(varNames)
|
||||
// The 2px gaps between slices are drawn in the surface colour so they read as clean cuts, not outlines.
|
||||
const [surfaceColor] = useThemeColors(SURFACE_VAR)
|
||||
|
||||
if (!total) return null
|
||||
|
||||
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">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative h-[128px] w-[128px] flex-shrink-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="value"
|
||||
nameKey="label"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={44}
|
||||
outerRadius={63}
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
paddingAngle={data.length > 1 ? 2 : 0}
|
||||
stroke={surfaceColor ?? undefined}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={colors[i] ?? 'transparent'} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<ul className="min-w-0 flex-1 space-y-2">
|
||||
{data.map((s, i) => (
|
||||
<li key={s.label} className="flex items-center gap-2.5">
|
||||
<span
|
||||
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
||||
style={{background: colors[i] ?? 'transparent'}}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] text-ink-700" title={s.label}>
|
||||
{s.label}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs font-semibold tabular-nums text-ink-900">
|
||||
{Math.round((s.value / total) * 100)}%
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SURFACE_VAR = ['--color-canvas-50']
|
||||
292
web/components/widgets/world-heatmap.tsx
Normal file
292
web/components/widgets/world-heatmap.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import clsx from 'clsx'
|
||||
import {CountryCount} from 'common/stats'
|
||||
import {geoPath, geoProjection} from 'd3-geo'
|
||||
import type {Feature, FeatureCollection, Geometry, MultiPoint} from 'geojson'
|
||||
import {useState} from 'react'
|
||||
import {feature} from 'topojson-client'
|
||||
import type {Topology} from 'topojson-specification'
|
||||
import {surface} from 'web/components/widgets/surface'
|
||||
import {useT} from 'web/lib/locale'
|
||||
import worldTopo from 'world-atlas/countries-110m.json'
|
||||
|
||||
/**
|
||||
* Where members are, as a choropleth heatmap (the counterpart to the ranked-bar `CountrySpread`).
|
||||
*
|
||||
* A world map is the one form that shows *reach* at a glance — a bar list ranks the top few, but only a
|
||||
* map makes "not tied to one country" legible instantly. Fill is a single warm ramp (sequential, the
|
||||
* right encoding for a magnitude), so it stays on-brand rather than pulling in map-primary blues; the
|
||||
* top few countries are named directly on the map, and any country is hoverable for its exact count.
|
||||
*
|
||||
* Geometry (world-atlas 110m) and the projection are computed once at module load — the extent is fixed
|
||||
* to the viewBox, so nothing here is per-render. Antarctica is dropped: it's always empty and eats a
|
||||
* third of the vertical space.
|
||||
*/
|
||||
|
||||
type CountryFeature = Feature<Geometry, {name: string}>
|
||||
|
||||
// Fixed internal coordinate system; the SVG scales to its container via viewBox.
|
||||
//
|
||||
// The projection is Miller cylindrical, built here from its raw formula because d3-geo ships Mercator and
|
||||
// equirectangular but not Miller. Picking it is what makes the map fill its box without looking wrong:
|
||||
// • Pseudocylindrical (NaturalEarth, EqualEarth) curves the edges inward toward the poles, pushing
|
||||
// Alaska a long way from the left border and leaving the corners empty.
|
||||
// • Equirectangular has straight edges but a *constant* vertical scale, so high-latitude land — Alaska,
|
||||
// Russia, Greenland — comes out horizontally smeared.
|
||||
// • Mercator cures the smearing but balloons Greenland and Russia.
|
||||
// Miller keeps straight vertical meridians, so every latitude spans the full width and Alaska sits against
|
||||
// the left border with New Zealand against the right, while its vertical scale grows gently toward the
|
||||
// poles so shapes stay natural. Latitudes are trimmed to the inhabited band, cropping empty polar water.
|
||||
const LAT_TOP = 84
|
||||
const LAT_BOTTOM = -57
|
||||
const VIEW_W = 820
|
||||
const MARGIN = 6
|
||||
const DEG = Math.PI / 180
|
||||
|
||||
/** Miller's vertical coordinate for a latitude in degrees (its raw y, in projection units). */
|
||||
function millerY(latDeg: number) {
|
||||
return 1.25 * Math.log(Math.tan(Math.PI / 4 + 0.4 * latDeg * DEG))
|
||||
}
|
||||
|
||||
function millerRaw(lambda: number, phi: number): [number, number] {
|
||||
return [lambda, 1.25 * Math.log(Math.tan(Math.PI / 4 + 0.4 * phi))]
|
||||
}
|
||||
millerRaw.invert = function (x: number, y: number): [number, number] {
|
||||
return [x, 2.5 * Math.atan(Math.exp(0.8 * y)) - 0.625 * Math.PI]
|
||||
}
|
||||
|
||||
// Height follows the window's own aspect (full 360° of longitude against the trimmed latitude band), so
|
||||
// the map fills the box with no letterboxing in either axis.
|
||||
const VIEW_H = Math.round(
|
||||
((VIEW_W - 2 * MARGIN) * (millerY(LAT_TOP) - millerY(LAT_BOTTOM))) / (2 * Math.PI) + 2 * MARGIN,
|
||||
)
|
||||
|
||||
// The four corners of that window. Fitting to these (rather than to the country shapes) is what makes the
|
||||
// map fill the box exactly: the shapes' own bounds are useless here because Fiji and Russia straddle the
|
||||
// antimeridian and each report a full-width bounding box.
|
||||
const WINDOW: MultiPoint = {
|
||||
type: 'MultiPoint',
|
||||
coordinates: [
|
||||
[-179.999, LAT_TOP],
|
||||
[179.999, LAT_TOP],
|
||||
[179.999, LAT_BOTTOM],
|
||||
[-179.999, LAT_BOTTOM],
|
||||
],
|
||||
}
|
||||
|
||||
// How many countries the ranked list under the map names before collapsing the rest into "+N more".
|
||||
const TOP_LIST = 16
|
||||
|
||||
const topology = worldTopo as unknown as Topology
|
||||
const collection = feature(topology, topology.objects.countries) as FeatureCollection<
|
||||
Geometry,
|
||||
{name: string}
|
||||
>
|
||||
|
||||
const FEATURES: CountryFeature[] = collection.features.filter(
|
||||
(f) => f.properties.name !== 'Antarctica',
|
||||
)
|
||||
|
||||
const projection = geoProjection(millerRaw).fitExtent(
|
||||
[
|
||||
[MARGIN, MARGIN],
|
||||
[VIEW_W - MARGIN, VIEW_H - MARGIN],
|
||||
],
|
||||
WINDOW,
|
||||
)
|
||||
const pathGen = geoPath(projection)
|
||||
|
||||
const SHAPES = FEATURES.map((f) => ({
|
||||
name: f.properties.name,
|
||||
d: pathGen(f) ?? '',
|
||||
centroid: pathGen.centroid(f) as [number, number],
|
||||
}))
|
||||
|
||||
// The stored `profiles.country` is an English display name; world-atlas uses its own short forms for a
|
||||
// handful of countries. Map our name → the atlas name for the common mismatches. Everything else matches
|
||||
// directly. Keys and the atlas lookup are both diacritic-stripped + lowercased, so accents never matter.
|
||||
const NAME_ALIASES: Record<string, string> = {
|
||||
'united states': 'united states of america',
|
||||
usa: 'united states of america',
|
||||
us: 'united states of america',
|
||||
'czech republic': 'czechia',
|
||||
'democratic republic of the congo': 'dem. rep. congo',
|
||||
'republic of the congo': 'congo',
|
||||
'bosnia and herzegovina': 'bosnia and herz.',
|
||||
'dominican republic': 'dominican rep.',
|
||||
'central african republic': 'central african rep.',
|
||||
'south sudan': 's. sudan',
|
||||
'equatorial guinea': 'eq. guinea',
|
||||
'solomon islands': 'solomon is.',
|
||||
'ivory coast': "cote d'ivoire",
|
||||
eswatini: 'eswatini',
|
||||
'north macedonia': 'macedonia',
|
||||
}
|
||||
|
||||
function normalize(name: string) {
|
||||
return name.normalize('NFD').replace(/[̀-ͯ]/g, '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
const SHAPE_BY_NORM = new Map(SHAPES.map((s) => [normalize(s.name), s.name]))
|
||||
|
||||
// Colour for a country with no members — a neutral land tone, deliberately outside the amber ramp so
|
||||
// that even a single member (the lightest ramp step) reads as clearly "someone is here" against it.
|
||||
const EMPTY_VAR = '--color-canvas-300'
|
||||
|
||||
// Sequential amber ramp, light → dark. It starts at a *saturated* step (primary-300), not a near-white
|
||||
// one, precisely so 1 member is visibly amber against the empty-land tone — the difference between nobody
|
||||
// and one person has to show. Referenced as CSS vars so the fills track the theme toggle without any JS.
|
||||
const RAMP = [
|
||||
'--color-primary-300',
|
||||
'--color-primary-400',
|
||||
'--color-primary-500',
|
||||
'--color-primary-600',
|
||||
'--color-primary-700',
|
||||
]
|
||||
|
||||
function rampVar(count: number, max: number): string {
|
||||
if (count <= 0) return EMPTY_VAR
|
||||
// sqrt so a long tail of 1-member countries doesn't all collapse onto the lightest step.
|
||||
const t = Math.sqrt(count / max)
|
||||
const i = Math.min(RAMP.length - 1, Math.max(0, Math.ceil(t * RAMP.length) - 1))
|
||||
return RAMP[i]
|
||||
}
|
||||
|
||||
export function WorldHeatmap({
|
||||
countries,
|
||||
countryCount,
|
||||
}: {
|
||||
countries?: CountryCount[]
|
||||
countryCount?: number
|
||||
}) {
|
||||
const t = useT()
|
||||
const [hover, setHover] = useState<{name: string; count: number; x: number; y: number} | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
const rows = countries ?? []
|
||||
// Resolve each data row to an atlas shape name, then sum onto it (two data spellings could collide).
|
||||
const countByShape = new Map<string, number>()
|
||||
for (const row of rows) {
|
||||
const norm = normalize(row.country)
|
||||
const shapeName = SHAPE_BY_NORM.get(NAME_ALIASES[norm] ?? norm)
|
||||
if (!shapeName) continue
|
||||
countByShape.set(shapeName, (countByShape.get(shapeName) ?? 0) + row.count)
|
||||
}
|
||||
|
||||
const max = Math.max(1, ...Array.from(countByShape.values()))
|
||||
const total = countryCount ?? rows.length
|
||||
|
||||
// The ranked list under the map — the country names and counts live here now, not on the map itself.
|
||||
const ranked = [...rows].sort((a, b) => b.count - a.count)
|
||||
const shownList = ranked.slice(0, TOP_LIST)
|
||||
const remaining = ranked.length - shownList.length
|
||||
|
||||
return (
|
||||
<div className={clsx(surface, 'flex h-full flex-col p-5 sm:p-6')}>
|
||||
<div className="mb-1 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold leading-tight text-ink-900">
|
||||
{t('stats.countries.title', 'Where members are')}
|
||||
</h3>
|
||||
<p className="text-xs text-ink-500">
|
||||
{total > 1
|
||||
? t('stats.countries.reach', '{count} countries and counting', {count: total})
|
||||
: t('stats.countries.subtitle', 'Compass is not tied to one city or one country.')}
|
||||
</p>
|
||||
</div>
|
||||
{/* Compact sequential legend: light = few, dark = many. */}
|
||||
<div className="hidden items-center gap-1.5 sm:flex">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-ink-500">
|
||||
Fewer
|
||||
</span>
|
||||
<div className="flex overflow-hidden rounded-full ring-1 ring-canvas-200">
|
||||
{RAMP.map((v) => (
|
||||
<span key={v} className="h-2.5 w-4" style={{background: `rgb(var(${v}))`}} />
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-ink-500">More</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bled out to the card's edges (cancelling its horizontal padding) so the map really does span the
|
||||
full width. The overlay tooltip is positioned inside this same box, so it scales with the map. */}
|
||||
<div className="relative -mx-5 mt-2 w-auto sm:-mx-6">
|
||||
<svg
|
||||
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
|
||||
className="h-auto w-full"
|
||||
role="img"
|
||||
aria-label={t('stats.countries.title', 'Where members are')}
|
||||
>
|
||||
{SHAPES.map((s) => {
|
||||
const count = countByShape.get(s.name) ?? 0
|
||||
return (
|
||||
<path
|
||||
key={s.name}
|
||||
d={s.d}
|
||||
style={{fill: `rgb(var(${rampVar(count, max)}))`}}
|
||||
stroke="rgb(var(--color-canvas-50))"
|
||||
strokeWidth={0.5}
|
||||
className={clsx(count > 0 && 'cursor-default')}
|
||||
onMouseEnter={
|
||||
count > 0
|
||||
? () =>
|
||||
setHover({
|
||||
name: s.name,
|
||||
count,
|
||||
x: (s.centroid[0] / VIEW_W) * 100,
|
||||
y: (s.centroid[1] / VIEW_H) * 100,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
onMouseLeave={count > 0 ? () => setHover(null) : undefined}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{hover && (
|
||||
<div
|
||||
className="pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-full rounded-lg bg-canvas-950 px-2.5 py-1.5 text-center shadow-lg"
|
||||
style={{left: `${hover.x}%`, top: `${hover.y}%`}}
|
||||
>
|
||||
<div className="text-xs font-bold leading-tight text-white">{hover.name}</div>
|
||||
<div className="text-[11px] font-semibold tabular-nums text-primary-400">
|
||||
{hover.count === 1
|
||||
? t('stats.countries.member_one', '1 member')
|
||||
: t('stats.countries.members', '{count} members', {count: hover.count})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Country names + counts, below the map. Each swatch matches that country's fill on the map, so
|
||||
the list and the map read as one object; the tail collapses into a "+N more". */}
|
||||
{!!shownList.length && (
|
||||
<div className="mt-4 flex flex-wrap gap-x-4 gap-y-2 border-t border-canvas-200/70 pt-4">
|
||||
{shownList.map((r) => {
|
||||
const norm = normalize(r.country)
|
||||
const resolved = SHAPE_BY_NORM.get(NAME_ALIASES[norm] ?? norm)
|
||||
const fillVar = resolved ? rampVar(r.count, max) : EMPTY_VAR
|
||||
return (
|
||||
<span key={r.country} className="inline-flex items-center gap-1.5 text-[13px]">
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-[3px] ring-1 ring-inset ring-black/5"
|
||||
style={{background: `rgb(var(${fillVar}))`}}
|
||||
/>
|
||||
<span className="text-ink-700">{r.country}</span>
|
||||
<span className="font-bold tabular-nums text-ink-900">
|
||||
{r.count.toLocaleString()}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{remaining > 0 && (
|
||||
<span className="text-[13px] text-ink-500">
|
||||
{t('stats.countries.remaining', '+ {count} more countries', {count: remaining})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
40
web/hooks/use-theme-colors.ts
Normal file
40
web/hooks/use-theme-colors.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
|
||||
/**
|
||||
* Resolves Tailwind colour CSS variables (e.g. `--color-primary-500`) to concrete `rgb(r g b)` strings,
|
||||
* and re-resolves when the theme flips — the app stamps `data-theme` / a class on `<html>`.
|
||||
*
|
||||
* Why it exists: recharts writes fills as SVG *presentation attributes*, which do not evaluate `var()`.
|
||||
* So a chart can't just reference `rgb(var(--color-primary-500))` the way normal CSS can; it needs the
|
||||
* value read out of the cascade. Raw SVG we render ourselves can use `var()` inline and doesn't need this.
|
||||
*
|
||||
* Runs entirely in an effect (no `getComputedStyle` on the server); components that use it for chart
|
||||
* fills should be client-only anyway. Before the first effect it returns `null`s, so guard on that.
|
||||
*/
|
||||
export function useThemeColors(varNames: string[]): (string | null)[] {
|
||||
const key = varNames.join(',')
|
||||
const [colors, setColors] = useState<(string | null)[]>(() => varNames.map(() => null))
|
||||
|
||||
useEffect(() => {
|
||||
const names = key.split(',')
|
||||
const resolve = () => {
|
||||
const styles = getComputedStyle(document.documentElement)
|
||||
setColors(
|
||||
names.map((n) => {
|
||||
const v = styles.getPropertyValue(n).trim()
|
||||
return v ? `rgb(${v})` : null
|
||||
}),
|
||||
)
|
||||
}
|
||||
resolve()
|
||||
// The theme toggle mutates <html> rather than firing an event, so watch the attributes it touches.
|
||||
const observer = new MutationObserver(resolve)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'data-theme'],
|
||||
})
|
||||
return () => observer.disconnect()
|
||||
}, [key])
|
||||
|
||||
return colors
|
||||
}
|
||||
@@ -49,6 +49,7 @@
|
||||
"@vercel/og": "0.5.20",
|
||||
"clsx": "2.1.1",
|
||||
"compressorjs": "1.1.1",
|
||||
"d3-geo": "3.1.1",
|
||||
"dayjs": "1.11.19",
|
||||
"firebase": "11.1.0",
|
||||
"heic2any": "0.0.4",
|
||||
@@ -66,7 +67,9 @@
|
||||
"react-icons": "5.5.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"recharts": "3.2.1",
|
||||
"tailwindcss": "^3.4"
|
||||
"tailwindcss": "^3.4",
|
||||
"topojson-client": "3.1.0",
|
||||
"world-atlas": "2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/android": "7.4.4",
|
||||
@@ -82,11 +85,12 @@
|
||||
"@types/node": "20.19.35",
|
||||
"@types/react": "19.2.3",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/topojson-client": "3.1.5",
|
||||
"autoprefixer": "10.4.27",
|
||||
"concurrently": "8.2.2",
|
||||
"cross-env": "7.0.3",
|
||||
"next-sitemap": "4.2.3",
|
||||
"postcss": "8.5.6",
|
||||
"postcss": "8.5.10",
|
||||
"tsc-files": "1.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,57 @@
|
||||
import {
|
||||
AcademicCapIcon,
|
||||
ArrowTrendingUpIcon,
|
||||
BoltIcon,
|
||||
BuildingLibraryIcon,
|
||||
CakeIcon,
|
||||
CalendarDaysIcon,
|
||||
ChatBubbleOvalLeftIcon,
|
||||
CheckCircleIcon,
|
||||
ClipboardDocumentListIcon,
|
||||
EnvelopeIcon,
|
||||
ChatBubbleLeftRightIcon,
|
||||
FingerPrintIcon,
|
||||
FlagIcon,
|
||||
GlobeAltIcon,
|
||||
HandRaisedIcon,
|
||||
HeartIcon,
|
||||
LanguageIcon,
|
||||
LinkIcon,
|
||||
MagnifyingGlassIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
ScaleIcon,
|
||||
PuzzlePieceIcon,
|
||||
SunIcon,
|
||||
UsersIcon,
|
||||
UserGroupIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import {type DemographicField, type Stats} from 'common/stats'
|
||||
import {ComponentType, ReactNode, SVGProps, useEffect, useState} from 'react'
|
||||
import {type DemographicField, type Distribution, type Stats} from 'common/stats'
|
||||
import dynamic from 'next/dynamic'
|
||||
import {ComponentType, 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 {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 {DonutSegment, StatDonut} from 'web/components/widgets/stat-donut'
|
||||
import {eyebrow, Section, surface} from 'web/components/widgets/surface'
|
||||
import {api} from 'web/lib/api'
|
||||
import {useT} from 'web/lib/locale'
|
||||
import {getCount} from 'web/lib/supabase/users'
|
||||
|
||||
// The map pulls in the world-atlas geometry (~100KB) plus d3-geo; code-split it so it never weighs on the
|
||||
// initial load, and never runs on the server — it's a client-only visual on an already client-fetched page.
|
||||
const WorldHeatmap = dynamic(
|
||||
() => import('web/components/widgets/world-heatmap').then((m) => m.WorldHeatmap),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className={clsx(surface, 'h-full min-h-[320px] animate-pulse p-6')}>
|
||||
<div className="h-4 w-40 rounded bg-canvas-200" />
|
||||
<div className="mt-6 h-[240px] rounded-xl bg-canvas-100" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type IconType = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
interface StatCardProps {
|
||||
value: string | number | null | undefined
|
||||
label: string
|
||||
icon: IconType
|
||||
accent?: 'amber' | 'sage' | 'muted'
|
||||
}
|
||||
|
||||
interface StatGroupProps {
|
||||
title: string
|
||||
/** Optional block sharing the group's row — the right column on large screens. */
|
||||
aside?: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
@@ -62,66 +60,72 @@ function formatNumber(n: number): string {
|
||||
return n.toLocaleString()
|
||||
}
|
||||
|
||||
// ─── Stat Card ────────────────────────────────────────────────────────────────
|
||||
// ─── Hero band ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({value, label, icon: Icon, accent = 'amber'}: StatCardProps) {
|
||||
if (value === null || value === undefined || value === 0) return null
|
||||
/**
|
||||
* The headline numbers as one dark, rule-divided band rather than a row of identical tiles — the tiles
|
||||
* were the start of the "wall of same block" the rest of the page also fights. A single feature surface
|
||||
* with an amber glow reads as an opening statement; it appears exactly once, so it never becomes texture.
|
||||
*/
|
||||
function HeroBand({
|
||||
members,
|
||||
active,
|
||||
countries,
|
||||
discussions,
|
||||
messages,
|
||||
}: {
|
||||
members: number | null
|
||||
active: number | null
|
||||
countries: number | null | undefined
|
||||
discussions: number | null
|
||||
messages: number | null | undefined
|
||||
}) {
|
||||
const t = useT()
|
||||
const items = [
|
||||
{value: members, label: t('stats.highlight.members', 'Members')},
|
||||
{value: active, label: t('stats.highlight.active', 'Active this month')},
|
||||
{
|
||||
value: countries && countries > 1 ? countries : undefined,
|
||||
label: t('stats.highlight.countries', 'Countries'),
|
||||
},
|
||||
{value: discussions, label: t('stats.discussions', 'Discussions')},
|
||||
{value: messages, label: t('stats.messages', 'Messages')},
|
||||
].filter((i) => !!i.value)
|
||||
|
||||
const formatted = typeof value === 'number' ? formatNumber(value) : value
|
||||
|
||||
const accentClasses = {
|
||||
amber: 'text-primary-600',
|
||||
sage: 'text-green-600',
|
||||
muted: 'text-ink-700',
|
||||
}
|
||||
if (!items.length) return null
|
||||
|
||||
return (
|
||||
<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="relative overflow-hidden rounded-3xl bg-canvas-950 px-6 py-8 sm:px-10 sm:py-10">
|
||||
<div
|
||||
className={clsx(
|
||||
'mb-2 text-3xl font-black leading-none tracking-tight',
|
||||
accentClasses[accent],
|
||||
)}
|
||||
>
|
||||
{formatted}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-16 -top-20 h-64 w-64 rounded-full bg-primary-500/20 blur-3xl"
|
||||
/>
|
||||
<div className="relative flex flex-wrap gap-x-10 gap-y-7 sm:gap-x-14">
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className={clsx(
|
||||
i === 0 && 'sm:border-r sm:border-white/10 sm:pr-14',
|
||||
i > 0 && 'min-w-[92px]',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'font-black leading-none tracking-tight tabular-nums text-primary-400',
|
||||
i === 0 ? 'text-5xl sm:text-6xl' : 'text-3xl sm:text-4xl',
|
||||
)}
|
||||
>
|
||||
{typeof item.value === 'number' ? formatNumber(item.value) : item.value}
|
||||
</div>
|
||||
<div className={clsx(eyebrow, 'mt-2.5 text-white/55')}>{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-xs font-semibold uppercase leading-tight tracking-wide text-ink-500">
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Stat Group ───────────────────────────────────────────────────────────────
|
||||
|
||||
function StatGroup({title, aside, children}: StatGroupProps) {
|
||||
return (
|
||||
<>
|
||||
<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 sm:gap-4', aside && 'items-start lg:grid-cols-2')}>
|
||||
<div
|
||||
className={clsx(
|
||||
'grid grid-cols-2 gap-3 sm:gap-4',
|
||||
aside ? 'md:grid-cols-2' : 'md:grid-cols-3 lg:grid-cols-4',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{aside}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Chart wrapper ────────────────────────────────────────────────────────────
|
||||
// ─── Growth chart ────────────────────────────────────────────────────────────
|
||||
|
||||
function ChartCard() {
|
||||
const t = useT()
|
||||
@@ -145,126 +149,179 @@ function ChartCard() {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Hero highlight band ────────────────────────────────────────────────────────
|
||||
// ─── Activity panel ──────────────────────────────────────────────────────────
|
||||
|
||||
function HighlightRow({
|
||||
members,
|
||||
active,
|
||||
messages,
|
||||
countries,
|
||||
/**
|
||||
* A small labelled list of related counts (conversations, compatibility, democracy). Replaces three
|
||||
* near-identical two-card sections with three compact panels in one row — same information, a fraction of
|
||||
* the vertical run, and no more repeated card grid at the foot of the page.
|
||||
*/
|
||||
function ActivityPanel({
|
||||
icon: Icon,
|
||||
title,
|
||||
rows,
|
||||
}: {
|
||||
members: number | null
|
||||
active: number | null
|
||||
icon: IconType
|
||||
title: string
|
||||
rows: {label: string; value: number | null | undefined}[]
|
||||
}) {
|
||||
const shown = rows.filter((r) => !!r.value)
|
||||
if (!shown.length) return null
|
||||
return (
|
||||
<div className={clsx(surface, 'flex h-full flex-col p-5 sm:p-6')}>
|
||||
<div className="mb-4 flex items-center gap-2.5">
|
||||
<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>
|
||||
<h3 className="text-sm font-bold text-ink-900">{title}</h3>
|
||||
</div>
|
||||
<dl className="space-y-3">
|
||||
{shown.map((r) => (
|
||||
<div
|
||||
key={r.label}
|
||||
className="flex items-baseline justify-between gap-3 border-t border-canvas-200/70 pt-3 first:border-0 first:pt-0"
|
||||
>
|
||||
<dt className="text-sm text-ink-600">{r.label}</dt>
|
||||
<dd className="text-lg font-black tabular-nums text-primary-600">
|
||||
{formatNumber(r.value as number)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The three activity panels as a row, with empty ones dropped entirely rather than left as blank grid
|
||||
* cells — a young platform may have votes but no messages yet, and a hole in the row reads as a bug.
|
||||
*/
|
||||
function ActivitySection({
|
||||
data,
|
||||
messages,
|
||||
}: {
|
||||
data: Record<string, number | null>
|
||||
messages: number | null | undefined
|
||||
countries: number | null | undefined
|
||||
}) {
|
||||
const t = useT()
|
||||
const items: {
|
||||
value: number | null | undefined
|
||||
label: string
|
||||
icon: IconType
|
||||
accent: 'amber' | 'sage'
|
||||
}[] = [
|
||||
const panels = [
|
||||
{
|
||||
value: members,
|
||||
label: t('stats.highlight.members', 'Total Members'),
|
||||
icon: UsersIcon,
|
||||
accent: 'amber' as const,
|
||||
icon: ChatBubbleLeftRightIcon,
|
||||
title: t('stats.group.conversations', 'Conversations'),
|
||||
rows: [
|
||||
{label: t('stats.discussions', 'Discussions'), value: data.private_user_message_channels},
|
||||
{label: t('stats.messages', 'Messages'), value: messages},
|
||||
],
|
||||
},
|
||||
{
|
||||
value: active,
|
||||
label: t('stats.highlight.active', 'Active (last month)'),
|
||||
icon: BoltIcon,
|
||||
accent: 'sage' as const,
|
||||
icon: PuzzlePieceIcon,
|
||||
title: t('stats.group.compatibility', 'Compatibility'),
|
||||
rows: [
|
||||
{
|
||||
label: t('stats.compatibility_prompts', 'Prompts created'),
|
||||
value: data.compatibility_prompts,
|
||||
},
|
||||
{label: t('stats.prompts_answered', 'Prompts answered'), value: data.compatibility_answers},
|
||||
],
|
||||
},
|
||||
{
|
||||
value: messages,
|
||||
label: t('stats.highlight.messages', 'Messages sent'),
|
||||
icon: EnvelopeIcon,
|
||||
accent: 'amber' as const,
|
||||
icon: BuildingLibraryIcon,
|
||||
title: t('stats.group.democracy', 'Democracy'),
|
||||
rows: [
|
||||
{label: t('stats.proposals', 'Proposals'), value: data.votes},
|
||||
{label: t('stats.votes', 'Votes cast'), value: data.vote_results},
|
||||
],
|
||||
},
|
||||
{
|
||||
value: countries && countries > 1 ? countries : undefined,
|
||||
label: t('stats.highlight.countries', 'Countries'),
|
||||
icon: GlobeAltIcon,
|
||||
accent: 'sage' as const,
|
||||
},
|
||||
].filter((i) => !!i.value)
|
||||
].filter((p) => p.rows.some((r) => !!r.value))
|
||||
|
||||
if (!items.length) return null
|
||||
if (!panels.length) return null
|
||||
|
||||
return (
|
||||
<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 (
|
||||
<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>
|
||||
<Section>
|
||||
<SectionLabel>{t('stats.group.activity', 'Activity & participation')}</SectionLabel>
|
||||
<div
|
||||
className={clsx(
|
||||
'grid grid-cols-1 gap-3 sm:gap-4',
|
||||
panels.length >= 3 ? 'sm:grid-cols-3' : 'sm:grid-cols-2',
|
||||
)}
|
||||
>
|
||||
{panels.map((p, i) => (
|
||||
<Reveal key={p.title} delay={i * 70}>
|
||||
<ActivityPanel icon={p.icon} title={p.title} rows={p.rows} />
|
||||
</Reveal>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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.
|
||||
* The bar-list breakdowns, in a masonry rather than a fixed grid. Their natural heights differ (education
|
||||
* has five rows, languages seven, "looking for" three), so a rigid grid would pad them all to the tallest
|
||||
* in the row — the exact uniformity that made the section read as wallpaper. CSS columns let each card
|
||||
* keep its own height and pack organically. Gender, age and countries are pulled out above as a donut /
|
||||
* donut / map trio, so the wall is broken before the bar lists even begin.
|
||||
*/
|
||||
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},
|
||||
const BAR_CARDS: {field: DemographicField; title: string; icon: IconType}[] = [
|
||||
{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: 'diet', title: 'Diet', icon: CakeIcon},
|
||||
{field: 'orientation', title: 'Orientation', icon: HeartIcon},
|
||||
{field: 'ethnicity', title: 'Ethnicity', icon: GlobeAltIcon},
|
||||
{field: 'pref_relation_styles', title: 'Looking for', icon: MagnifyingGlassIcon},
|
||||
{field: 'relationship_status', title: 'Relationship status', icon: LinkIcon},
|
||||
{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.
|
||||
// Gender is folded to man / woman / other for the donut: three slices read at a glance where the full
|
||||
// gender vocabulary would not, and "other" is exact (base − men − women) regardless of how the tail was
|
||||
// truncated. Colour follows the entity, on the brand's warm ramp; the legend carries identity.
|
||||
function genderSegments(
|
||||
dist: Distribution | undefined,
|
||||
t: ReturnType<typeof useT>,
|
||||
): DonutSegment[] {
|
||||
if (!dist) return []
|
||||
const male = dist.items.find((i) => i.value === 'male')?.count ?? 0
|
||||
const female = dist.items.find((i) => i.value === 'female')?.count ?? 0
|
||||
const other = Math.max(0, dist.base - male - female)
|
||||
return [
|
||||
{label: t('stats.gender.women', 'Women'), value: female, colorVar: '--color-primary-500'},
|
||||
{label: t('stats.gender.men', 'Men'), value: male, colorVar: '--color-primary-300'},
|
||||
{label: t('stats.gender.other', 'Other'), value: other, colorVar: '--color-ink-500'},
|
||||
].sort((a, b) => b.value - a.value)
|
||||
}
|
||||
|
||||
// Age keeps its buckets in age order (not by size) and rides a light→dark amber ramp — an ordinal ramp
|
||||
// for an ordinal field, so "older" reads as "deeper" without a legend having to say so.
|
||||
const AGE_RAMP = [
|
||||
'--color-primary-200',
|
||||
'--color-primary-300',
|
||||
'--color-primary-400',
|
||||
'--color-primary-600',
|
||||
]
|
||||
|
||||
function ageSegments(dist: Distribution | undefined): DonutSegment[] {
|
||||
if (!dist) return []
|
||||
return dist.items.map((it, i) => ({
|
||||
label: it.value,
|
||||
value: it.count,
|
||||
colorVar: AGE_RAMP[Math.min(i, AGE_RAMP.length - 1)],
|
||||
}))
|
||||
}
|
||||
|
||||
function Demographics({stats}: {stats: Stats}) {
|
||||
const t = useT()
|
||||
const {demographics, countries, countryCount} = stats
|
||||
const cards = DEMOGRAPHIC_CARDS.filter((c) => demographics[c.field])
|
||||
const barCards = BAR_CARDS.filter((c) => demographics[c.field])
|
||||
const hasCountries = (countries?.length ?? 0) >= MIN_COUNTRIES
|
||||
if (!cards.length && !hasCountries) return null
|
||||
const gender = genderSegments(demographics.gender, t)
|
||||
const age = ageSegments(demographics.age)
|
||||
|
||||
if (!barCards.length && !hasCountries && !gender.length && !age.length) return null
|
||||
|
||||
return (
|
||||
<Section>
|
||||
@@ -275,26 +332,54 @@ function Demographics({stats}: {stats: Stats}) {
|
||||
'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]}
|
||||
|
||||
{/* Feature block: a full-width map, then the two donuts side by side — three different marks
|
||||
before the bar lists, so the section never opens on a grid of identical cards. */}
|
||||
{hasCountries && (
|
||||
<Reveal>
|
||||
<WorldHeatmap countries={countries} countryCount={countryCount} />
|
||||
</Reveal>
|
||||
)}
|
||||
<div className={clsx('grid gap-3 sm:grid-cols-2 sm:gap-4', hasCountries && 'mt-3 sm:mt-4')}>
|
||||
{!!gender.length && (
|
||||
<Reveal delay={80}>
|
||||
<StatDonut
|
||||
title={t('stats.demographics.field.gender', 'Gender')}
|
||||
subtitle={t('stats.donut.shared', '{count} shared', {
|
||||
count: demographics.gender?.base ?? 0,
|
||||
})}
|
||||
icon={UserGroupIcon}
|
||||
segments={gender}
|
||||
/>
|
||||
</Reveal>
|
||||
)}
|
||||
{!!age.length && (
|
||||
<Reveal delay={140}>
|
||||
<StatDonut
|
||||
title={t('stats.demographics.field.age', 'Age')}
|
||||
subtitle={t('stats.donut.shared', '{count} shared', {
|
||||
count: demographics.age?.base ?? 0,
|
||||
})}
|
||||
icon={CalendarDaysIcon}
|
||||
segments={age}
|
||||
/>
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Masonry bar lists. CSS columns give each card its own height. */}
|
||||
<div className="mt-3 gap-3 [column-fill:_balance] sm:mt-4 sm:gap-4 sm:columns-2 lg:columns-3">
|
||||
{barCards.map((c, i) => (
|
||||
<div key={c.field} className="mb-3 break-inside-avoid sm:mb-4">
|
||||
<Reveal delay={(i % 3) * 60}>
|
||||
<DistributionCard
|
||||
field={c.field}
|
||||
title={t(`stats.demographics.field.${c.field}`, c.title)}
|
||||
icon={c.icon}
|
||||
dist={demographics[c.field]}
|
||||
/>
|
||||
</Reveal>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
@@ -314,9 +399,7 @@ export default function Stats() {
|
||||
const tables = [
|
||||
'profiles',
|
||||
'active_members',
|
||||
'bookmarked_searches',
|
||||
'private_user_message_channels',
|
||||
'profile_comments',
|
||||
'compatibility_prompts',
|
||||
'compatibility_answers',
|
||||
'votes',
|
||||
@@ -324,7 +407,7 @@ export default function Stats() {
|
||||
] as const
|
||||
|
||||
const [settled, statsResult] = await Promise.allSettled([
|
||||
Promise.allSettled(tables.map((t) => getCount(t))),
|
||||
Promise.allSettled(tables.map((tbl) => getCount(tbl))),
|
||||
api('stats', {}),
|
||||
])
|
||||
|
||||
@@ -374,89 +457,40 @@ export default function Stats() {
|
||||
|
||||
{/* ── Loading skeleton ── */}
|
||||
{loading && (
|
||||
<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={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 className="rounded-3xl bg-canvas-950 px-6 py-8 sm:px-10 sm:py-10">
|
||||
<div className="flex flex-wrap gap-x-14 gap-y-7">
|
||||
{Array.from({length: 4}).map((_, i) => (
|
||||
<div key={i} className="animate-pulse">
|
||||
<div className="h-10 w-24 rounded bg-white/10" />
|
||||
<div className="mt-3 h-3 w-16 rounded bg-white/10" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
{/* ── Hero highlight band ── */}
|
||||
<HighlightRow
|
||||
{/* ── Hero band ── */}
|
||||
<HeroBand
|
||||
members={data.profiles}
|
||||
active={data.active_members}
|
||||
messages={statsData?.messages}
|
||||
countries={statsData?.countryCount}
|
||||
discussions={data.private_user_message_channels}
|
||||
messages={statsData?.messages}
|
||||
/>
|
||||
|
||||
{/* ── Who's on Compass (gender, countries + demographic breakdowns) ── */}
|
||||
{/* ── Who's on Compass (map, donuts + demographic breakdowns) ── */}
|
||||
{statsData && <Demographics stats={statsData} />}
|
||||
|
||||
{/* ── Growth chart ── */}
|
||||
{/* ── Growth over time ── */}
|
||||
<Section>
|
||||
<SectionLabel>{t('stats.group.growth', 'Growth over time')}</SectionLabel>
|
||||
<ChartCard />
|
||||
</Section>
|
||||
|
||||
{/* ── Conversations ── */}
|
||||
<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 ── */}
|
||||
<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 ── */}
|
||||
<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>
|
||||
{/* ── Activity & participation ── */}
|
||||
<ActivitySection data={data} messages={statsData?.messages} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
112
yarn.lock
112
yarn.lock
@@ -6020,6 +6020,21 @@
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/topojson-client@3.1.5":
|
||||
version "3.1.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/topojson-client/-/topojson-client-3.1.5.tgz#3fdbcd52161db8747a071b1d0f635ac700cf599d"
|
||||
integrity sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==
|
||||
dependencies:
|
||||
"@types/geojson" "*"
|
||||
"@types/topojson-specification" "*"
|
||||
|
||||
"@types/topojson-specification@*":
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/topojson-specification/-/topojson-specification-1.0.5.tgz#bf0009b2e0debb2d97237b124c00b9ea92570375"
|
||||
integrity sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==
|
||||
dependencies:
|
||||
"@types/geojson" "*"
|
||||
|
||||
"@types/tough-cookie@*":
|
||||
version "4.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz#cb6e2a691b70cb177c6e3ae9c1d2e8b2ea8cd304"
|
||||
@@ -7598,6 +7613,11 @@ comma-separated-tokens@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee"
|
||||
integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==
|
||||
|
||||
commander@2, commander@^2.19.0:
|
||||
version "2.20.3"
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
|
||||
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
|
||||
|
||||
commander@6.2.0:
|
||||
version "6.2.0"
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.0.tgz#b990bfb8ac030aedc6d11bc04d1488ffef56db75"
|
||||
@@ -7628,11 +7648,6 @@ commander@^13.0.0:
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-13.1.0.tgz#776167db68c78f38dcce1f9b8d7b8b9a488abf46"
|
||||
integrity sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==
|
||||
|
||||
commander@^2.19.0:
|
||||
version "2.20.3"
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
|
||||
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
|
||||
|
||||
commander@^4.0.0:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
|
||||
@@ -8184,7 +8199,7 @@ csv-parse@^5.0.4:
|
||||
resolved "https://registry.yarnpkg.com/csv-parse/-/csv-parse-5.6.0.tgz#219beace2a3e9f28929999d2aa417d3fb3071c7f"
|
||||
integrity sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==
|
||||
|
||||
"d3-array@2 - 3", "d3-array@2.10.0 - 3", d3-array@^3.1.6:
|
||||
"d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", d3-array@^3.1.6:
|
||||
version "3.2.4"
|
||||
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5"
|
||||
integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==
|
||||
@@ -8206,6 +8221,13 @@ d3-ease@^3.0.1:
|
||||
resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641"
|
||||
integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==
|
||||
|
||||
d3-geo@3.1.1:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/d3-geo/-/d3-geo-3.1.1.tgz#6027cf51246f9b2ebd64f99e01dc7c3364033a4d"
|
||||
integrity sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==
|
||||
dependencies:
|
||||
d3-array "2.5.0 - 3"
|
||||
|
||||
"d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d"
|
||||
@@ -14736,15 +14758,20 @@ pg-cloudflare@^1.3.0:
|
||||
resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz#386035d4bfcf1a7045b026f8b21acf5353f14d65"
|
||||
integrity sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==
|
||||
|
||||
pg-cloudflare@^1.4.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz#4b4c20e6d8ae531d400730f4804571a8d62f1497"
|
||||
integrity sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==
|
||||
|
||||
pg-connection-string@^2.11.0:
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.11.0.tgz#5dca53ff595df33ba9db812e181b19909866d10b"
|
||||
integrity sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==
|
||||
|
||||
pg-connection-string@^2.12.0:
|
||||
version "2.12.0"
|
||||
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.12.0.tgz#4084f917902bb2daae3dc1376fe24ac7b4eaccf2"
|
||||
integrity sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==
|
||||
pg-connection-string@^2.14.0:
|
||||
version "2.14.0"
|
||||
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.14.0.tgz#abc26ee4f37c56c0f3ae0fcf0b0653cc4e1c0fd9"
|
||||
integrity sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==
|
||||
|
||||
pg-connection-string@^2.9.1:
|
||||
version "2.9.1"
|
||||
@@ -14781,10 +14808,10 @@ pg-pool@^3.11.0:
|
||||
resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.11.0.tgz#adf9a6651a30c839f565a3cc400110949c473d69"
|
||||
integrity sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==
|
||||
|
||||
pg-pool@^3.13.0:
|
||||
version "3.13.0"
|
||||
resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.13.0.tgz#416482e9700e8f80c685a6ae5681697a413c13a3"
|
||||
integrity sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==
|
||||
pg-pool@^3.14.0:
|
||||
version "3.14.0"
|
||||
resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.14.0.tgz#f35ae4eb846780cad71af24099b3edfa9781ad90"
|
||||
integrity sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==
|
||||
|
||||
pg-promise@12.6.1:
|
||||
version "12.6.1"
|
||||
@@ -14811,10 +14838,10 @@ pg-protocol@^1.11.0:
|
||||
resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.11.0.tgz#2502908893edaa1e8c0feeba262dd7b40b317b53"
|
||||
integrity sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==
|
||||
|
||||
pg-protocol@^1.13.0:
|
||||
version "1.13.0"
|
||||
resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.13.0.tgz#fdaf6d020bca590d58bb991b4b16fc448efe0511"
|
||||
integrity sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==
|
||||
pg-protocol@^1.15.0:
|
||||
version "1.15.0"
|
||||
resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.15.0.tgz#758f6c0679cc0bbf4938603b7597703f333180c0"
|
||||
integrity sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==
|
||||
|
||||
pg-query-stream@4.12.0:
|
||||
version "4.12.0"
|
||||
@@ -14848,17 +14875,17 @@ pg@8.18.0:
|
||||
pg-cloudflare "^1.3.0"
|
||||
|
||||
pg@8.x:
|
||||
version "8.20.0"
|
||||
resolved "https://registry.yarnpkg.com/pg/-/pg-8.20.0.tgz#1a274de944cb329fd6dd77a6d371a005ba6b136d"
|
||||
integrity sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==
|
||||
version "8.22.0"
|
||||
resolved "https://registry.yarnpkg.com/pg/-/pg-8.22.0.tgz#55ca3975026180c6dced6eec3a20a844c2dc9237"
|
||||
integrity sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==
|
||||
dependencies:
|
||||
pg-connection-string "^2.12.0"
|
||||
pg-pool "^3.13.0"
|
||||
pg-protocol "^1.13.0"
|
||||
pg-connection-string "^2.14.0"
|
||||
pg-pool "^3.14.0"
|
||||
pg-protocol "^1.15.0"
|
||||
pg-types "2.2.0"
|
||||
pgpass "1.0.5"
|
||||
optionalDependencies:
|
||||
pg-cloudflare "^1.3.0"
|
||||
pg-cloudflare "^1.4.0"
|
||||
|
||||
pg@^8.11.3:
|
||||
version "8.16.3"
|
||||
@@ -15044,7 +15071,16 @@ postcss@8.4.31:
|
||||
picocolors "^1.0.0"
|
||||
source-map-js "^1.0.2"
|
||||
|
||||
postcss@8.5.6, postcss@^8.4.47:
|
||||
postcss@8.5.10:
|
||||
version "8.5.10"
|
||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.10.tgz#8992d8c30acf3f12169e7c09514a12fed7e48356"
|
||||
integrity sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==
|
||||
dependencies:
|
||||
nanoid "^3.3.11"
|
||||
picocolors "^1.1.1"
|
||||
source-map-js "^1.2.1"
|
||||
|
||||
postcss@^8.4.47:
|
||||
version "8.5.6"
|
||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c"
|
||||
integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==
|
||||
@@ -15360,9 +15396,9 @@ prosemirror-menu@^1.2.4:
|
||||
prosemirror-state "^1.0.0"
|
||||
|
||||
prosemirror-model@1.x, prosemirror-model@^1.0.0, prosemirror-model@^1.20.0, prosemirror-model@^1.21.0, prosemirror-model@^1.23.0, prosemirror-model@^1.25.0, prosemirror-model@^1.25.4:
|
||||
version "1.25.4"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.25.4.tgz#8ebfbe29ecbee9e5e2e4048c4fe8e363fcd56e7c"
|
||||
integrity sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==
|
||||
version "1.25.11"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.25.11.tgz#2ac01dce5176094a52cc1b889c20f3849563aba9"
|
||||
integrity sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==
|
||||
dependencies:
|
||||
orderedmap "^2.0.0"
|
||||
|
||||
@@ -15794,9 +15830,9 @@ react-icons@5.5.0:
|
||||
integrity sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==
|
||||
|
||||
react-is@^16.13.1, react-is@^17.0.1, react-is@^18.0.0, react-is@^19.0.0:
|
||||
version "19.2.4"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.4.tgz#a080758243c572ccd4a63386537654298c99d135"
|
||||
integrity sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==
|
||||
version "19.2.8"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.8.tgz#09826f9fbc187bc668e3e5c62edc001f804d5018"
|
||||
integrity sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==
|
||||
|
||||
react-markdown@10.1.0:
|
||||
version "10.1.0"
|
||||
@@ -17762,6 +17798,13 @@ toidentifier@1.0.1, toidentifier@~1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
|
||||
integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
|
||||
|
||||
topojson-client@3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/topojson-client/-/topojson-client-3.1.0.tgz#22e8b1ed08a2b922feeb4af6f53b6ef09a467b99"
|
||||
integrity sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==
|
||||
dependencies:
|
||||
commander "2"
|
||||
|
||||
touch@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.0.tgz#fe365f5f75ec9ed4e56825e0bb76d24ab74af83b"
|
||||
@@ -18770,6 +18813,11 @@ wordwrap@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
|
||||
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
|
||||
|
||||
world-atlas@2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/world-atlas/-/world-atlas-2.0.2.tgz#4bca4a578f3a413bd409c3f07f2054fe7375824f"
|
||||
integrity sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==
|
||||
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
|
||||
Reference in New Issue
Block a user