From 8b7d9ca4eabbf871522fe3fde0e7c2029826eadb Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Wed, 9 Sep 2026 23:17:15 +0700 Subject: [PATCH] Fix Sasquatch spot results: honest count + collapse duplicates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs from "spots in bangkok" (said "Found 20" but showed 6, with repeats): 1. Misleading count: formatLocalResults hard-capped the visible list to 6 while the headline reported the full count. Now shows up to 10 and appends a truthful "…and N more — see them all on the map" line, so the number and the list always agree. 2. Duplicate spots: coordinate dedupe (kind:pubkey:d) can't catch the same real-world spot re-posted under a different identifier or by another account (e.g. "Yellow Park – Sukhumvit 50" appearing twice). Added deduplicateByIdentity() — a second-pass dedupe on a normalized name + geohash/location signature, keeping the freshest event — applied in findSpots so the count and list are clean. Both fixes flow through the AI tool-call path and the offline fallback path. Co-authored-by: shakespeare.diy --- src/components/SasquatchGuide.tsx | 14 ++++++++++++-- src/hooks/useCVMClient.ts | 32 +++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) 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