Commit graph

849 commits

Author SHA1 Message Date
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
SkateSpots
58154bf5d5 feat(stats): stable country grouping, an Other row, and disclose Nominatim
Answers 'how are these accounted?' and fixes the privacy-page gap it exposed.

- Spot creation now also publishes ['country_code', <ISO-3166-1 alpha-2>], kept
  alongside the display 'country' name so existing search/display still work.
- Stats group countries by country_code when present, else a lowercased name;
  the label is the most common raw name in the group, so a localised spelling
  can't split one country across two rows.
- The tail beyond the top 6 is surfaced as an 'Other' row, and the section is
  relabelled 'Spots by Region' (it counts spots, not people) with a footnote.
- PERSIST_BUSTER v2 -> v3 (GrowthStats shape changed again).
- /privacy: disclose OpenStreetMap Nominatim — spot geocoding sees your IP and
  the spot's pin (never the device's GPS). It was missing from the IP list.

Docs: AGENTS.md.
2026-10-01 13:16:02 +07:00
SkateSpots
863b6a115d fix(stats): don't crash on a persisted cache from the old data shape
/stats slammed with 'Cannot read properties of undefined' for anyone whose
IndexedDB held a growth-stats cache saved before the new fields existed —
stats.topCountries.length threw.

- Bump PERSIST_BUSTER v1 -> v2 (the documented way to invalidate a shape change;
  it is wired into App.tsx).
- Read the new fields defensively (stats?.x ?? []) so a future field can't do
  this again.
- AGENTS.md: note that the smoke test cannot catch this (clean profile, no
  persisted cache).
2026-10-01 13:02:21 +07:00
SkateSpots
d32df04c79 feat(stats): honesty banner, marketplace + regions, mobile Lab collapse
Three improvements to /stats.

1. A relay outage must not read as real zeros. Every sub-query swallowed its
   error, so a failed relay rendered a full board of 0s. The queryFn now counts
   failures and returns 'partial'; the page shows 'Couldn't reach every relay —
   these numbers may be incomplete'. Badge also gains 'updated Nm ago' + Refresh.

