diff --git a/src/components/SasquatchGuide.tsx b/src/components/SasquatchGuide.tsx index c5fc95b..028d527 100644 --- a/src/components/SasquatchGuide.tsx +++ b/src/components/SasquatchGuide.tsx @@ -414,7 +414,11 @@ function formatLocalResults(intent: LocalIntent, result: unknown): string { return `No ${label} listed there yet 🦧 β€” be the first to add some at [skate-spots.org](https://skate-spots.org).`; } - const lines = items.slice(0, 6).map((it) => { + // Show a generous slice, then honestly summarise the rest so the headline + // count and the visible list never disagree. + const MAX_SHOWN = 10; + const shown = items.slice(0, MAX_SHOWN); + const lines = shown.map((it) => { const name = (it.name ?? it.title ?? it.role ?? 'Unnamed') as string; const url = it.url as string | undefined; const sub = (it.location ?? it.city ?? it.start ?? '') as string; @@ -429,7 +433,13 @@ function formatLocalResults(intent: LocalIntent, result: unknown): string { intent.tool === 'find_crews' ? 'πŸ‘₯' : intent.tool === 'find_shops' ? 'πŸͺ' : intent.tool === 'find_tours' ? 'πŸ—ΊοΈ' : 'πŸ›οΈ'; - return `${emoji} Found **${items.length}** ${label}:\n${lines}`; + + const remaining = items.length - shown.length; + const more = remaining > 0 + ? `\n\n…and **${remaining}** more β€” [see them all on the map](https://skate-spots.org/map) πŸ—ΊοΈ` + : ''; + + return `${emoji} Found **${items.length}** ${label}:\n${lines}${more}`; } /** diff --git a/src/hooks/useCVMClient.ts b/src/hooks/useCVMClient.ts index 98d6e42..44031be 100644 --- a/src/hooks/useCVMClient.ts +++ b/src/hooks/useCVMClient.ts @@ -182,6 +182,34 @@ function deduplicateAddressable(events: { pubkey: string; kind: number; tags: st return Array.from(seen.values()); } +/** Normalise a string for fuzzy matching: lowercase, trim, collapse whitespace. */ +function normalizeForDedupe(s: string | undefined): string { + return (s ?? '').toLowerCase().replace(/\s+/g, ' ').trim(); +} + +/** + * Collapse spots that are the SAME real-world spot published as different + * addressable events β€” e.g. the same place re-posted under a new `d` identifier + * or by two different accounts. Coordinate dedupe (kind:pubkey:d) can't catch + * these because the coordinates differ, so we also dedupe on a + * name + location/geohash signature and keep the freshest event. + */ +function deduplicateByIdentity(spots: T[]): T[] { + const seen = new Map(); + for (const e of spots) { + const name = normalizeForDedupe(getTag(e, 'title') ?? getTag(e, 'name')); + // Prefer geohash (precise) then location text as the place signature. + const place = normalizeForDedupe(getTag(e, 'g') ?? getTag(e, 'location') ?? getTag(e, 'city')); + // No name β†’ can't confidently match; keep it (unique key on coordinate). + const key = name ? `${name}|${place}` : `__unique__${buildNaddr(e as unknown as { kind: number; pubkey: string; tags: string[][] })}`; + const existing = seen.get(key); + if (!existing || e.created_at > existing.created_at) { + seen.set(key, e); + } + } + return Array.from(seen.values()); +} + function buildNaddr(event: { kind: number; pubkey: string; tags: string[][] }): string { return `${event.kind}:${event.pubkey}:${getTag(event, 'd') ?? ''}`; } @@ -215,6 +243,10 @@ export function useCVMClient() { e.tags.some((t) => t[0] === 't' && t[1] === 'skatespot') ); + // Collapse the same real-world spot re-posted under a different id/author + // (coordinate dedupe above can't catch those). Keeps the freshest copy. + spots = deduplicateByIdentity(spots as typeof spots & { tags: string[][]; created_at: number }[]); + if (params.difficulty && params.difficulty !== 'all') { spots = spots.filter( (e) => getTag(e, 'difficulty')?.toLowerCase() === params.difficulty