Sasquatch: full public-data coverage (all Lab crafts + shops + tours)

- findCoaches now covers EVERY Lab craft (coach, filmer, editor, photographer,
  builder, artist, graffiti, tattoo, trainer, musician, judge) via role param;
  reads skills from `t` tags and strips self-tags.
- New findShops (kind 30017, t=skateshop) and findTours (kind 30823) tools.
- getCommunityStats counts all crafts individually + lab_total + shops + tours,
  and filters spots to t=skatespot (drops foreign kind-30818 events).
- Wired new tools + expanded role vocabulary into the Sasquatch brain: system
  prompt, intent parser, keyword detector, and both dispatch/format paths.
- Privacy preserved: the public CVM data layer never queries the encrypted
  Stash (kind 30001) — verified no 30001/decrypt references. Personal 'my
  stash' stays login+Pro gated and decrypts locally with the user's own key.
- Normalized useCVMClient.ts to clean UTF-8 (comment glyphs only; no code change)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-08-27 16:14:20 +07:00
parent e996d96fa0
commit 407ba40a33
2 changed files with 295 additions and 42 deletions

View file

@ -151,13 +151,13 @@ Args: city?(string), country?(string), event_type?(string), from_date?(string),
Example: {"tool":"find_events","args":{"city":"Bangkok"}}
### find_coaches
Args: role?("coach"|"filmer"|"editor"|"any"), skills?(string[]), location?(string), limit?(number)
- Covers all The Lab service providers: coaches, filmers, photographers, editors, and ramp/park builders
Args: role?("coach"|"filmer"|"editor"|"photographer"|"builder"|"artist"|"graffiti"|"tattoo"|"trainer"|"musician"|"judge"|"any"), skills?(string[]), location?(string), limit?(number)
- Covers EVERY The Lab craft: coaches, filmers, video editors, photographers, ramp/park/DIY builders, designers/artists, graffiti artists, tattoo artists, trainers/physios, musicians/bands, and event JUDGES (Game of S.K.A.T.E., contests, tours).
- Use the matching role for a specific craft (e.g. role:"judge" for "find judges", role:"tattoo" for tattoo artists). Use role:"any" to search all crafts.
- location searches name, bio, city tag — use the CITY name not country name for best results
- "coaches in Thailand" → use location:"Bangkok" (main city)
- "coaches in Bangkok" → use location:"Bangkok"
- Always fetch with limit:20 to avoid missing results
Example: {"tool":"find_coaches","args":{"location":"Bangkok","limit":20}}
Example: {"tool":"find_coaches","args":{"role":"judge","location":"Barcelona","limit":20}}
### find_gear
Args: category?(string), condition?("new"|"like_new"|"good"|"fair"|"any"), max_price_sats?(number), search?(string), limit?(number)
@ -169,6 +169,15 @@ Find skate crews (community groups). Args: city?(string), country?(string), vibe
- Always use limit:20+ for city/country searches
Example: {"tool":"find_crews","args":{"city":"Barcelona","limit":20}}
### find_shops
Find local skate shops. Args: city?(string), country?(string), search?(string), limit?(number)
- For country queries use the main city too (Thailand → city:"Bangkok")
Example: {"tool":"find_shops","args":{"city":"Barcelona","limit":20}}
### find_tours
Find local/guided skate tours (a guide shows you the hidden spots). Args: city?(string), country?(string), search?(string), limit?(number)
Example: {"tool":"find_tours","args":{"city":"Lisbon","limit":20}}
### get_community_stats
Args: none
Example: {"tool":"get_community_stats","args":{}}
@ -188,7 +197,7 @@ Note: this returns a rich itinerary CARD — after calling it, add a short one-l
## Rules
1. Data questions (spots/events/coaches/gear/stats) → ALWAYS use a tool, never guess
2. Tool call = ONE JSON object on its own line, nothing else before or after
3. App/protocol/how-to questions → answer directly (no tool). Stash, Wallet, Tours, Shops, Supporters have NO live search tool — answer these from the App facts below and point users to the right page. (Crews DO have find_crews — use it for "find/show crews".)
3. App/protocol/how-to questions → answer directly (no tool). Wallet, Supporters, and the private Stash have NO live search tool — answer from the App facts below and point users to the right page. Spots, Events, the Lab (all crafts incl. judges), Gear, Crews, Shops and Tours all HAVE live tools — use them for "find/show" requests.
4. Be concise, skater-friendly
5. Never invent spot names, event dates, or user data
6. 0 results = honest "none listed yet" + encourage adding content
@ -265,6 +274,10 @@ function needsLiveData(question: string): boolean {
'gear', 'for sale', 'buy', 'sell', 'how many', 'stats', 'count',
'list', 'nearby', 'close to', 'around', 'available',
'crews in', 'crews near', 'find crew', 'find a crew', 'crews around',
// Expanded Lab crafts + shops + tours
'photographer', 'editor', 'builder', 'artist', 'graffiti', 'tattoo',
'trainer', 'physio', 'musician', 'band', 'judge', 'judges',
'shop', 'shops', 'store', 'tour', 'tours', 'guide',
// Personal (Sasquatch Pro) — route to AI so it picks a get_my_* tool
'my stash', 'my gear', 'my crew', 'my crews', 'my spots', 'my listings',
'in my stash', "what am i selling", 'what do i own', 'my inventory',
@ -283,6 +296,8 @@ type LocalIntent =
| { tool: 'find_events'; location: string }
| { tool: 'find_coaches'; location: string }
| { tool: 'find_crews'; location: string }
| { tool: 'find_shops'; location: string }
| { tool: 'find_tours'; location: string }
| { tool: 'find_gear'; search: string }
| { tool: 'get_community_stats' }
| { tool: 'get_weather'; city: string }
@ -313,13 +328,17 @@ function parseLocalIntent(question: string): LocalIntent | null {
return { tool: 'get_weather', city: cleanLocation(weatherMatch[1]) };
}
// Determine the subject
// Determine the subject. Check the more specific nouns (shops, tours, the Lab
// crafts) before the generic ones so "skate shops in X" isn't mistaken for a
// spot search.
let tool: LocalIntent['tool'] | null = null;
if (/\bspots?\b|skateparks?|skate parks?|places? to skate/.test(q)) tool = 'find_spots';
if (/\bshops?\b|skate ?shops?|stores?/.test(q)) tool = 'find_shops';
else if (/\btours?\b|guides?\b/.test(q)) tool = 'find_tours';
else if (/\bevents?\b|comps?|competitions?|jams?|meet ?ups?/.test(q)) tool = 'find_events';
else if (/\bcoach(es)?\b|filmers?|photographers?|editors?|instructors?/.test(q)) tool = 'find_coaches';
else if (/\bcrews?\b/.test(q)) tool = 'find_crews';
else if (/\bgear\b|decks?|trucks?|wheels?|for sale|marketplace/.test(q)) tool = 'find_gear';
else if (/\bcoach(es)?\b|filmers?|photographers?|editors?|instructors?|builders?|artists?|graffiti|tattoo|trainers?|physios?|musicians?|bands?|judges?/.test(q)) tool = 'find_coaches';
else if (/\bspots?\b|skateparks?|skate parks?|places? to skate/.test(q)) tool = 'find_spots';
if (!tool) return null;
@ -339,6 +358,8 @@ function parseLocalIntent(question: string): LocalIntent | null {
if (tool === 'find_spots') return { tool: 'find_spots', location };
if (tool === 'find_events') return { tool: 'find_events', location };
if (tool === 'find_coaches') return { tool: 'find_coaches', location };
if (tool === 'find_shops') return { tool: 'find_shops', location };
if (tool === 'find_tours') return { tool: 'find_tours', location };
return { tool: 'find_crews', location };
}
@ -377,13 +398,17 @@ function formatLocalResults(intent: LocalIntent, result: unknown): string {
intent.tool === 'find_spots' ? 'spots' :
intent.tool === 'find_events' ? 'events' :
intent.tool === 'find_coaches' ? 'profiles' :
intent.tool === 'find_crews' ? 'crews' : 'listings';
intent.tool === 'find_crews' ? 'crews' :
intent.tool === 'find_shops' ? 'shops' :
intent.tool === 'find_tours' ? 'tours' : 'listings';
const items = (r[listKey] as Array<Record<string, unknown>>) ?? [];
const label =
intent.tool === 'find_spots' ? 'spots' :
intent.tool === 'find_events' ? 'events' :
intent.tool === 'find_coaches' ? 'Lab pros' :
intent.tool === 'find_crews' ? 'crews' : 'gear listings';
intent.tool === 'find_crews' ? 'crews' :
intent.tool === 'find_shops' ? 'skate shops' :
intent.tool === 'find_tours' ? 'tours' : 'gear listings';
if (!items.length) {
return `No ${label} listed there yet 🦧 — be the first to add some at [skate-spots.org](https://skate-spots.org).`;
@ -401,7 +426,9 @@ function formatLocalResults(intent: LocalIntent, result: unknown): string {
intent.tool === 'find_spots' ? '🛹' :
intent.tool === 'find_events' ? '📅' :
intent.tool === 'find_coaches' ? '🧪' :
intent.tool === 'find_crews' ? '👥' : '🛍️';
intent.tool === 'find_crews' ? '👥' :
intent.tool === 'find_shops' ? '🏪' :
intent.tool === 'find_tours' ? '🗺️' : '🛍️';
return `${emoji} Found **${items.length}** ${label}:\n${lines}`;
}
@ -942,6 +969,12 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
case 'find_crews':
toolResult = await cvm.findCrews(toolArgs as Parameters<typeof cvm.findCrews>[0]);
break;
case 'find_shops':
toolResult = await cvm.findShops(toolArgs as Parameters<typeof cvm.findShops>[0]);
break;
case 'find_tours':
toolResult = await cvm.findTours(toolArgs as Parameters<typeof cvm.findTours>[0]);
break;
case 'get_community_stats':
toolResult = await cvm.getCommunityStats();
break;
@ -1077,6 +1110,12 @@ Instructions:
case 'find_crews':
localResult = await cvm.findCrews({ city: intent.location, limit: 20 });
break;
case 'find_shops':
localResult = await cvm.findShops({ city: intent.location, limit: 20 });
break;
case 'find_tours':
localResult = await cvm.findTours({ city: intent.location, limit: 20 });
break;
case 'find_gear':
localResult = await cvm.findGear({ search: intent.search, limit: 20 });
break;

View file

@ -1,7 +1,7 @@
/**
* useCVMClient
* ─────────────────────────────────────────────────────────────────────────────
* Queries Nostr relays directly from the browser — same logic as the CVM
*
* Queries Nostr relays directly from the browser same logic as the CVM
* server but without the NIP-44 encrypted round-trip (which was unreliable).
*
* This gives instant results with no dependency on the CVM server being up.
@ -10,7 +10,7 @@
import { useCallback } from 'react';
import { useNostr } from '@nostrify/react';
// ─── Relay list ───────────────────────────────────────────────────────────────
// Relay list
const DATA_RELAYS = [
'wss://goskatespots.nostr1.com',
@ -18,7 +18,7 @@ const DATA_RELAYS = [
'wss://relay.damus.io',
];
// ─── Tool result types ───────────────────────────────────────────────────────
// Tool result types
export interface SpotResult {
name: string;
@ -49,8 +49,16 @@ export interface EventResult {
url: string;
}
/**
* A Lab professional. `role` covers every public Lab craft — not just the
* original coach/filmer/editor trio.
*/
export type LabRole =
| 'coach' | 'filmer' | 'editor' | 'photographer' | 'builder'
| 'artist' | 'graffiti' | 'tattoo' | 'trainer' | 'musician' | 'judge';
export interface CoachResult {
role: 'coach' | 'filmer' | 'editor';
role: LabRole;
pubkey: string;
bio: string;
location?: string;
@ -61,6 +69,32 @@ export interface CoachResult {
url: string;
}
export interface ShopResult {
name: string;
description: string;
location?: string;
geohash?: string;
image?: string;
website?: string;
owner_pubkey: string;
naddr: string;
url: string;
created_at: string;
}
export interface TourResult {
title: string;
description: string;
location?: string;
geohash?: string;
price?: string;
image?: string;
guide_pubkey: string;
naddr: string;
url: string;
created_at: string;
}
export interface GearResult {
title: string;
description: string;
@ -95,13 +129,25 @@ export interface CommunityStats {
coaches: number;
filmers: number;
editors: number;
photographers: number;
builders: number;
artists: number;
graffiti: number;
tattoo: number;
trainers: number;
musicians: number;
judges: number;
/** Total across every Lab craft (coaches..judges). */
lab_total: number;
shops: number;
tours: number;
gear_listings: number;
crews: number;
relays_queried: string[];
queried_at: string;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
// Helpers
function getTag(event: { tags: string[][] }, name: string): string | undefined {
return event.tags.find((t) => t[0] === name)?.[1];
@ -131,7 +177,7 @@ function buildNaddr(event: { kind: number; pubkey: string; tags: string[][] }):
return `${event.kind}:${event.pubkey}:${getTag(event, 'd') ?? ''}`;
}
// ─── The hook ────────────────────────────────────────────────────────────────
// The hook
export function useCVMClient() {
const { nostr } = useNostr();
@ -321,27 +367,36 @@ export function useCVMClient() {
const findCoaches = useCallback(
async (params: {
role?: 'coach' | 'filmer' | 'editor' | 'any';
// Every public Lab craft is searchable. 'any' searches all of them.
role?: LabRole | 'any';
skills?: string[];
location?: string;
limit?: number;
}): Promise<{ count: number; profiles: CoachResult[] }> => {
const kindMap: Record<string, number[]> = {
coach: [35001],
filmer: [35002],
editor: [35003],
any: [35001, 35002, 35003],
// Kind ↔ role for every Lab craft (35001..35012, minus 35006 = crews).
const ROLE_KIND: Record<LabRole, number> = {
coach: 35001, filmer: 35002, editor: 35003, photographer: 35004,
builder: 35005, artist: 35007, graffiti: 35008, tattoo: 35009,
trainer: 35010, musician: 35011, judge: 35012,
};
const kinds = kindMap[params.role ?? 'any'];
const KIND_ROLE: Record<number, LabRole> = Object.fromEntries(
Object.entries(ROLE_KIND).map(([role, kind]) => [kind, role as LabRole])
);
const ALL_LAB_KINDS = Object.values(ROLE_KIND);
const kinds =
params.role && params.role !== 'any' ? [ROLE_KIND[params.role]] : ALL_LAB_KINDS;
const signal = AbortSignal.any([AbortSignal.timeout(10000)]);
const relayGroup = nostr.group(DATA_RELAYS);
const raw = await relayGroup.query([{ kinds, limit: 200 }], { signal });
const raw = await relayGroup.query([{ kinds, limit: 500 }], { signal });
let profiles = deduplicateAddressable(raw as typeof raw & { tags: string[][] }[]);
// Skills live in `t` tags (parseCraftBase) — also check legacy `skill` tags + bio.
if (params.skills && params.skills.length > 0) {
profiles = profiles.filter((e) => {
const profileSkills = getTags(e, 'skill').map((s) => s.toLowerCase());
const profileSkills = [...getTags(e, 't'), ...getTags(e, 'skill')].map((s) => s.toLowerCase());
const bio = (e as unknown as { content: string }).content.toLowerCase();
return params.skills!.some(
(s) =>
@ -361,19 +416,24 @@ export function useCVMClient() {
);
}
profiles.sort((a, b) => b.created_at - a.created_at);
profiles = profiles.slice(0, params.limit ?? 10);
const roleLabel = (kind: number): 'coach' | 'filmer' | 'editor' =>
kind === 35001 ? 'coach' : kind === 35002 ? 'filmer' : 'editor';
// Self-tag markers we don't want to surface as "skills".
const SELF_TAGS = new Set([
'skatecoach', 'skatefilmer', 'skateeditor', 'skatephotographer', 'skatebuilder',
'skateartist', 'skategraffiti', 'skatetattoo', 'skatetrainer', 'skatemusician', 'skatejudge',
]);
const result: CoachResult[] = profiles.map((e) => {
const ev = e as unknown as { content: string; pubkey: string; kind: number; tags: string[][]; created_at: number };
const skills = [...getTags(ev, 't'), ...getTags(ev, 'skill')].filter((t) => !SELF_TAGS.has(t));
return {
role: roleLabel(ev.kind),
role: KIND_ROLE[ev.kind] ?? 'coach',
pubkey: ev.pubkey,
bio: ev.content.slice(0, 300),
location: getTag(ev, 'location'),
skills: getTags(ev, 'skill'),
skills,
experience: getTag(ev, 'experience'),
lightning_address: getTag(ev, 'lud16') ?? getTag(ev, 'contact'),
naddr: buildNaddr(ev),
@ -532,31 +592,183 @@ export function useCVMClient() {
[nostr]
);
const findShops = useCallback(
async (params: {
city?: string;
country?: string;
search?: string;
limit?: number;
}): Promise<{ count: number; shops: ShopResult[] }> => {
const signal = AbortSignal.any([AbortSignal.timeout(10000)]);
const relayGroup = nostr.group(DATA_RELAYS);
// Kind 30017 = NIP-15 stall; skate shops mark themselves with t=skateshop.
const raw = await relayGroup.query([{ kinds: [30017], limit: 500 }], { signal });
let shops = deduplicateAddressable(raw as typeof raw & { tags: string[][] }[]);
// Keep only skate shops (drops generic NIP-15 stalls from other apps).
shops = shops.filter((e) => e.tags.some((t) => t[0] === 't' && t[1] === 'skateshop'));
const locMatch = (e: typeof shops[0], q: string) =>
(getTag(e, 'location') ?? '').toLowerCase().includes(q) ||
(getTag(e, 'city') ?? '').toLowerCase().includes(q) ||
(getTag(e, 'name') ?? getTag(e, 'title') ?? '').toLowerCase().includes(q) ||
(e as unknown as { content: string }).content.toLowerCase().includes(q);
if (params.city) shops = shops.filter((e) => locMatch(e, params.city!.toLowerCase()));
if (params.country) shops = shops.filter((e) => locMatch(e, params.country!.toLowerCase()));
if (params.search) {
const q = params.search.toLowerCase();
shops = shops.filter((e) => locMatch(e, q));
}
shops.sort((a, b) => b.created_at - a.created_at);
shops = shops.slice(0, params.limit ?? 10);
const result: ShopResult[] = shops.map((e) => {
const ev = e as unknown as { content: string; pubkey: string; kind: number; tags: string[][]; created_at: number };
return {
name: getTag(ev, 'name') ?? getTag(ev, 'title') ?? 'Unnamed Shop',
description: ev.content.slice(0, 300),
location: getTag(ev, 'location'),
geohash: getTag(ev, 'g'),
image: getTag(ev, 'image') ?? getTag(ev, 'picture'),
website: getTag(ev, 'website'),
owner_pubkey: ev.pubkey,
naddr: buildNaddr(ev),
url: `https://skate-spots.org/shop/${ev.pubkey}/${getTag(ev, 'd') ?? ''}`,
created_at: new Date(ev.created_at * 1000).toISOString(),
};
});
return { count: result.length, shops: result };
},
[nostr]
);
const findTours = useCallback(
async (params: {
city?: string;
country?: string;
search?: string;
limit?: number;
}): Promise<{ count: number; tours: TourResult[] }> => {
const signal = AbortSignal.any([AbortSignal.timeout(10000)]);
const relayGroup = nostr.group(DATA_RELAYS);
// Kind 30823 = local skate tours (guides showing hidden spots).
const raw = await relayGroup.query([{ kinds: [30823], limit: 500 }], { signal });
let tours = deduplicateAddressable(raw as typeof raw & { tags: string[][] }[]);
const locMatch = (e: typeof tours[0], q: string) =>
(getTag(e, 'location') ?? '').toLowerCase().includes(q) ||
(getTag(e, 'city') ?? '').toLowerCase().includes(q) ||
(getTag(e, 'title') ?? '').toLowerCase().includes(q) ||
(e as unknown as { content: string }).content.toLowerCase().includes(q);
if (params.city) tours = tours.filter((e) => locMatch(e, params.city!.toLowerCase()));
if (params.country) tours = tours.filter((e) => locMatch(e, params.country!.toLowerCase()));
if (params.search) {
const q = params.search.toLowerCase();
tours = tours.filter((e) => locMatch(e, q));
}
tours.sort((a, b) => b.created_at - a.created_at);
tours = tours.slice(0, params.limit ?? 10);
const result: TourResult[] = tours.map((e) => {
const ev = e as unknown as { content: string; pubkey: string; kind: number; tags: string[][]; created_at: number };
return {
title: getTag(ev, 'title') ?? 'Untitled Tour',
description: ev.content.slice(0, 300),
location: getTag(ev, 'location'),
geohash: getTag(ev, 'g'),
price: getTag(ev, 'price'),
image: getTag(ev, 'image'),
guide_pubkey: ev.pubkey,
naddr: buildNaddr(ev),
url: `https://skate-spots.org/tour/${ev.pubkey}/${getTag(ev, 'd') ?? ''}`,
created_at: new Date(ev.created_at * 1000).toISOString(),
};
});
return { count: result.length, tours: result };
},
[nostr]
);
const getCommunityStats = useCallback(async (): Promise<CommunityStats> => {
const signal = AbortSignal.any([AbortSignal.timeout(10000)]);
const relayGroup = nostr.group(DATA_RELAYS);
const now = Math.floor(Date.now() / 1000);
const [spots, events, coaches, filmers, editors, gear, crews] = await Promise.all([
relayGroup.query([{ kinds: [30818], limit: 2000 }], { signal }),
// One query per content type. Lab crafts are counted individually so the
// stats match The Lab's per-craft tabs (coaches..judges). Everything here is
// PUBLIC data — the private encrypted Stash (kind 30001) is never touched.
const [
spots, events, gear, crews, shops, tours,
coaches, filmers, editors, photographers, builders,
artists, graffiti, tattoo, trainers, musicians, judges,
] = await Promise.all([
relayGroup.query([{ kinds: [30818], '#t': ['skatespot'], limit: 2000 }], { signal }),
relayGroup.query([{ kinds: [31923], limit: 500, since: now }], { signal }),
relayGroup.query([{ kinds: [30402], limit: 500, '#t': ['skateboarding'] }], { signal }),
relayGroup.query([{ kinds: [35006], limit: 500 }], { signal }),
relayGroup.query([{ kinds: [30017], limit: 500 }], { signal }),
relayGroup.query([{ kinds: [30823], limit: 500 }], { signal }),
relayGroup.query([{ kinds: [35001], limit: 500 }], { signal }),
relayGroup.query([{ kinds: [35002], limit: 500 }], { signal }),
relayGroup.query([{ kinds: [35003], limit: 500 }], { signal }),
relayGroup.query([{ kinds: [30402], limit: 500, '#t': ['skateboarding'] }], { signal }),
relayGroup.query([{ kinds: [35006], limit: 500 }], { signal }),
relayGroup.query([{ kinds: [35004], limit: 500 }], { signal }),
relayGroup.query([{ kinds: [35005], limit: 500 }], { signal }),
relayGroup.query([{ kinds: [35007], limit: 500 }], { signal }),
relayGroup.query([{ kinds: [35008], limit: 500 }], { signal }),
relayGroup.query([{ kinds: [35009], limit: 500 }], { signal }),
relayGroup.query([{ kinds: [35010], limit: 500 }], { signal }),
relayGroup.query([{ kinds: [35011], limit: 500 }], { signal }),
relayGroup.query([{ kinds: [35012], limit: 500 }], { signal }),
]);
const uniq = (rows: unknown) =>
deduplicateAddressable(rows as { pubkey: string; kind: number; tags: string[][]; created_at: number }[]);
const coachesN = uniq(coaches).length;
const filmersN = uniq(filmers).length;
const editorsN = uniq(editors).length;
const photographersN = uniq(photographers).length;
const buildersN = uniq(builders).length;
const artistsN = uniq(artists).length;
const graffitiN = uniq(graffiti).length;
const tattooN = uniq(tattoo).length;
const trainersN = uniq(trainers).length;
const musiciansN = uniq(musicians).length;
const judgesN = uniq(judges).length;
return {
spots: deduplicateAddressable(spots as typeof spots & { tags: string[][] }[]).filter(
spots: uniq(spots).filter(
(e) => e.tags.some((t) => t[0] === 't' && t[1] === 'skatespot')
).length,
upcoming_events: deduplicateAddressable(events as typeof events & { tags: string[][] }[]).length,
coaches: deduplicateAddressable(coaches as typeof coaches & { tags: string[][] }[]).length,
filmers: deduplicateAddressable(filmers as typeof filmers & { tags: string[][] }[]).length,
editors: deduplicateAddressable(editors as typeof editors & { tags: string[][] }[]).length,
gear_listings: deduplicateAddressable(gear as typeof gear & { tags: string[][] }[]).length,
crews: deduplicateAddressable(crews as typeof crews & { tags: string[][] }[]).filter(
upcoming_events: uniq(events).length,
coaches: coachesN,
filmers: filmersN,
editors: editorsN,
photographers: photographersN,
builders: buildersN,
artists: artistsN,
graffiti: graffitiN,
tattoo: tattooN,
trainers: trainersN,
musicians: musiciansN,
judges: judgesN,
lab_total:
coachesN + filmersN + editorsN + photographersN + buildersN +
artistsN + graffitiN + tattooN + trainersN + musiciansN + judgesN,
shops: uniq(shops).filter(
(e) => e.tags.some((t) => t[0] === 't' && t[1] === 'skateshop')
).length,
tours: uniq(tours).length,
gear_listings: uniq(gear).length,
crews: uniq(crews).filter(
(e) => e.tags.some((t) => t[0] === 't' && t[1] === 'skatecrew')
).length,
relays_queried: DATA_RELAYS,
@ -570,6 +782,8 @@ export function useCVMClient() {
findCoaches,
findGear,
findCrews,
findShops,
findTours,
getCommunityStats,
};
}