Add Photographers & Builders to community stats + restructure Lab section

- useGrowthStats now queries kind 35004 (photographers) and 35005 (builders),
  which were previously omitted; added to totals, monthly buckets, allContent,
  and the content-mix donut (folded into the "Lab" slice)
- Community Stats page reorganized into clear sub-sections:
  * "The Lab · Creators & Services" — Coaches, Filmers, Editors,
    Photographers, Builders, with a total badge
  * "Community" — Tours, Crews
  This scales the growing role list cleanly instead of cramming one row.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-24 15:06:18 +07:00
parent 3b25e7bcf6
commit cf514a7c3f
2 changed files with 70 additions and 11 deletions

View file

@ -7,6 +7,8 @@ import {
SKATE_COACH_KIND, SKATE_COACH_KIND,
SKATE_FILMER_KIND, SKATE_FILMER_KIND,
SKATE_EDITOR_KIND, SKATE_EDITOR_KIND,
SKATE_PHOTOGRAPHER_KIND,
SKATE_BUILDER_KIND,
SKATE_TOUR_KIND, SKATE_TOUR_KIND,
SKATE_CREW_KIND, SKATE_CREW_KIND,
SKATE_RELAY, SKATE_RELAY,
@ -25,6 +27,8 @@ export interface MonthBucket {
coaches: number; coaches: number;
filmers: number; filmers: number;
editors: number; editors: number;
photographers: number;
builders: number;
tours: number; tours: number;
crews: number; crews: number;
total: number; total: number;
@ -46,6 +50,8 @@ export interface GrowthStats {
totalCoaches: number; totalCoaches: number;
totalFilmers: number; totalFilmers: number;
totalEditors: number; totalEditors: number;
totalPhotographers: number;
totalBuilders: number;
totalTours: number; totalTours: number;
totalCrews: number; totalCrews: number;
/** All unique pubkeys that have published any content */ /** All unique pubkeys that have published any content */
@ -118,6 +124,8 @@ export function useGrowthStats() {
coachEvents, coachEvents,
filmerEvents, filmerEvents,
editorEvents, editorEvents,
photographerEvents,
builderEvents,
tourEvents, tourEvents,
crewEvents, crewEvents,
] = await Promise.all([ ] = await Promise.all([
@ -127,6 +135,8 @@ export function useGrowthStats() {
relay.query([{ kinds: [SKATE_COACH_KIND], limit: 500 }], { signal }).catch(() => []), relay.query([{ kinds: [SKATE_COACH_KIND], limit: 500 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_FILMER_KIND], limit: 500 }], { signal }).catch(() => []), relay.query([{ kinds: [SKATE_FILMER_KIND], limit: 500 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_EDITOR_KIND], limit: 500 }], { signal }).catch(() => []), relay.query([{ kinds: [SKATE_EDITOR_KIND], limit: 500 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_PHOTOGRAPHER_KIND], limit: 500 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_BUILDER_KIND], limit: 500 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_TOUR_KIND], limit: 500 }], { signal }).catch(() => []), relay.query([{ kinds: [SKATE_TOUR_KIND], limit: 500 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_CREW_KIND], limit: 500 }], { signal }).catch(() => []), relay.query([{ kinds: [SKATE_CREW_KIND], limit: 500 }], { signal }).catch(() => []),
]); ]);
@ -149,6 +159,8 @@ export function useGrowthStats() {
const coaches = dedup(coachEvents); const coaches = dedup(coachEvents);
const filmers = dedup(filmerEvents); const filmers = dedup(filmerEvents);
const editors = dedup(editorEvents); const editors = dedup(editorEvents);
const photographers = dedup(photographerEvents);
const builders = dedup(builderEvents);
const tours = dedup(tourEvents); const tours = dedup(tourEvents);
const crews = dedup(crewEvents); const crews = dedup(crewEvents);
@ -157,6 +169,7 @@ export function useGrowthStats() {
const bucketMap = new Map<string, { const bucketMap = new Map<string, {
spots: Set<string>; shops: Set<string>; events: Set<string>; spots: Set<string>; shops: Set<string>; events: Set<string>;
coaches: Set<string>; filmers: Set<string>; editors: Set<string>; coaches: Set<string>; filmers: Set<string>; editors: Set<string>;
photographers: Set<string>; builders: Set<string>;
tours: Set<string>; crews: Set<string>; pubkeys: Set<string>; tours: Set<string>; crews: Set<string>; pubkeys: Set<string>;
}>(); }>();
@ -165,6 +178,7 @@ export function useGrowthStats() {
bucketMap.set(key, { bucketMap.set(key, {
spots: new Set(), shops: new Set(), events: new Set(), spots: new Set(), shops: new Set(), events: new Set(),
coaches: new Set(), filmers: new Set(), editors: new Set(), coaches: new Set(), filmers: new Set(), editors: new Set(),
photographers: new Set(), builders: new Set(),
tours: new Set(), crews: new Set(), pubkeys: new Set(), tours: new Set(), crews: new Set(), pubkeys: new Set(),
}); });
} }
@ -177,6 +191,8 @@ export function useGrowthStats() {
for (const e of coaches) { const b = ensureBucket(toMonthKey(e.created_at)); b.coaches.add(e.id); b.pubkeys.add(e.pubkey); } for (const e of coaches) { const b = ensureBucket(toMonthKey(e.created_at)); b.coaches.add(e.id); b.pubkeys.add(e.pubkey); }
for (const e of filmers) { const b = ensureBucket(toMonthKey(e.created_at)); b.filmers.add(e.id); b.pubkeys.add(e.pubkey); } for (const e of filmers) { const b = ensureBucket(toMonthKey(e.created_at)); b.filmers.add(e.id); b.pubkeys.add(e.pubkey); }
for (const e of editors) { const b = ensureBucket(toMonthKey(e.created_at)); b.editors.add(e.id); b.pubkeys.add(e.pubkey); } for (const e of editors) { const b = ensureBucket(toMonthKey(e.created_at)); b.editors.add(e.id); b.pubkeys.add(e.pubkey); }
for (const e of photographers) { const b = ensureBucket(toMonthKey(e.created_at)); b.photographers.add(e.id); b.pubkeys.add(e.pubkey); }
for (const e of builders) { const b = ensureBucket(toMonthKey(e.created_at)); b.builders.add(e.id); b.pubkeys.add(e.pubkey); }
for (const e of tours) { const b = ensureBucket(toMonthKey(e.created_at)); b.tours.add(e.id); b.pubkeys.add(e.pubkey); } for (const e of tours) { const b = ensureBucket(toMonthKey(e.created_at)); b.tours.add(e.id); b.pubkeys.add(e.pubkey); }
for (const e of crews) { const b = ensureBucket(toMonthKey(e.created_at)); b.crews.add(e.id); b.pubkeys.add(e.pubkey); } for (const e of crews) { const b = ensureBucket(toMonthKey(e.created_at)); b.crews.add(e.id); b.pubkeys.add(e.pubkey); }
@ -191,14 +207,17 @@ export function useGrowthStats() {
const co = b?.coaches.size ?? 0; const co = b?.coaches.size ?? 0;
const fi = b?.filmers.size ?? 0; const fi = b?.filmers.size ?? 0;
const ed = b?.editors.size ?? 0; const ed = b?.editors.size ?? 0;
const ph = b?.photographers.size ?? 0;
const bu = b?.builders.size ?? 0;
const to = b?.tours.size ?? 0; const to = b?.tours.size ?? 0;
const cr = b?.crews.size ?? 0; const cr = b?.crews.size ?? 0;
return { return {
label: toMonthLabel(key), label: toMonthLabel(key),
key, key,
spots: s, shops: sh, events: ev, spots: s, shops: sh, events: ev,
coaches: co, filmers: fi, editors: ed, tours: to, crews: cr, coaches: co, filmers: fi, editors: ed,
total: s + sh + ev + co + fi + ed + to + cr, photographers: ph, builders: bu, tours: to, crews: cr,
total: s + sh + ev + co + fi + ed + ph + bu + to + cr,
activeMappers: b?.pubkeys.size ?? 0, activeMappers: b?.pubkeys.size ?? 0,
}; };
}); });
@ -216,7 +235,7 @@ export function useGrowthStats() {
const mapperTotal = new Map<string, number>(); const mapperTotal = new Map<string, number>();
for (const e of spots) { mapperSpots.set(e.pubkey, (mapperSpots.get(e.pubkey) ?? 0) + 1); } for (const e of spots) { mapperSpots.set(e.pubkey, (mapperSpots.get(e.pubkey) ?? 0) + 1); }
const allContent = [...spots, ...shops, ...events, ...coaches, ...filmers, ...editors, ...tours, ...crews]; const allContent = [...spots, ...shops, ...events, ...coaches, ...filmers, ...editors, ...photographers, ...builders, ...tours, ...crews];
for (const e of allContent) { mapperTotal.set(e.pubkey, (mapperTotal.get(e.pubkey) ?? 0) + 1); } for (const e of allContent) { mapperTotal.set(e.pubkey, (mapperTotal.get(e.pubkey) ?? 0) + 1); }
const topMappers: TopMapper[] = Array.from(mapperSpots.entries()) const topMappers: TopMapper[] = Array.from(mapperSpots.entries())
@ -254,6 +273,8 @@ export function useGrowthStats() {
totalCoaches: coaches.length, totalCoaches: coaches.length,
totalFilmers: filmers.length, totalFilmers: filmers.length,
totalEditors: editors.length, totalEditors: editors.length,
totalPhotographers: photographers.length,
totalBuilders: builders.length,
totalTours: tours.length, totalTours: tours.length,
totalCrews: crews.length, totalCrews: crews.length,
totalMappers: allPubkeys.size, totalMappers: allPubkeys.size,

View file

@ -20,8 +20,9 @@ import {
import { import {
MapPin, Store, Calendar, GraduationCap, MapPin, Store, Calendar, GraduationCap,
Video, Scissors, Route, Users, Users2, Video, Scissors, Route, Users, Users2,
Camera, Hammer,
TrendingUp, Radio, Zap, ExternalLink, TrendingUp, Radio, Zap, ExternalLink,
Trophy, Trophy, FlaskConical,
} from 'lucide-react'; } from 'lucide-react';
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
@ -159,7 +160,7 @@ export default function CommunityStatsPage() {
return [ return [
{ name: 'Spots', value: stats.totalSpots, color: '#ff0040' }, { name: 'Spots', value: stats.totalSpots, color: '#ff0040' },
{ name: 'Events', value: stats.totalEvents, color: '#ccff00' }, { name: 'Events', value: stats.totalEvents, color: '#ccff00' },
{ name: 'Lab', value: stats.totalCoaches + stats.totalFilmers + stats.totalEditors, color: '#ff6600' }, { name: 'Lab', value: stats.totalCoaches + stats.totalFilmers + stats.totalEditors + stats.totalPhotographers + stats.totalBuilders, color: '#ff6600' },
{ name: 'Shops', value: stats.totalShops, color: '#4ade80' }, { name: 'Shops', value: stats.totalShops, color: '#4ade80' },
{ name: 'Tours', value: stats.totalTours, color: '#c084fc' }, { name: 'Tours', value: stats.totalTours, color: '#c084fc' },
{ name: 'Crews', value: stats.totalCrews, color: '#2dd4bf' }, { name: 'Crews', value: stats.totalCrews, color: '#2dd4bf' },
@ -173,6 +174,18 @@ export default function CommunityStatsPage() {
// Current month delta — how many items were added this month // Current month delta — how many items were added this month
const thisMonth = useMemo(() => stats?.monthly[stats.monthly.length - 1], [stats]); const thisMonth = useMemo(() => stats?.monthly[stats.monthly.length - 1], [stats]);
// Total across all Lab creator/service roles
const labTotal = useMemo(() => {
if (!stats) return 0;
return (
stats.totalCoaches +
stats.totalFilmers +
stats.totalEditors +
stats.totalPhotographers +
stats.totalBuilders
);
}, [stats]);
return ( return (
<Layout> <Layout>
<div className="container max-w-6xl px-4 py-10 md:py-14"> <div className="container max-w-6xl px-4 py-10 md:py-14">
@ -216,12 +229,37 @@ export default function CommunityStatsPage() {
<StatCard icon={Calendar} value={stats?.totalEvents ?? 0} label="Events" color="#ccff00" loading={isLoading} thisMonthDelta={thisMonth?.events} /> <StatCard icon={Calendar} value={stats?.totalEvents ?? 0} label="Events" color="#ccff00" loading={isLoading} thisMonthDelta={thisMonth?.events} />
<StatCard icon={Users} value={stats?.totalMappers ?? 0} label="Mappers" color="#facc15" loading={isLoading} trustedValue={stats?.trustedMappers} sub="unique contributors" thisMonthDelta={thisMonth?.activeMappers} /> <StatCard icon={Users} value={stats?.totalMappers ?? 0} label="Mappers" color="#facc15" loading={isLoading} trustedValue={stats?.trustedMappers} sub="unique contributors" thisMonthDelta={thisMonth?.activeMappers} />
</div> </div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 mt-3"> </section>
<StatCard icon={GraduationCap} value={stats?.totalCoaches ?? 0} label="Coaches" color="#ff6600" loading={isLoading} thisMonthDelta={thisMonth?.coaches} />
<StatCard icon={Video} value={stats?.totalFilmers ?? 0} label="Filmers" color="#60a5fa" loading={isLoading} thisMonthDelta={thisMonth?.filmers} /> {/* ── Section 1b: The Lab — creators & services ── */}
<StatCard icon={Scissors} value={stats?.totalEditors ?? 0} label="Editors" color="#c084fc" loading={isLoading} thisMonthDelta={thisMonth?.editors} /> <section className="mb-12">
<StatCard icon={Route} value={stats?.totalTours ?? 0} label="Tours" color="#f472b6" loading={isLoading} thisMonthDelta={thisMonth?.tours} /> <div className="flex items-center justify-between mb-4 gap-2">
<StatCard icon={Users2} value={stats?.totalCrews ?? 0} label="Crews" color="#2dd4bf" loading={isLoading} thisMonthDelta={thisMonth?.crews} /> <h2 className="text-xs font-black uppercase tracking-widest text-gray-500 flex items-center gap-2">
<FlaskConical className="h-3.5 w-3.5 text-[#ff6600]" /> The Lab · Creators &amp; Services
</h2>
{!isLoading && labTotal > 0 && (
<span className="text-[10px] font-black uppercase tracking-wide px-2 py-0.5 rounded-full bg-[#ff6600]/15 text-[#ff6600]">
{labTotal.toLocaleString()} total
</span>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<StatCard icon={GraduationCap} value={stats?.totalCoaches ?? 0} label="Coaches" color="#ff6600" loading={isLoading} thisMonthDelta={thisMonth?.coaches} />
<StatCard icon={Video} value={stats?.totalFilmers ?? 0} label="Filmers" color="#60a5fa" loading={isLoading} thisMonthDelta={thisMonth?.filmers} />
<StatCard icon={Scissors} value={stats?.totalEditors ?? 0} label="Editors" color="#c084fc" loading={isLoading} thisMonthDelta={thisMonth?.editors} />
<StatCard icon={Camera} value={stats?.totalPhotographers ?? 0} label="Photographers" color="#38bdf8" loading={isLoading} thisMonthDelta={thisMonth?.photographers} />
<StatCard icon={Hammer} value={stats?.totalBuilders ?? 0} label="Builders" color="#fb923c" loading={isLoading} thisMonthDelta={thisMonth?.builders} />
</div>
</section>
{/* ── Section 1c: Community — tours & crews ── */}
<section className="mb-12">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
<Users2 className="h-3.5 w-3.5 text-[#2dd4bf]" /> Community
</h2>
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard icon={Route} value={stats?.totalTours ?? 0} label="Tours" color="#f472b6" loading={isLoading} thisMonthDelta={thisMonth?.tours} />
<StatCard icon={Users2} value={stats?.totalCrews ?? 0} label="Crews" color="#2dd4bf" loading={isLoading} thisMonthDelta={thisMonth?.crews} />
</div> </div>
</section> </section>