Commit graph

1217 commits

Author SHA1 Message Date
SkateSpots
5ccc191b32 feat(card): hero full-bleed redesign (no vertical rules)
The card had a portrait panel + dashed stub divider — two vertical lines with a
dead middle. Rebuilt as one composition: full-bleed photo/cover, a scrim, the
name + identity overlaid bottom-left, and the QR floating bottom-right. No photo
falls back to a themed gradient with a monogram watermark. Applied to all four
front types; the back is a plain list. Also: DEFAULT_RIDER_CARD.style is now ''
so a profile-less card doesn't claim 'Street'. Verified by rendering a real
profile, an app-profile, and an empty npub.
2026-10-02 12:25:36 +07:00
SkateSpots
0dd7e9bfa9 fix(card,profile): resolve the card's profile via the app's own relays
The card showed the placeholder (now initials) even when the npub had a photo:
useAuthor missed profiles that only live on the community relay and could drop
a profile its strict schema rejected. Card pages now use a tolerant
useCardProfile() that reads kind 0 via querySkateContent (community + user
relays) and JSON.parses leniently. Also: useAuthor now queries the community
relay as its own parallel branch (a flaky relay in the group used to reject the
whole batch); and '_@domain' NIP-05 no longer renders a lone '@'.
Verified by rendering a real profile's card headlessly.
2026-10-02 12:18:49 +07:00
SkateSpots
9dc7e7429d polish(card): roles/badges on their own line, QR nudged to centre
Verified both sides by rendering the public card headlessly (the studio preview
and /card/:npub use the same canvas renderer).
2026-10-02 12:06:39 +07:00
SkateSpots
3b542caade fix(card): smaller name, big right-side QR, cleaner geometry
- Name 82px -> 46px (was truncating 'KEEN HAWK' and dominating).
- QR now a proper right-hand verify stub: 214px band with a dashed divider and
  a 156px QR (was a small bottom-corner stub). Applied to all front types.
- Rebalanced geometry (portrait 244, text 338, stub 214); identity/badges sit on
  the lower band; back side stays plain typographic with a small right QR.
- Fixed a string of fillText(fitText(...)) calls that were missing their x.
2026-10-02 12:03:07 +07:00
SkateSpots
84665adc8a feat(card): redesign as a 'Credential' + unify to ONE canvas renderer
Design: the card read like a spec sheet (16 competing elements, 5-6px type).
Rebuilt as an editorial credential:
- Left full-height portrait panel; right column = brand, ONE huge name, a single
  identity line (style / level / local), nip05, crew monograms + witnessed
  badges, and a clean QR verify stub. Dropped the tiny stats row, npub clutter,
  'Powered by Nostr' filler and the 'SKATER LICENSE' chrome.
- Same system applied to Park / Crew / Event fronts; typed-up back side.

Engine: the studio preview and /card/:npub now render the canvas PNG, so there
is a SINGLE renderer (src/lib/riderCardCanvas.ts) — no React/canvas drift. The
old React card components + CardQr are deleted. Holographic is now a canvas
sheen. Also fixed several fillText calls missing an x-coord.
2026-10-02 11:57:32 +07:00
SkateSpots
ce0e004a59 feat(card): customization depth — custom accent, logo, back side, pass expiry
- Custom accent colour (native picker) overrides the theme accent via
  --ss-accent in CSS + the canvas palette.
- Optional personal logo/crest (Blossom upload) shown next to the name.
- Printable back side (RiderCardBack React + drawCardBack canvas); Print renders
  both sides, and the preview + public card can flip.
- Park passes enforce valid-thru: passExpiry() drives an EXPIRED badge on the
  card and verify page; the input is now a date picker.
- Card schema -> v:3 (accent/logo/back). i18n; NIP.md + AGENTS.
2026-10-02 11:47:19 +07:00
SkateSpots
9891101452 feat(card): Card Studio redesign + drop self-claimed skills
- Two-pane studio: sticky live preview (desktop) + inspector column; mobile
  stacks preview-first.