2. Surface more of the community: Marketplace (live listings, completed trades,
   self-reported sats, cash/barter split) and Regions (top countries/cities from
   each spot's own tags). Zap receipts are counted ONLY for the top contributors
   and labelled as not-global-volume, because global zap volume isn't measurable
   on a public relay.

3. Mobile/desktop: collapse the 13 Lab cards on mobile behind 'Show all' (a
   display:contents wrapper keeps them grid items); widen max-w-6xl -> max-w-7xl;
   StatCard gains a compact prop so big sats don't overflow.

Docs: AGENTS.md community-stats note.
2026-10-01 12:54:36 +07:00
SkateSpots
c1bf6516f5 fix(map): welcome banner no longer sits under the floating search toolbar
The 'New here?' banner was a later sibling of the absolute toolbar inside the
same map container, so it became the first in-flow child at y=0 and the floating
toolbar painted over it — the search bar covered the banner.

Move the banner to be the container's first child (above the toolbar) and have
the toolbar drop into flow when the banner is shown, so it can't cover it while
still floating over the map otherwise. Desktop is unchanged (md:static).

Docs: AGENTS.md map-layout note.
2026-10-01 12:31:30 +07:00
SkateSpots
257ce05a25 feat(auth): NIP-49 encrypted keys + NIP-46 nostrconnect QR login
Two Nostr-native auth upgrades (read-only npub sessions were declined).

NIP-49 (src/lib/ncryptsec.ts):
- Login: the Secret Key tab gains an 'Encrypted (NIP-49)' mode — paste
  ncryptsec1… + password -> decrypt -> login.
- Signup: the done step's key card exports a password-protected backup, which
  is safe to keep in cloud storage (unlike a raw nsec).
- Verified in Node: round-trips; wrong password, non-ncryptsec and empty
  password all rejected.

NIP-46 nostrconnect (src/lib/nostrConnect.ts):
- Desktop -> phone pairing via QR. Nostrify implements only the bunker
  direction; nostrconnect is signer-initiated, so the handshake lives here
  (ephemeral client keypair + secret, subscribe kind 24133 #p:[clientPub],
  reply {id,result:secret} signed with the ephemeral key), then hands the
  derived bunker:// URI to login.bunker().
- Try NIP-44 then NIP-04 (the transport moved NIP-04 -> NIP-44; support varies).
- LoginDialog's Bunker tab leads with the QR; bunker:// paste stays a fallback.
- NOT verified against a real signer — needs an on-device Amber check.

Docs: AGENTS.md.
2026-10-01 12:00:08 +07:00
SkateSpots
f953082217 feat(routes): give the map less of the screen; move the handoff note to /privacy
Owner feedback: the route dialog's map still dominated the phone screen, and
the 'nothing leaves to Google or Apple Maps' tagline cluttered the chrome.

- Map: 50vh -> 34vh on mobile (min-h 300 -> 220); desktop 440 -> 400. Follow
  mode 60vh -> 54vh, so the bigger map is kept only when you need it.
- Drop the visible tagline from the route header (kept sr-only for a11y); the
  promise now reads on /privacy -> 'What leaves your device' -> Directions
  ('routing stays in the app; start/destination are never handed off to Google
  or Apple Maps').

Docs: AGENTS.md map-layout note.
2026-10-01 11:21:30 +07:00
SkateSpots
09b51a764a fix(ops): don't flag a dirty-tree skip that's already been committed
The deploy timer can record a dirty-tree skip mid-edit; once the tree is clean
that reason is stale. Verify git status before flagging it (and parse the timer
NEXT row only when it is a real timestamp).
2026-10-01 11:15:16 +07:00
SkateSpots
5fff51c814 feat(transit): depart/arrive time picker, mode filters, and stop boards
Builds out the rest of the transit UX from data Transitous actually provides.

- Time picker: Leave now / Depart at / Arrive by (MOTIS time + arriveBy).
- Mode filter: Bus/Tram/Metro/Train/Ferry groups -> MOTIS transitModes.
- Stop departure boards: tap a stop name -> MOTIS stoptimes, with live and
  cancelled badges and a feed-provided platform hint.
- Fares: MOTIS carries NO prices (verified live: debugOutput.fares=0,
  agencyFareUrl empty), so a 'tickets' link is shown ONLY when the feed supplies
  an operator fare URL - never an invented price.

New src/lib/transitUi.ts holds the mode vocabulary (emoji/colour/groups) so the
itinerary and the board cannot drift; StopDepartures.tsx is its own dialog.

Docs: AGENTS.md transit note.
2026-10-01 11:14:34 +07:00
SkateSpots
126eb38330 fix(ops): don't cry wolf about a deploy that just hasn't ticked yet
A push seconds before an ops-health run has no skip reason yet — that is
normal, not a problem. Only flag a missing skip reason once the deploy script
hasn't run for >6 min (i.e. the timer really looks stopped).
2026-10-01 10:59:14 +07:00
SkateSpots
52ed2f89bd feat(transit): real itineraries — alternatives, leg-by-leg, mode badges
Transit showed one cryptic summary line and threw away 3 of 4 itineraries.
Surface what the MOTIS/Transitous response already contains.

routing.ts: TransitLeg gains headsign, agencyName, from/to track, stop count,
realTime and distance; RouteResult gains transitOptions (all itineraries,
fastest first). fetchTransitRoute maps every itinerary (the API returns more
than numItineraries).

RouteDialog:
- Alternatives row — tap to switch; a dedicated effect redraws the map from the
  selected itinerary, so switching never re-queries the service.
- Leg-by-leg itinerary: coloured line badge, headsign, board/alight stop + clock
  times, platform track, N stops, operator, and a 'live' badge only when the
  feed is realtime.
- Summary now reflects the selected option and shows the option count.
- 'Whole route' re-fit follows the selection.

Also clears the 2 pre-existing eslint warnings (unused StatusIcon/nip19) so the
documented zero-warning baseline holds.

Docs: AGENTS.md transit-detail note (incl. Transitous 403s a generic UA).
2026-10-01 10:58:27 +07:00
SkateSpots
b6cc7797e6 fix(ops): don't imply the current sha was itself deployed
A non-site-only skip advances the recorded sha without a production deploy,
so 'deployed Nm ago' could read as if that sha was deployed. Say 'last
production deploy Nm ago' instead.
2026-10-01 10:42:51 +07:00
SkateSpots
50fcb419f4 feat(ops): a "why isn't it live?" health check (signer + deploy)
Two failures today were invisible: the ngit/Amber remote signer stalling
mid-push, and the deploy timer skipping while origin/main had moved.

- scripts/auto-deploy.sh records the reason for every skip (and the run time)
  to ~/.local/state/skatespots-deploy/{last-skip,last-run}.
- scripts/ops-health.sh reads that back and prints repo/push sync, the skip
  reason, the throttle window and timer health; exit 1 when action is needed.
  --probe-push runs a bounded real push (the only true signer probe),
  --notify desktop-notifies (deduped), --quiet is silent when healthy.
- Periodic watch: skatespots-ops-health.{service,timer} (every 15 min) plus
  scripts/install-ops-health-timer.sh.

Docs: AGENTS.md Deploy section.
2026-10-01 10:42:01 +07:00
SkateSpots
d6713ba20e feat(stash): merge multi-device changes instead of clobbering
The stash is one replaceable NIP-44 blob, so a naive last-write-wins publish
silently dropped whatever another device added. Make it merge.

- StashItem.updatedAt (last edit, falls back to addedAt) and a top-level
  Stash.deleted[] of {id, at} tombstones so deletes aren't resurrected.
- export mergeStashes(): union by id, newest stamp wins, tombstones win at/after
  the last edit; tombstones pruned (180d / 500).
- fetchLatestStash() merges the live remote with the list already held (query
  cache -> closure) and toasts when the other device contributed.
- Writes now require a confirmed relay read: assertConfirmed() refuses an
  unconfirmed base so useNostrPublish can't queue a pre-signed blob that would
  overwrite a newer edit later. Reads still work offline from cached ciphertext.
- StashPage surfaces the refusal via onStashError on every mutation.

Docs: NIP.md multi-device merge section; AGENTS.md updated.
2026-10-01 09:57:19 +07:00
SkateSpots
b89136161c feat(onboarding): clearer signup steps, backup confirmation, home area
Signup and onboarding are the first impression, so tighten both.

Signup (SignupDialog):
- Add a Choose -> Profile -> Save key step indicator.
- Make backup a confirmation, not a skip: an "I've saved my recovery code"
  checkbox enables "Let's skate" (downloading the key also sets it); the old
  co-equal "Skip for now" is now a quiet link.
- Fix copy that contradicted step 3 ("nothing to remember") and warn that a
  signer may ask to approve the profile publish.
- Platform-aware signer nudge instead of implying a recommendation.

LoginArea: drop the dead SignupMethodDialog/MnemonicSignupDialog wiring (never
opened) and rename the misleading nsecSignupDialogOpen state.

Onboarding: add a "Where do you skate?" first step (Use my location caches the
fix so the map opens on the skater's area) and remove the dead disabled
"Log in first" button.
2026-10-01 09:44:47 +07:00
SkateSpots
907da9b497 feat(stash): a public want becomes a marketplace want ad
The Wishlist tab lived only in the encrypted stash, so 'I want an 8.25 deck'
was invisible to sellers. A public want (itemType wtb) now publishes a real
NIP-99 listing with listing_type=wtb (tag stash-want, price = what they'd pay),
which the Marketplace already surfaces under its WTB filter; the encrypted
Wishlist keeps the private copy.

- buildListingTags handles the want case (no showcase tag, listing_type=wtb,
  no condition); syncItem no longer skips wtb.
- StashPage drops the item.itemType !== 'wtb' showcase guards (add/edit/bulk).
- AddItemDialog defaults wants to PUBLIC (a want is meant to be seen) and the
  visibility hint + wishlist teaser explain where it goes.
2026-10-01 08:35:52 +07:00
SkateSpots
03c960456f docs(agents): stash sort/search, public preview, bulk actions 2026-09-29 22:31:47 +07:00
SkateSpots
1311f699f9 feat(stash): sort + size search, public-stash preview, bulk actions
- Filter bar gains a Sort select (Newest / Value / Condition / A-Z) and search
  now matches size too.
- 'Public stash' card lists which items are public with a link to your profile
  ('see what others see').
- Bulk actions: a header Select toggle puts rows/cards into multi-select
  (checkbox + click-to-toggle, per-item actions hidden) with a fixed action bar
  -- Public / Private / For sale / Remove -- backed by new bulkSetVisibility /
  bulkSetForSale / bulkRemove mutations that each do ONE re-encrypt, plus a
  bulk-delete confirm. Showcase sync/unpublish runs per affected item.
2026-09-29 22:31:14 +07:00
SkateSpots
5a8e3688ee feat(stash): readable offline + export/import backup
The private stash was deny-listed from persistence with no local cache, so an
offline or relay-outage cold start showed an EMPTY stash even though decrypting
needs no network.

- offlineCache v2 adds a 'stash' table storing the encrypted CIPHERTEXT (never
  the decrypted list). useStash caches it on every successful fetch, and both
  the read query and fetchLatestStash (the pre-write re-fetch) fall back to it --
  so the stash loads at the park and an offline edit rebuilds from the real list
  instead of throwing.
- /stash gains a 'Backup & transfer' card (desktop sidebar + mobile) with
  Export (a JSON you control) and Import (one re-encrypt, skipping
  title+category+size duplicates).
2026-09-29 22:20:50 +07:00