v1.2.1: Community stats now show ALL Lab crafts + count every craft type

- useGrowthStats now queries and exposes every Lab craft individually: artists,
  graffiti, tattoo, trainers, musicians and judges (previously folded into an
  opaque "other" bucket with no visible breakdown).
- CommunityStatsPage renders a card for all 11 Lab crafts, so the section matches
  what /lab actually lists instead of only showing 5 categories.
- labTotal + the content-mix donut's "Lab" slice now sum every craft, keeping the
  "total" badge and chart consistent.

Note on relay mismatch: the app dedupes addressable events (keeps newest per
pubkey+kind+d) and merges across the community relay + the user's relays, so
counts can legitimately differ from a raw single-relay browser that shows stale
duplicate versions.

Bump version to 1.2.1 (versionCode 10201).

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-08-27 15:07:13 +07:00
parent 677ccb8a82
commit 94a88b2ba0
3 changed files with 61 additions and 18 deletions

View file

@ -1,7 +1,7 @@
{
"name": "skate-spots",
"private": true,
"version": "1.2.0",
"version": "1.2.1",
"type": "module",
"homepage": "https://skate-spots.com",
"scripts": {

View file

@ -14,6 +14,7 @@ import {
SKATE_TATTOO_KIND,
SKATE_TRAINER_KIND,
SKATE_MUSICIAN_KIND,
SKATE_JUDGE_KIND,
SKATE_TOUR_KIND,
SKATE_CREW_KIND,
} from '@/lib/types';
@ -56,6 +57,13 @@ export interface GrowthStats {
totalEditors: number;
totalPhotographers: number;
totalBuilders: number;
// Additional Lab crafts
totalArtists: number;
totalGraffiti: number;
totalTattoo: number;
totalTrainers: number;
totalMusicians: number;
totalJudges: number;
totalTours: number;
totalCrews: number;
/** All unique pubkeys that have published any content */
@ -135,7 +143,12 @@ export function useGrowthStats() {
builderEvents,
tourEvents,
crewEvents,
otherLabEvents,
artistEvents,
graffitiEvents,
tattooEvents,
trainerEvents,
musicianEvents,
judgeEvents,
] = await Promise.all([
querySkateContent([{ kinds: [SKATE_SPOT_KIND], '#t': ['skatespot'], limit: 2000 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_SHOP_KIND], limit: 2000 }], { signal }).catch(() => []),
@ -147,9 +160,14 @@ export function useGrowthStats() {
querySkateContent([{ kinds: [SKATE_BUILDER_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_TOUR_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_CREW_KIND], limit: 500 }], { signal }).catch(() => []),
// New Lab crafts (artist, graffiti, tattoo, trainer, musician) — folded
// into community totals/mappers without their own chart series.
querySkateContent([{ kinds: [SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND], limit: 1000 }], { signal }).catch(() => []),
// All other Lab crafts, each counted individually so the stats page can
// show a card per craft (matching what /lab lists).
querySkateContent([{ kinds: [SKATE_ARTIST_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_GRAFFITI_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_TATTOO_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_TRAINER_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_MUSICIAN_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_JUDGE_KIND], limit: 500 }], { signal }).catch(() => []),
]);
// Deduplicate addressable events (keep newest per pubkey+kind+d)
@ -174,7 +192,14 @@ export function useGrowthStats() {
const builders = dedup(builderEvents);
const tours = dedup(tourEvents);
const crews = dedup(crewEvents);
const otherLab = dedup(otherLabEvents);
const artists = dedup(artistEvents);
const graffiti = dedup(graffitiEvents);
const tattoo = dedup(tattooEvents);
const trainers = dedup(trainerEvents);
const musicians = dedup(musicianEvents);
const judges = dedup(judgeEvents);
// Aggregate of the "other" crafts for active-mapper / total rollups.
const otherLab = [...artists, ...graffiti, ...tattoo, ...trainers, ...musicians, ...judges];
// ── Monthly bucketing ───────────────────────────────────────────────
@ -289,6 +314,12 @@ export function useGrowthStats() {
totalEditors: editors.length,
totalPhotographers: photographers.length,
totalBuilders: builders.length,
totalArtists: artists.length,
totalGraffiti: graffiti.length,
totalTattoo: tattoo.length,
totalTrainers: trainers.length,
totalMusicians: musicians.length,
totalJudges: judges.length,
totalTours: tours.length,
totalCrews: crews.length,
totalMappers: allPubkeys.size,

View file

@ -25,10 +25,25 @@ import {
Camera, Hammer,
TrendingUp, Radio, Zap, ExternalLink,
Trophy, FlaskConical,
Palette, SprayCan, PenTool, Dumbbell, Music, Gavel,
} from 'lucide-react';
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** Sum of every Lab craft count — keeps the "total" badge and donut in sync. */
function labCraftTotal(s: {
totalCoaches: number; totalFilmers: number; totalEditors: number;
totalPhotographers: number; totalBuilders: number; totalArtists: number;
totalGraffiti: number; totalTattoo: number; totalTrainers: number;
totalMusicians: number; totalJudges: number;
}): number {
return (
s.totalCoaches + s.totalFilmers + s.totalEditors + s.totalPhotographers +
s.totalBuilders + s.totalArtists + s.totalGraffiti + s.totalTattoo +
s.totalTrainers + s.totalMusicians + s.totalJudges
);
}
function formatSats(sats: number): string {
if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(1)}M`;
if (sats >= 1_000) return `${Math.round(sats / 1_000)}k`;
@ -162,7 +177,7 @@ export default function CommunityStatsPage() {
return [
{ name: 'Spots', value: stats.totalSpots, color: '#ff0040' },
{ name: 'Events', value: stats.totalEvents, color: '#ccff00' },
{ name: 'Lab', value: stats.totalCoaches + stats.totalFilmers + stats.totalEditors + stats.totalPhotographers + stats.totalBuilders, color: '#ff6600' },
{ name: 'Lab', value: labCraftTotal(stats), color: '#ff6600' },
{ name: 'Shops', value: stats.totalShops, color: '#4ade80' },
{ name: 'Tours', value: stats.totalTours, color: '#c084fc' },
{ name: 'Crews', value: stats.totalCrews, color: '#2dd4bf' },
@ -176,17 +191,8 @@ export default function CommunityStatsPage() {
// Current month delta — how many items were added this month
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]);
// Total across ALL Lab creator/service crafts (matches what /lab lists)
const labTotal = useMemo(() => (stats ? labCraftTotal(stats) : 0), [stats]);
return (
<Layout>
@ -251,6 +257,12 @@ export default function CommunityStatsPage() {
<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} />
<StatCard icon={Palette} value={stats?.totalArtists ?? 0} label="Artists" color="#f472b6" loading={isLoading} />
<StatCard icon={SprayCan} value={stats?.totalGraffiti ?? 0} label="Graffiti" color="#a3e635" loading={isLoading} />
<StatCard icon={PenTool} value={stats?.totalTattoo ?? 0} label="Tattoo" color="#f87171" loading={isLoading} />
<StatCard icon={Dumbbell} value={stats?.totalTrainers ?? 0} label="Trainers" color="#34d399" loading={isLoading} />
<StatCard icon={Music} value={stats?.totalMusicians ?? 0} label="Musicians" color="#818cf8" loading={isLoading} />
<StatCard icon={Gavel} value={stats?.totalJudges ?? 0} label="Judges" color="#fbbf24" loading={isLoading} />
</div>
</section>