- Actions decluttered to a single primary Share + a '...' menu (Save PNG /
  Print / Copy ID); removed the competing big buttons.
- Unsaved-changes state + manual Save changes button (no autosave, so an
  interactive signer like Amber isn't prompted per keystroke).
- Card type is now a compact 4-up chip row; the two toggle grids merged into
  ONE 'Show on card' list with live values.
- Skills: removed the self-claimed skill chips (marginal/unverifiable). The card
  shows witnessed badges (kind 1293) only; style + level are now DERIVED from the
  Skater profile (kind 35000) instead of a manual 'Skate Style' select.
- React card + canvas export updated; i18n cardUi.studio.*; NIP.md + AGENTS.
2026-10-02 11:24:51 +07:00
SkateSpots
7cafcc0dfa feat(card,review): Event pass/wristband template + Claims tab in /review
- /card gains a fourth type: Event. Renders an event (kind 31923) you organise
  or RSVP'd going/interested to (useMyRsvpCoords, kind 31925) as a passport/
  wristband: title, date/time, venue, cover, who it admits, going count.
  Derived from the event's own signed event — its QR verifies that event;
  /verify/:naddr renders kind 31923 too. React + canvas renderers; i18n.
- /review gains a dedicated Claims tab (pending private-spot ownership claims),
  replacing the banner that sat above the tabs.
2026-10-02 11:13:40 +07:00
SkateSpots
411f6e219a feat(spots): claim ownership of a private spot (NIP-05 + moderator confirm)
Private spots can't be domain-verified like shops, so ownership is a gated
two-step flow — never an instant takeover:
- A NIP-05-verified user files a claim (kind 1985 label in the separate
  org.skatespots.spotclaim namespace) on a spot marked access=private.
- A moderator approves by publishing claim-approved (moderation namespace,
  a=coord + p=claimant); surfaced in a pending-claims list atop /review.
- The claimant then finishes by re-publishing the spot with
  claim-reason=private-approved (existing claim mechanism).

Adds useSpotClaims/useApprovedSpotClaims/useAllSpotClaims/useSubmitSpotClaim,
useAdminApproveClaim, the private branch in SpotOwnership, i18n, and docs.
Non-private spots keep the abandoned-claim flow. Nothing is auto-transferred.
2026-10-02 11:06:58 +07:00
SkateSpots
391eeb9bf9 fix(ux): recoverable route error screen + smoke-test shop/spot routes
- The per-route 'slammed' screen only offered 'Go back', so a stale-chunk
  failure after a deploy trapped the user. Add a primary 'Reload' that clears
  the lazyWithRetry session flag and does a fresh reload.
- Smoke test now boots /shop/:pubkey/:identifier, /spot/…, /event/…, /crew/…
  so a broken detail route can't ship again.
- Investigation note: no reproducible slam across 11 live shops; the report
  matched a transient chunk miss from two rapid deploys.
2026-10-02 10:52:18 +07:00
SkateSpots
ad75120a43 feat(card): Crew Card template (derived from the crew's signed event)
- /card gains a third type: Crew. Pick one of your crews and render a printable
  crew card (name, logo, home turf, founded, members, spots, vibes, Open call).
- No new event: the card is derived from the crew's own signed kind 35006, so its
  QR verifies that event (the founder's key IS the signature).
- Rendered in the React card + canvas export; /verify/:naddr now renders kind
  35006 with a link to the crew page.
- i18n cardUi.crew.* / verify.kindCrew; NIP.md + AGENTS.md updated.
2026-10-02 10:42:26 +07:00
SkateSpots
e348796e05 feat(card): identity facets — crew crests, local badge, Lab role chips
- Card shows crew crests (kind 35006 memberships), a local badge (skater
  profile location/home_spot), and Lab role chips (kinds 35001-35014) — all
  DERIVED LIVE, so a card can't claim a role/crew the skater doesn't have.
- Rendered in the React card + the canvas export (print/PNG); local/crews as
  text lines, roles as blue chips.
- Per-facet show toggles in the editor; card schema bumped to v2 so the new
  flags default ON for cards saved before them.
- Also fixed: verifyUrl wasn't passed to the canvas renderer (QR fell back to
  the profile path).
- i18n cardUi.facets.* / cardUi.roles.*; NIP.md + AGENTS.md updated.
2026-10-02 10:35:06 +07:00
SkateSpots
dbb9ea98c0 feat(card): make Rider Cards real & verifiable
- Publish the Rider Card as an addressable event (kind 32271, d=rider-card) and
  hydrate the editor from it, so customization persists across devices.
- New routes: /card/:npub (public read-only card) and /verify/:naddr
  (signature check + park venue binding).
- Park Access Cards can bind to the issuer's own spot (a tag 30818:...) and the
  verify page confirms the issuer owns the venue.
- QR on the card (+ canvas export) now encodes the verify URL; the real QR
  renders in the React card too (was a placeholder / broken profile path).
- Extracted shared card visuals to src/components/RiderCard.tsx; helpers in
  src/lib/riderCard.ts; hooks useRiderCard/useRiderBadges.
- Documented in NIP.md; i18n cardUi namespace (EN/ES); smoke test covers the
  new routes.
2026-10-02 10:26:54 +07:00
SkateSpots
0b796440f1 feat(i18n): Spanish batch 2 — nav/chrome, events/shops/tours, crews/DMs, settings/auth, wallet/stash/newsletter/stats
~3,470 new keys (5,770 total, EN=ES), ~115 more files wired.
- nav + chromeUi: header, More menu, burger menu, footer, map toolbar widgets
  (sort/trust/weather), spot-type label helper.
- placesUi: events, shops, tours, food. socialUi: crews, DMs, notifications,
  collectives, crew chat/coordinator. settingsUi: settings, auth/signup,
  onboarding, profile edit, legal. walletUi: wallet/Cashu, stash, mints,
  app shop, newsletter, community stats, leaderboards, profile.
- Fixed 738 usages where an agent dropped the socialUi. prefix (caught by check:i18n).
2026-10-02 10:11:06 +07:00
SkateSpots
c205ad34f0 docs(i18n): record batch-1 coverage + checker, list remaining areas 2026-10-01 23:58:13 +07:00
SkateSpots
09735375c2 feat(i18n): Spanish (Castellano) — infra + Map/Spots, Marketplace, Lab (batch 1)
- First-visit language auto-detect (Spanish devices land in es; stored choice wins).
- Sasquatch AI replies in the app language (skate trick names kept English).
- i18n safety net: scripts/check-i18n.mjs wired into npm test — fails on any t()
  key missing from en/es or any en/es key drift.
- ~1,680 new keys (2,297 total) across mapUi / spotUi / marketUi / labUi and
  ~65 files wired to t(): Map, spots (detail/create/edit/chat), marketplace,
  trades, offers, escrow, shipping, The Lab, /become/* forms, coaches, skaters.
- Policy: only skate TRICK names stay English; user-generated content untouched.
2026-10-01 23:57:20 +07:00
SkateSpots
9155949fda feat(push): extend categories to crew + social; master switch tears down push
- CATEGORY_BY_KIND now maps crew invite (36229) / join-request (31969) -> crew and
  zap (9735) / reply-mention (1) / comment (1111) -> social; the relay watch
  includes those kinds. Reactions/reposts are deliberately not pushed (noisy) and
  newsletter has no push channel.
- Master switch off now also disables web push and the native poller.
- Verified: a crew-only token is not woken by a zap but is by a crew invite.
2026-10-01 20:20:10 +07:00
SkateSpots
4df961b015 feat(push): category-aware notifications (trades-only actually means trades-only)
- /subscribe + /native/poll/register carry the enabled categories; the server
  stores them per subscription/token and only pushes matching ones (DM wrap ->
  dms; offer/counter/accept/attestation/shipment -> trades). Absent = legacy
  (all), empty array = none.
- /pending uses a per-category activity breakdown so it only moves for the
  token's categories; the smoke test proves a DM does NOT wake a trades-only token.
- usePushNotifications/useBackgroundDelivery send categories (from device prefs)
  and expose resubscribe(); NotificationSettingsCard re-sends on toggle change.
2026-10-01 20:01:07 +07:00
SkateSpots
3ad214683a feat(android): native background poller plugin (no Google) - Cordn pattern
packages/skatespots-background: a local Capacitor Android plugin that polls our
own push-service /pending and raises local notifications. Modes: off / standard
(WorkManager ~15 min) / fast (foreground service ~5 min). Kotlin +
AndroidManifest (POST_NOTIFICATIONS, FOREGROUND_SERVICE_DATA_SYNC, WAKE_LOCK,
battery-exemption) + Gradle module. Client: useBackgroundDelivery (registers a
poll token via NIP-98, seeds the plugin) + a Background delivery (Android)
control in NotificationSettingsCard. The app registers by NAME, not by importing
the package, so the web bundle is unaffected. UNVERIFIED - needs cap sync + a
device.
2026-10-01 19:51:56 +07:00
SkateSpots
5785a77211 feat(push): server-side native background polling (no Google) - Cordn pattern
Adds the content-free activity cursor the Android APK can poll from a native
worker: POST /native/poll/register (NIP-98 -> poll token; the pubkey joins the
relay watch), GET /pending?token&since -> { seq, at, changed }, and
/native/poll/unregister. pushTo() bumps recordActivity(). Verified locally:
register -> offer addressed to the pubkey -> seq 0 -> 1, changed=true. No Google,
no decryption; the native plugin is the remaining piece.
2026-10-01 19:47:21 +07:00
SkateSpots
3d18dcf5f3 docs(agents): Google/FCM rejected - native push must be UnifiedPush or foreground service 2026-10-01 19:31:46 +07:00
SkateSpots
68c12a2297 Revert "feat(push): Android FCM (Phase 2) - native device support"
This reverts commit 5f0a9537b0.
2026-10-01 19:31:24 +07:00
SkateSpots
5f0a9537b0 feat(push): Android FCM (Phase 2) - native device support
Server: /native/subscribe + /native/unsubscribe (NIP-98 proof), FCM device store,
and FCM HTTP v1 sending (service-account OAuth2, cached ~1h); /health reports
native + device count. 503 without PUSH_FIREBASE_SERVICE_ACCOUNT_JSON.
Client: @capacitor/push-notifications (lazily imported, native-only) in
usePushNotifications - request permission, register, store the FCM token, and
subscribe via /native/subscribe; tap deep-links into the app.
Needs a Firebase project: google-services.json + the Gradle plugin (android/),
and the service-account JSON in the host .env.
2026-10-01 19:28:54 +07:00
SkateSpots
4b831ebcc9 docs(agents): push service is deployed (push.skate-spots.org, Caddy + DNS) 2026-10-01 18:47:15 +07:00
SkateSpots
9a82483cd3 docs(agents): record the barter-deposit affordance 2026-10-01 18:40:31 +07:00
SkateSpots
4f0416c23d feat(barter): optional good-faith deposit with a verifiable receipt
A pure swap has no invoice, so ListingTradeActions now offers a 'Good-faith
deposit' input for price_type=trade listings. Paying it stores a deposit-slot
receipt (saveReceipt(coord, r, 'deposit')); the buyer's confirm attaches it as
['proof','deposit',preimage,payment_hash] with method=trade, so a barter carries
a verifiable COST without pretending the goods were proven.
2026-10-01 18:39:30 +07:00
SkateSpots
e7c6bb1ece feat(receipts): verifiable Lightning preimage receipts (money legs)
- getPaymentHashFromBolt11 (bolt11.ts, dependency-free; matches the BOLT11 spec
  test vector) + verifyPreimage (sha256(preimage) === payment_hash).
- usePayPaymentPointer captures the preimage from NWC/WebLN/melt and returns a
  PaymentReceipt; PayWithSats.onPaid carries it; stored device-locally so the
  later confirm step attaches it; publishTrade writes
  ['proof','lnpreimage',preimage,hash] (and ['proof','deposit',...] for barter).
- useTradeReputation verifies and counts it in paymentVerified.
- Honest scope: proves PAYMENT, not delivery; money legs only - a pure hardware
  swap has no invoice, stays attested; a deposit receipt gives barter real cost.
2026-10-01 17:25:16 +07:00
SkateSpots
4b1c025901 feat(shipping): meet-at-a-spot suggestions + shipment status (kind 34967)
- HandoffHelper suggests the 3 nearest public skate spots to the listing as
  one-tap DM meetup templates (public data only; never the viewer's location).
- New addressable kind 34967 + useShipmentStatus + ShippingStatusCard: seller
  marks shipped, buyer received, either issue. p-tagged so the push service
  notifies (added to MARKET_KINDS); d=ship:<listing-d>:<author> so a status
  replaces only its author's.
- Honest framing: a self-reported claim, NOT proof - 'received' does not release
  escrow; no address stored; parcel photos with an address discouraged.
2026-10-01 17:01:57 +07:00
SkateSpots
16c787d3f3 feat(marketplace): shipping flow - who-pays, distances, guided handoff
- ['shipping','buyer'|'seller'|'split'] tag + create-page selector.
- Distance from the viewer's last known location (cache only, no GPS prompt) on
  both gear cards, and a 'Nearest (local)' sort on GearSwapPage.
- HandoffHelper (buyer-only, in ListingTradeActions) opens the trade DM
  pre-filled: suggest a time & place / share delivery details / use a locker,
  with anti-scam copy. Details stay in the NIP-17 DM; the app never sees them.
- ships deals now nudge safety on all platforms, not just when escrow is ready.
2026-10-01 16:55:27 +07:00
SkateSpots
a12db574d4 feat(marketplace): NIP-13 proof of work - free CPU anti-spam
pow.ts: minePow/verifyPow/countLeadingZeroBits. PoW pays NO ONE - it is a free
CPU tax, not a fee. useNostrPublish accepts pow and mines the nonce on the
UNSIGNED event before signing (id is signature-independent -> one signer prompt).
CreateListingPage sends 16 bits per listing (~80ms) and 20 bits when a newcomer
chooses work instead of the 1,000-sat fee (SpamFeePayment.onPowInstead = the free
alternative). parseMarketplaceItem exposes powDifficulty; the card shows a PoW
badge at >=16.
2026-10-01 16:42:10 +07:00
SkateSpots
864a384f60 feat(reputation): activity weighting + closed-ring detection
useTradeIntegrity.ts adds two anti-sybil levers to TradeReputationCard:
- useBatchActivity counts each counterparty's published spots+listings and weights
  their contribution by min(1, activity/3), so a ring of empty accounts
  contributes ~0 even when cross-confirmed.
- useTradeRings does a 2-hop query of the partners' own attestations and measures
  closedness (share of partner-partner trades); >=3 partners with >=0.5 closedness
  flags a closed cluster. PoW (NIP-13) remains, but needs mining at publish.
2026-10-01 16:26:30 +07:00
SkateSpots
8c5b1636f7 feat(marketplace): local-first fulfilment (pickup/ships) + ring warning
- Listings carry optional ["fulfilment","pickup"|"ships"|"both"] (selling only):
  a create-page selector (default pickup), a card badge, and a Pickup/Ships filter
  on GearSwapPage. The app NEVER collects an address - a ships deal exchanges it
  privately in the NIP-17 trade chat, and ListingTradeActions nudges escrow for
  shipped deals. No KYC rail, no labels, no carrier API.
- TradeReputationCard adds a low-diversity ring warning (verified >= 5 across <= 2
  people) beyond the single-partner one.
2026-10-01 16:18:42 +07:00
SkateSpots
50735d1718 feat(reputation): verifiable payment tier via NIP-57 zap receipts
A trade proof of ['proof','zap','<receiptId>'] is now checked by verifyZapReceipt
(kind-9735 signature + p = seller + decodable bolt11 amount + embedded 9734
request) and counted as paymentVerified, shown on TradeReputationCard as
'payment-verified (zap receipt)' - a real tier above the self-reported claim.
Honest ceiling: proves the seller's LNURL provider acknowledged a payment, not
that goods changed hands. NIP.md documents the zap proof variant.
2026-10-01 16:14:55 +07:00
SkateSpots
eec56db124 feat(marketplace): Buy with sats for fixed-price listings
A price_type=fixed listing now shows a direct 'Buy with sats' button in
ListingTradeActions (card + detail) - no negotiation needed. The price resolves
to sats (SATS directly, USD via useUsdToSats) and pays through PayWithSats (NWC ->
WebLN -> Cashu melt, falling back to the seller's lud16). The sheet mounts only
while open so the wallet hooks stay off every grid card. Reuses the accepted-offer
Pay rail.
2026-10-01 16:11:14 +07:00
SkateSpots
baa770f1b8 feat(push): push marketplace events (offers/requests/confirms), not just DMs
The service now also subscribes to marketplace events addressed to subscribers
(kinds 30403/31872/31871) via #p filters built from the live subscription set,
re-subscribed on every subscribe/unsubscribe. Payloads stay generic ('New offer
on your listing', 'Your offer was accepted') - never the amount or item. Verified
locally: subscribe -> offer to #p -> push attempt.
2026-10-01 16:07:21 +07:00
SkateSpots
a538bd7add feat(reputation): surface 'payment proof' as its own tier, never as verified
The proof tag is self-reported and unverifiable client-side, so it must not read
as proof of payment. useTradeReputation now exposes withPaymentProof (mutual
trades carrying a proof), shown on TradeReputationCard as 'with payment proof
(self-reported - not verified)' distinct from the verified count.
2026-10-01 15:57:42 +07:00
SkateSpots
1a834f7b40 fix(stats): robust trade volume instead of a sybil-inflatable sum
The marketplace 'Sats Traded' was a naive sum of the self-reported amount tag on
31871, which two colluding accounts could inflate for free. tradedSats is now the
robust figure - mutual trades only (a listing attested by >=2 distinct pubkeys),
per-pair cap 3, amounts >1 BTC dropped - with tradedCount + tradedTraders, and
tradedSatsClaimed keeps the raw claim so the card shows both. Added query fields
=> PERSIST_BUSTER v5. No trade attestations exist on the relay yet (honest 0).
2026-10-01 15:53:13 +07:00
SkateSpots
38317ee2a5 feat(push): client half - subscribe flow, service worker, DM marker
- public/push-sw.js: shows the content-free push; tap focuses/opens the app.
- src/lib/pushService.ts + usePushNotifications: register the SW, subscribe with
  the service's VAPID key, sign a NIP-98 challenge proof, POST /subscribe.
- NotificationSettingsCard: a 'Background notifications' toggle (opt-in,
  per-device) + honest metadata copy; corrected the now-stale 'we run no push
  server' note.
- useSendDirectMessage: marks the RECIPIENT wrap ["skatespots","push"] (never
  the self copy) and adds the community relay as the notify relay so the push
  service sees it.
- /privacy: discloses the push server sees metadata only.
2026-10-01 15:39:04 +07:00
SkateSpots
807037ee9b feat(push): self-hosted closed-app push service (Phase 1 backend)
Our own keyless Bun service (not Linky's): watches relays for kind-1059 wraps
carrying the plaintext marker ["skatespots","push"] on the recipient copy,
extracts only {recipient, wrapId}, and sends a Web Push. NEVER decrypts - the
client fetches + decrypts the named wrap itself.

Endpoints: GET /health, GET /vapid-public-key, POST /auth/challenge ->
/subscribe|/unsubscribe with a one-time NIP-98 (kind 27235) challenge proof.
State is a JSON file; VAPID keys from env. Wired like the other standalone
servers (typecheck:push + check:push in npm test, deploy:push, Caddyfile +
systemd unit). Verified locally end-to-end: auth -> subscribe -> marked wrap ->
watcher -> push attempt, and proof replay rejected.

Client half (service worker, subscribe hook + Settings toggle, marker on DM
sends) is the next step.
2026-10-01 15:34:43 +07:00
SkateSpots
b1d21f89ba feat(marketplace): pay an accepted offer in-app
Phases 1-2. The seller may attach a ["payment", value, kind] pointer to the
accepted 30403 message (lninvoice|lnaddress|creq|clink, optional payment_hash);
ListingOffersPanel attaches the seller's own lud16 on accept, falling back to
their profile lud16 on the buyer's side. The pointer rides the accepted event so
it flows through the existing decrypt-free trade notification path (a NIP-17 DM
can't reach the Trades tab without decrypting).

Buyer gets a 'Pay with sats' button -> PayWithSats dialog -> usePayPaymentPointer
pays via NWC -> WebLN -> Cashu melt (mirrors useZaps). Nothing is held or signed
by us; recording the trade stays the existing 31871 flow. creq/clink are
recognized but not payable yet. No new event kind.
2026-10-01 15:08:17 +07:00
SkateSpots
a355a90408 feat(wallet): parse BIP21 + Cashu payment requests in the QR scanner
Numo (and other POS) emit a BIP21 'bitcoin:?creq=...&lightning=...' QR. The
scanner treated it as 'unknown', so a Numo terminal couldn't be paid at all.

classifyQRCode now parses BIP21, extracting lightning= (paid via the existing
NWC/Cashu-melt path) and creq=, and recognizes standalone NUT-18 creq1.../creqA...
as a new 'cashu_request' type. cashu_request is recognized but not payable yet -
CashuWalletCard shows an honest message. BIP21 parsing avoids URLSearchParams so
a raw creqA... base64 '+' isn't coerced to a space.
2026-10-01 14:59:14 +07:00
SkateSpots
fe7e5cfae4 feat(spots): tag new spots/shops by default with a BigDataCloud fallback
Create/edit geocoding used Nominatim alone, so when it failed (e.g. the 429) a
new spot published with no city/country and landed in 'Uncategorised'. Now
reverseGeocodeRegions() tries Nominatim, then falls back to BigDataCloud's
client-side reverse-geocode-client, so a spot is region-tagged by default. Still
never throws (an outage can't block a publish); the spot keeps its geohash and
the stats' read-time derivation can still place it. derivedRegions reuses the
same geocodeBigDataCloud helper (one implementation).
2026-10-01 14:37:29 +07:00
SkateSpots
8fd5bab3f1 fix(stats): resolve derived regions via BigDataCloud, not Nominatim
Nominatim returned 429 for our IP because the read-time derivation runs from
every viewer on every load — its usage policy forbids that, and a 429 makes
reverseGeocodeRegions() (which returns empty on !res.ok) fail silently, freezing
the resolve counter and stalling the region card.

The stats fallback now uses BigDataCloud's reverse-geocode-client (no key, CORS
on both hops, English countryName/countryCode/city), spaced ~300ms. Nominatim
stays for create/edit geocoding only (author-initiated, one call per spot).
/privacy now names both providers.
2026-10-01 14:31:49 +07:00
SkateSpots
6091a0acd8 fix(stats): tick the region-derivation counter live and merge name/code groups
Two issues from the first derived-regions pass:
- The 'Resolving N...' counter only updated when the whole pass finished, so it
  looked frozen. fillDerivedRegions now reports progress per pin, the hook ticks
  the counter down, and the card refreshes every 5 resolved pins so the numbers
  climb while you watch.
- A country could appear twice ('United Kingdom' 1 + 1) when one pin carried a
  country_code and another only a name. Learn name->code from pins that have
  both and fold name-only pins into the same bucket.
2026-10-01 14:22:02 +07:00
SkateSpots
7bad809878 feat(stats): derive region from a pin's coordinates when it has no tag
The region card only placed pins carrying a city/country tag, so legacy pins
(coords but no tag) drew on the map yet landed in 'Uncategorised' — the map and
the stats disagreed. useGrowthStats now falls back to a cached reverse geocode
of the pin's own coordinates (lib/derivedRegions, localStorage keyed by ~110m
coords). useDeriveRegions (mounted on /stats) fills cache misses at 1.1s spacing
for Nominatim's 1 req/s policy and invalidates growth-stats; the card shows a
'Resolving N...' note meanwhile. Display-only fallback; the admin backfill still
writes real tags. Read-time geocoding disclosed on /privacy.
2026-10-01 14:17:04 +07:00
SkateSpots
02b81a8b76 feat(stats): put the region backfill right on the stats page
The admin backfill lived only in Settings -> Advanced, so an admin staring at
the 'Uncategorised' row had no obvious way to fix it. Render the admin-only
RegionBackfillCard inline in the region section when uncategorised > 0, and
clarify the zero-state (remaining untagged pins belong to other mappers).
2026-10-01 14:07:14 +07:00
SkateSpots
dc0ae9bc32 feat(stats): admin backfill for legacy untagged spots/shops
Spots created before reverse geocoding have coordinates but no city/country
tag, so the map draws them while /stats can only show "Uncategorised". Counted
on the canonical relay: 54 raw -> 43 after admin-hide -> 13 tagged / 30
uncategorised, and 21 of the 30 are the two admin accounts' own pins.

useRegionBackfill finds the logged-in user's own untagged spots/shops, decodes
each pin's coords (lat/lon else geohash), reverse-geocodes via the shared
reverseGeocodeRegions(), and republishes the addressable event with the region
tags added - preserving every existing tag. Publishes to getWriteRelays()
(community + author) so the canonical stats see it, spaced 1.1s for Nominatim's
1 req/s policy. Surfaced as an admin-only RegionBackfillCard in Settings ->
Advanced.
2026-10-01 13:58:21 +07:00
SkateSpots
cc4e1cd5ca fix(spots): editing a spot no longer drops its city/country tags
EditSpotPage rebuilds eventTags from scratch, so saving a spot replaced the
addressable event WITHOUT its city/country/country_code — silently losing the
region tags it had. It also meant re-saving a legacy spot could never fix a
missing tag.

Both edit pages (and both create pages) now go through the shared
reverseGeocodeRegions()/pushRegionTags() helper, so region tags are re-derived
on every save. Re-saving a legacy spot now tags it.

Docs: AGENTS.md (any page that republishes an addressable event must
re-derive or preserve its region tags).
2026-10-01 13:37:54 +07:00
SkateSpots
6d2492d847 fix(stats): apply moderation to the stats, like the map already does
A spot the community has hidden as 'not a skate spot' no longer inflates the
community numbers.

- useGrowthStats calls useAdminModeration() and drops hidden content inside
  dedup(), so it's excluded from totalSpots, the region breakdown and the
  trusted counts (kind-agnostic: the coord is built from each event's own kind).
- The hidden-set sizes are part of the query key, so the stats recompute once
  the labels load.
- Region footnote says 'community relay only, with moderation-hidden content
  excluded'.

This narrows the map<->stats gap. The remaining deltas are legitimate and
documented in AGENTS.md (untagged pins; auto-hidden spots).
2026-10-01 13:33:51 +07:00
SkateSpots
9fe219aac6 feat(stats): reconcile the region card, and geocode shops too
Answers 'why don't the map and stats match' by making the gap visible, and
brings shops into the region breakdown.

- src/lib/geocodeRegions.ts is now the single reverse-geocode helper (spot
  create, shop create, shop edit), so the Nominatim call + Accept-Language + the
  parse rules can't drift. Never throws.
- Shops now publish city/country/country_code (previously none), so they can
  appear in the region breakdown.
- Stats: regions cover spots AND shops; a new 'Uncategorised (N)' row plus a
  '{N} of {M}' footnote let the card reconcile with the location total. Section
  retitled 'Spots & Shops by Region'.
- PERSIST_BUSTER v3 -> v4.
- /privacy: wording now 'spot or shop geocoding'.

The map deliberately still reads the community relay + the reader's relays
(forcing canonical would hide an opted-out user's own spots) - documented.
2026-10-01 13:27:44 +07:00