Strautomator
Strava 활동을 자동화해 주는 Strautomator는 IFTTT와 비슷하지만 Strava 전용이다
샌프란시스코·뉴욕 공공 운동장 지도, 농구·피클볼·테니스·배구·축구 등 실시간 개방 여부·수영장 일정·레크 클래스 확인
Free live map of every public place to play in San Francisco and New York City: basketball, pickleball, tennis, volleyball, soccer and more, plus pool schedules and rec classes. See what’s open now, c
스포츠·피트니스JavaScript · PythonGitHub @8kevinxu온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기Free live map of every public place to play in San Francisco and New York City: basketball, pickleball, tennis, volleyball, soccer and more, plus pool schedules and rec classes. See what’s open now, check in, and find your game.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
🏀 RECreate
https://playrecreate.com/
Find somewhere to play right now — in San Francisco or New York City.
RECreate started as an SF indoor-basketball finder and has grown into a
multi-city map of drop-in recreation across four tabs. The app auto-detects
your metro (with a manual switcher in Settings) and, in NYC, lets you filter to
just the boroughs you care about. Everything below describes SF, the most fully
built-out city; see Cities for what NYC adds and how the
multi-city architecture works.README 전문 보기 ↗Find somewhere to play right now — in San Francisco or New York City. RECreate started as an SF indoor-basketball finder and has grown into a multi-city map of drop-in recreation across four tabs. The app auto-detects your metro (with a manual switcher in Settings) and, in NYC, lets you filter to just the boroughs you care about. Everything below describes SF, the most fully built-out city; see Cities for what NYC adds and how the multi-city architecture works.
chatbot/), never from
a model's memory. Off unless you run the service — see
Assistant.The whole UI is localized in English / 中文 / Español. Built with Expo /
React Native (also shipped to the web as a static export). The map is
Leaflet + OpenStreetMap rendered inside a WebView (no API key, no billing).
RECreate covers two metros, with an architecture built to add more.
How it's wired. lib/cities.js is the city registry (center, zoom,
timezone, bbox, per-city feature flags, optional subregions); nearestCity()
auto-detects your metro and Settings has a manual switcher. Every court/class
record carries a city field (SF ids are unchanged, so existing check-ins /
reviews / favorites keep working). Per-city data lives in data/cities/<id>/,
aggregated by data/cities/index.js. Sport and class filter chips self-hide
when a city has no matching data, so NYC-only filters never show for SF and
vice-versa — and the same rule drives the sport lists themselves
(sportsInCourts()), so the map's sport dial, the interest pickers and the
social composers offer only the sports the active city has courts for. NYC's outdoor courts come through a config-driven Socrata
adapter (scripts/lib/socrata-outdoor.js + scripts/cities/nyc.js) — adding
another Socrata-portal city (e.g. Chicago) is config-only; an ArcGIS city
(Seattle/LA) needs a new adapter.
Everything NYC-specific beyond that adapter is a separate build that joins back
to those pins through the park key the adapter now emits (key = the city's
gispropnum), so no name or distance matching is involved:
build-nyc-reservations (permits + tennis + dusk + floodlights),
build-nyc-directory (NYC Parks' own facility directories + the pickleball
page + community colour) and build-nyc-pools. Occupancy lands in the same
reserved contract SF's rec.us snapshot uses, so one render path serves both
cities. See CLAUDE.md → Multi-city for the full contributor notes.
npm install # if you haven't already
npx expo start # then press 'i' (iOS sim), 'a' (Android), or scan the QR in Expo Go
On first launch the app shows a short, skippable onboarding flow
(components/Onboarding.js, a full-screen overlay): a welcome slide → pick your
metro → two more value-prop slides → pick interests (favorite sports + class
activities) → an optional Create account step. It's shown once, gated on the
recreate.onboarded.v1 AsyncStorage flag; returning users skip straight to the
map. Navigation is a top-left back button (no swipe).
Three things make it more than a splash:
lib/interests.js) and drive "Recommended for you" immediately; the signed-in
profile's favorites take over once you have an account. Create account opens
the sign-up sheet inline and, on success, drops you on the map.If location is off, a dismiss-once map banner offers to enable it, and the "enable location" action is Settings-aware (routes to iOS Settings once the permission has been hard-denied).
Once on the map, a one-time coach mark points at the sport FAB (the map's
least-obvious control — an emoji glyph with no label), explaining that it switches
sport / opens ⭐ Favorites. It's shown once and then never again (persisted under
recreate.coach.sportfab.v1), dismissed by tapping it or opening the sport dial.
| File | What it does |
|---|---|
App.js | Main screen: header, open-now filter, map, court detail card, geolocation |
components/CourtMap.js · .web.js | Leaflet map (native = WebView, web = DOM); markers, zoom clustering + open/closed hierarchy, taps — keep the two in sync |
components/SportGlyph.js | Per-sport glyph: emoji for most, a drawn/rasterized ball for pickleball (which has no emoji) |
assets/stephCurryIcon.js | User-location marker image (data URI) |
data/courts.js | Generated bundled court list (offline fallback) |
data/courts.json | Generated hostable court data the app fetches at launch |
data/manual-courts.js | Hand-curated fully-static courts; merged in at runtime, never regenerated |
data/sanbruno-court.js | Generated San Bruno RAC court (drop-in hours from a city Google Sheet) |
lib/cities.js | City registry (center/zoom/tz/bbox/feature-flags/boroughs) + nearestCity / inSubregions |
data/cities/index.js · data/cities/<id>/* | Per-city generated data (NYC outdoor/indoor courts, classes, occupancy, directory, pools) + the aggregator that merges them — it also applies NYC's dusk/floodlight hours, expands run-length-encoded occupancy, and unions in sports the official directory adds |
scripts/lib/socrata-outdoor.js · scripts/cities/nyc.js | Config-driven Socrata adapter + NYC config (run via scripts/build-city-outdoor.js) |
scripts/build-nyc-indoor.js · scripts/build-nyc-classes.js | NYC rec-center schedules + NYC Parks programs feed builders |
scripts/build-nyc-reservations.js | NYC occupancy: citywide field/court permits + the 8 online tennis sites, plus the real dusk and floodlight times the hours model uses |
data/courts.js is auto-generated — don't edit it by hand. It combines three
sources:
CENTERS table in the build script (rarely changes).ib5c-xgwu), fetched by property name.Each court carries:
schedule[] — facility operating hours (one block per day)dropins — a { sportId: week } map of drop-in open-gym blocks per sport, where
each week is indexed 0=Sun..6=Sat and each day is an array of [startMin, endMin] blocks. Tracked sports live in lib/sports.js (basketball,
volleyball, ping pong, badminton, pickleball, tennis,
soccer, baseball); the app shows one at a time via the (scrollable)
sport toggle. Indoor coverage comes from rec-center gyms (basketball broad;
volleyball/pickleball a handful of centers; ping pong ~10 and badminton at
Betty Ann Ong, scraped from the multipurpose-room / auditorium rows, not just
the gym). DataSF adds outdoor public courts & fields (see below): basketball
~75 parks, tennis ~65, pickleball ~13, volleyball 4, soccer ~23 (incl.
multi-use turf), baseball ~39 diamonds, plus 12 outdoor fitness courts
that join the weight-room view. Sports with both settings get an
Indoor/Outdoor sub-filter; tennis, soccer, and baseball are outdoor-only.indoor — true for gyms, false for outdoor courts; surfaced as an
Indoor/Outdoor badge and in the header count.scheduleSource — "live" (scraped this run) · "cache" (last-good) ·
"curated" · "datasf" (outdoor courts)Regenerate anytime:
npm run build:courts
Not every gym is an SF Rec & Parks center. lib/useCourts.js merges non-sfrecpark
courts into whatever the SF pipeline produced (bundled, cached, or remote), deduped
by id, so npm run build:courts never touches them. Two flavors:
data/manual-courts.js — today that's the 6 SFRPD golf courses (⛳ map
view), each with a curated golf block: holes/par/yardage, green fees (as of
July 2026 — bump ~annually like the pool fee tables), and tee-time booking link.scripts/build-sanbruno-court.js
pulls the city's public Gymnasium Schedule Google Sheet (CSV export), parses the
two-side gym grid into per-sport drop-in blocks (basketball + volleyball), and
writes data/sanbruno-court.js. Run it with npm run build:sanbruno, or
npm run build:data to rebuild every source together. It mirrors the SF build's
resilience — last-good cache (scripts/sanbruno-cache.json) plus a validation gate
that keeps the old data if the sheet won't parse.scripts/build-outdoor-courts.js, which queries the DataSF facilities dataset
(ib5c-xgwu) for the Basketball Court, Tennis Court, Pickleball Courts, and
Tennis/Pickleball Court types, maps each to its sport(s), and groups records by
park into one pin each (data/outdoor-courts.js, npm run build:outdoor). These are first-come
public courts with no posted schedule, so each is modeled as open a fixed daily
park-hours window (8 AM–8 PM) with its sport(s) available across all of it. Same
cache + gate resilience as the others (scripts/outdoor-courts-cache.json).scripts/build-reservations.js, which reads SF Rec & Park's reservation platform
(rec.us, via its public api.rec.us per-location endpoint). For each reservable
court it compares the open-hours schedule against the still-free slots over the
next 7 days (rec.us only lists future-free times, so this captures who's reserved
when), geo-matches each rec.us location to one of our courts by proximity + sport
(closest wins), and writes a court id → { sport: { pct, courts, slots, url } } map
(data/reservations.js + a hostable data/reservations.json for the runtime
refresh above, npm run build:reservations). Venues are selected by rec.us
organization id (SF Rec & Park), not by a bounding box — rec.us runs a demo
org geocoded inside SF whose locations are , and one of
them spent two weeks feeding a proof-of-concept court's availability onto Rossi
Playground's card. is
booked% keyed by actual SF-local datetime () — the fraction of the
location's courts reserved at that 30-min slot — is the window average, and
is the court's rec.us page. merges it onto courts as : the
court detail card shows a live badge — a red when every court is reserved (also tagged in the Nearby list) — plus a button (deep-links to that court's rec.us page) and a how-to-book link, and
the "Plan a game" sheet shows each court's booked% for the picked day+time. When you pick
a time on the main screen, the card, the Nearby tag, and the map all switch from "right
now" to that exact date+time (e.g. "0% booked at 6 PM"), each court recomputed for the
picked slot via . On the map, each reservable court's glyph gets an keyed to how booked it is at the viewed time — green (free) → light green → yellow
→ orange → red, and a fully-booked court flashes red and hops (see in
). The main screen also
offers multi-select (Bookable / Lights / Restrooms / Nets provided)
for tennis + pickleball, each shown only when some court qualifies (see in
, fed by the directory + reservation data). Because the slots are
date-keyed they cover today through the window end and then —
every reading becomes null, so the 3-hourly cron plus the runtime refresh above
are what keep "right now" accurate, and an expired snapshot says so rather than
looking empty. Same last-good cache + gate resilience
(), with the gate checking both an absolute
floor and a >20% drop from the last good run — venue loss here has historically
been gradual, which an absolute floor alone never catches.An optional disclaimer field on a court overrides the default "verify on
sfrecpark.org" footnote on the court detail screen.
The schedules are seasonal, so they're refreshed automatically:
.github/workflows/refresh-schedules.yml)
re-runs the build and commits data/courts.js only if it changed.live → cache → curated. A
validation gate aborts the run (leaving the old data in place) if fewer
than MIN_LIVE_OK centers scrape — so a site redesign fails the Action and
notifies you instead of silently publishing empty schedules.Four crons run on cadences matched to how fast each source actually moves:
| Workflow | Every | Covers |
|---|---|---|
refresh-schedules.yml | weekly | SF builders in one step, each NYC source in its own |
refresh-classes.yml | 6h | SF and NYC class openings |
refresh-reservations.yml | 3h | rec.us bookings (they change hourly) |
refresh-nyc-reservations.yml | daily | NYC permits + tennis + dusk |
NYC permits are issued to leagues weeks ahead, so they barely move hour to
hour — but the snapshot only covers the next 7 days, so the window has to roll
forward daily or its far end goes dark. Adding a generated file? Add it and its
cache to the workflow's FILES list, or the cron will run and never commit it.
One stale source must not discard the healthy ones. reportStale() exits
non-zero when a build falls back to a cache past its budget, and the builds used
to be chained with && inside a single step — so when nycgovparks.org began
answering GitHub's runner IPs with HTTP 405 in Aug 2026 and the NYC caches aged
out, the first stale source short-circuited the eight builds behind it and
skipped the commit, discarding SF's live scrape every 6 hours while its scraper
worked perfectly. build:data is now build:data:sf && build:data:nyc, the
weekly workflow runs SF as one step and each NYC source as its own, and every
scrape and commit step is guarded if: !cancelled() && steps.deps.outcome == 'success'. A bare if is implicitly ANDed with success(), so only a status
function runs a step after an earlier failure; the deps half still skips
everything when npm ci fails. The job still reports failure — a failed step
sets the conclusion regardless of what runs after it — so the gate stays loud, it
just no longer costs the sources that are fine.
The 405 is routed around, not fixed. The block is by request origin:
identical code and User-Agent gets 200 from a residential IP and 405 from Azure
space, so no header tweak or retry defeats it, and five of the six NYC builds
need that host (only build:nyc reads Socrata, which is unaffected). There is no
alternate source to migrate to — the Socrata events dataset is dead (newest event
Dec 2019) and the Parks Pools dataset carries locations but no schedules. So
scripts/refresh-nyc-local.sh, on a daily LaunchAgent
(scripts/com.recreate.nyc-refresh.plist, install/remove commands in its
header), scrapes NYC from an unblocked machine and pushes the refreshed caches.
CI then checks out a fresh cache, still 405s, still falls back — but finds
fetchedAt recent, so reportStale stays quiet and the run goes green with no
workflow change at all. It is a cache feeder, not a second pipeline, and
nothing monitors it because nothing needs to: if it stops, the cache ages past
its budget and the stale gate goes red, which is the alert.
Each court's card has a Reviews section — a list of comments plus a box to
add one (optional name + text). Stored in lib/reviews.js with the same
Supabase-or-local pattern as check-ins; loaded lazily per court.
Guards for free-text content (spam + UGC accountability):
to authenticated with
auth.uid() — migration 016_reviews_require_auth.sql,
also folded into schema/02_reviews.sql). Local-only reviews (no backend) stay
open, since they never leave the device. The Report flow is unchanged.There's no in-app delete — moderate via the Supabase dashboard (Table Editor →
reviews) if needed. A rating column exists (unused) so star ratings are an easy add.
Seed your own initial data in the Supabase SQL editor:
insert into public.reviews (court_id, author, body) values
('hamilton-recreation-center', 'Kevin', 'Great runs on weekday evenings, competitive.'),
('mission-recreation-center', null, 'Gym can get packed after 6pm.');
(court_id matches the id in data/courts.js — e.g. palega-recreation-center.)
The app fetches fresh data on launch instead of relying only on the bundled
file (lib/useCourts.js):
bundled data (instant, offline) → cached copy (last good) → remote fetch (revalidate)
data/courts.json.EXPO_PUBLIC_COURTS_URL=https://raw.githubusercontent.com/<user>/RECreate/main/data/courts.json
(Put it in a .env file or your EAS build env. Needs a public repo for the
raw URL; or host courts.json on GitHub Pages / any CDN.)Until EXPO_PUBLIC_COURTS_URL is set, the app simply uses the bundled data.
EXPO_PUBLIC_RESERVATIONS_URL=https://raw.githubusercontent.com/<user>/RECreate/main/data/reservations.json
Court hours are stored per weekday, so a bundled copy stays correct indefinitely. Reservation slots are keyed to absolute dates on a rolling 7-day window, so the snapshot compiled into a build simply expires about a week after it ships: every reading becomes null and the booking indicators disappear from the map, the Nearby list and the court card at once. Leaving this unset means each release shows occupancy for a week and then goes quiet until the next store build.
It refreshes on launch and on foreground (at most hourly), because iOS resumes a suspended app without remounting — a process that lives a week would otherwise fetch exactly once. A payload is only accepted if it's strictly newer than what's already held. If a snapshot does outlive its window, the card says "Booking data is out of date" rather than rendering as though nothing is booked.
Open-gym times reflect the current season scraped from sfrecpark.org and vary by program — verify on sfrecpark.org. Gene Friend has no open-gym blocks (facility page offline, likely renovation) and falls back to curated data.
Tap a court → "How crowded right now?" → vote Empty / Moderate / Packed. Your pick is highlighted; tap it again to remove your check-in, or tap a different level to switch. The latest check-in shows as e.g. "🔴 Packed · voted 12 min ago", plus a short history ("👥 4 check-ins in the last hour" and the recent votes), and animates the map marker:
z z z drifting off the basketballCheck-ins expire after FRESH_WINDOW_MS (2h) — after that the gym's current
state is "unknown" again (no animation), though the last report time still shows.
Signed in, the card also offers "I'm here": a personal check-in for the sport you're viewing, which feeds your profile's per-sport counters and most-visited park. It's a different record from the crowd vote above — that one is anonymous and says how busy the court is; this one is yours and says you turned up.
Both undo. Tap your crowd level again to remove it; for a check-in, the button itself holds the state — it turns outlined and reads "tap to undo" — and your own rows in the Activity feed swipe away, for the one you notice after leaving the court. A crowd report quietly logs an "I'm here" too, so undoing the report takes that visit back with it, unless you'd already checked in deliberately.
Undo can't recall a push that already went out (it fires the moment the check-in lands). The row does disappear from friends' feeds, which read live — so the app says "tap to undo", never that nobody saw it.
The card's "👥 N check-ins in the last hour" counts both kinds, community-wide.
That number comes from a Postgres aggregate (court_checkin_count(), migration
025) rather than from reading rows, because check-ins are a location history and
stay private per 017:
a count names nobody, so it can be public while the rows can't. The card lists the
people it may name (you and your friends) and accounts for the rest as "+2 more
checked in here". Without migration 025 applied, it falls back to counting only
what it can see rather than claiming an empty court.
Storage is pluggable (lib/crowd.js): it uses Supabase (shared across
all users + real-time) when configured, and falls back to on-device storage
otherwise. No UI changes between the two.
supabase/schema/01_crowd_check_ins.sql
→ Run (creates the check_ins table, public read/insert/delete policies,
and turns on real-time). The other features each have their own numbered
schema/ file — run them in order as you enable each; see
supabase/README.md for the full layout..env:
EXPO_PUBLIC_SUPABASE_URL=https://<project>.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=<anon public key>
npx expo start -c.Now check-ins write to Supabase, everyone sees the same counts, and a real-time subscription pushes other users' check-ins to the map live. Until the env vars are set, the app uses local on-device check-ins (you see only your own).
Real-time is incremental: each new check-in is merged into state by id
(mergeCheckIn) rather than refetching the whole table — so cost scales with
check-ins, not users × check-ins.
Anti-spam (two layers, no cooldown):
BEFORE INSERT trigger caps check-ins per client IP
(default 30 / 60s; tune in supabase/schema/01_crowd_check_ins.sql). Unlike the client guard,
it can't be bypassed by clearing app storage.Both are pragmatic backstops, not airtight: the anonymous model means shared Wi-Fi / mobile CGNAT users share an IP. True per-user protection would need auth or device attestation.
The map's 📍 Nearby button opens a list of courts ranked by distance from
you (straight-line miles via lib/distance.js — no routing API). Each row shows
distance and, for open courts, how much open-gym time is left ("open · 1h 20m
left", or a red "closing · 15m left" under 30 min) so you don't trek to a gym
that's about to close. An Open for filter (Any / 30m+ / 1h+) drops courts
with too little time left.
The list reuses the app's current Open now/then filter and time picker, so it
respects the selected view time — e.g. nearest court open for 1h+ at Tue 3 PM.
Distance + time-left also show on each court's detail card, alongside rough
travel times (drive / bus / walk) derived from straight-line distance — shown
with a leading ~ (e.g. "~12 min") to read as estimates, since the real ETA comes
from tapping Directions. Without location
permission the list stays in default order with an "Enable location" prompt. Code:
components/NearbyList.js (+ getBasketballRemaining in lib/hours.js).
Accounts are the foundation for social features. They're optional — the map and check-ins work signed-out; you need an account for social features and (when a Supabase backend is configured) to post a review. Reading reviews needs no account.
Sign-in is email + password via Supabase Auth (lib/auth.js). Tap Sign in
in the header to create an account (with a display name) or log in; the session
persists across launches. Profiles live in a profiles table (id → auth.users,
display_name), auto-created on signup by a DB trigger and public-readable so
names can show in social features.
The Account sheet doubles as the player's profile: editable display name,
age, bio, and favorite sports (multi-select of the tracked sports), plus
a check-ins summary — a per-sport visit counter and your favorite (most-
visited) park. Visits are logged to a player_check_ins table (user_id, court_id, sport) whenever a signed-in user reports a court's crowd level or taps the
court's "I played here" button; a 3-hour per-court/sport window dedupes the two
paths so one session counts once. Stats are aggregated client-side (lib/playerCheckins.js).
Setup (on top of the Supabase steps under Live crowd check-ins):
supabase/schema/03_profiles.sql (the
profiles table, its policies, the signup trigger, friend codes, and
player_check_ins). On an existing DB that predates these profile fields,
apply supabase/migrations/002_add_player_profiles.sql
instead (idempotent — adds the new columns + table only).When Supabase isn't configured, the account button is simply hidden.
The social front door. The header 📣 Activity button opens one stream that merges everything happening with your friends — their "down to play" signals (tap to open the session), confirmed sessions (court + time locked in, marked ✅), and upcoming runs (with I'm in / Leave / Cancel) — sorted soonest-first. Up top are quick 🤙 I’m down and + Plan a run composers, so the feed is both where you see activity and where you start it.
The Activity button carries an unread badge: a count of feed items posted
since you last opened the sheet. "Last seen" is a single timestamp kept on the
device (AsyncStorage), so the badge survives restarts and clears when you open
(or close) the feed; your own posts never count. The feed live-updates while open
via Supabase realtime on both the signal and run tables. Code lives in
lib/feed.js (merge + unread) and components/FeedModal.js.
The 👥 Friends sheet is now just friend management (code, add, requests, list), and its badge counts pending friend requests.
Plan an actual session — a court, a sport, and a time — for whatever you're
organizing (not only casual pickup; any tracked sport at any tracked court).
Signed-in users start from the + Plan button next to the map's time picker (no
longer buried in each court's card). The form lets you start from either end:
pick a court and its open-gym days/times light up, or pick a day/time and the
court list flags which gyms run open gym then (others are disabled). For sports that
have both, an Indoor / Outdoor / Either filter narrows the court list (the same
control the map uses). Pick the sport (any tracked sport), who can see it
(Friends, the default, or Anyone), and an optional note. If the map's time
picker is set, the form opens seeded to that time. Others who can see it tap I'm
in to join (from the Activity feed); the host sees a roster count and can
Cancel. Code lives in lib/runs.js + components/RunModal.js (internally still
the rec_runs table — only the user-facing wording is "plan / game").
Visibility is enforced by RLS via the visibility column (public | friends):
public games are readable by all, friends-only ones only by the host and accepted
friends (loadUpcomingRuns powers the Activity feed across all courts).
Setup: run supabase/schema/04_runs.sql — it adds
rec_runs / rec_run_participants, policies, real-time, and the host auto-join
trigger. The friends-only visibility policy lives in
05_friends.sql (it needs the friendships table).
On an existing DB, apply supabase/migrations/003_generalize_run_sports.sql
to allow all sports (the original constraint only permitted basketball/volleyball).
Signed-in users can connect via friend codes. The header 👥 Friends sheet
shows your code (with Share), an add by code box, incoming requests
(accept/decline), and your friends list. Adding by code sends a request the
other person accepts; if they'd already requested you, adding them completes it.
Each profile gets a unique 6-char code (no ambiguous characters) via a DB trigger.
(Friends' runs and signals show in the 📣 Activity feed, not here.) Code lives
in lib/friends.js + components/FriendsModal.js.
Setup: run supabase/schema/05_friends.sql — the
friendships table, policies, and real-time. (The friend_code column + generator
live in 03_profiles.sql.)
An availability ping to friends: in the 📣 Activity sheet tap 🤙 I’m down → optionally pick a sport (or leave it 🤸 Anything — just down for some recreation) and Right now or At a time (+ optional note). You can also seed the location up front — an optional Indoor / Outdoor / Either preference and an optional specific court (or leave it Anywhere and let friends decide) — the same filters the map uses. Anything you set shows on the session sheet as a "Prefers: …" line; anything you leave blank stays open for friends to suggest. Friends see it live in their Activity feed, and the Activity button shows an unread badge — the in-app "notification". Signals are friends-only (RLS) and auto-expire 2h after they start. The chosen sport rides along on the feed row and the session sheet, and scopes which courts/times you can suggest (an Anything signal falls back to a default sport for those suggestions).
Each signal is a joinable session: tap it to open the session sheet, join
(I'm in), suggest an activity → place → time, and — as the host — confirm
one (either a participant's suggestion or your own). You pick the sport/activity
first (essential for an Anything signal — it has none yet), which scopes the rec
center options and a time limited to that court's open-gym blocks (shared with
the run form via dropinWeekdays / openGymSlots in lib/hours.js); the host
confirming a suggestion promotes its sport onto the signal. Joining or suggesting
drops you into the signal's group chat and posts an announcement there ("I'm in!"
/ "Suggested {sport} · {court} · {when}"), so everyone sees who's in and what they
proposed. The
confirmed court + time show to everyone and extend the session's expiry. Code lives in
lib/signals.js, components/SignalModal.js (composer), and
components/SessionModal.js (session).
Setup: run supabase/schema/06_signals.sql — it
adds rec_signals + rec_signal_participants (with the sport, creator place/court
prefs, and suggested/confirmed court + time columns), policies, the auto-join trigger,
and real-time. On an existing DB, apply
006_signal_sport.sql (the sport
column) and 015_signal_place_court.sql
(the creator's place + pref_court_id prefs).
Note: the in-app feed is real-time. To also buzz the phone when the app is closed, see Push notifications below.
The Social → Chats tab lists every conversation you're in: group chats for
runs and "down to play" sessions (you're added when you join) and 1:1 direct
chats with friends, plus a row of friends to start a new direct chat. A thread
opens a messaging view with colored avatars, date dividers, timestamped message
bubbles, and a rounded composer. Swipe a row to hide a chat (restore it from the
Deleted view). All three kinds share one chat_messages table, scoped by RLS;
read state + hidden threads are tracked locally (AsyncStorage). Code lives in
lib/chat.js, components/ChatsScreen.js (list), and components/ChatThread.js
(conversation). Setup: run supabase/schema/08_chat.sql.
The top of the Social tab shows an auto-revolving card of suggestions tailored
to your interests — your favorite sports and class categories (any of
the Classes tab's fourteen), both set on your profile. It mixes
open-gym games happening today for your sports (“Play basketball at Palega ·
2:00 PM” — tap to open that court on the matching sport) with rec-center
classes that have openings in your categories (“Zumba at Sunset Rec · Openings” —
tap for a detail sheet, components/ClassDetail.js, then register on ActiveNet).
With no interests set it recommends across everything. All computed on-device from the
bundled court + class data (lib/recommend.js, components/RecommendPane.js); set
interests are stored in profiles.favorite_categories (run
supabase/migrations/008_add_interests.sql).
The same matches drive interest-based local notifications (lib/localNotify.js):
when you open the app, it schedules on-device reminders ~30 min before today's
matching games and classes, so you get a nudge even if the app is closed (deduped
per day; needs interests + notification permission). Server push for events while
the app has never been opened is a deliberate later addition.
Because the app carries user-generated content (chat, reviews, signals) — which
the App Store requires apps to moderate — users can report objectionable
content and block abusive accounts. Long-press a chat message to report it
or block the sender; reviews have a Report link (reviews are anonymous, so
no block). Blocking is stored in blocked_users (syncs across your devices) and
filtered into every social loader (signals, chat, feed, friends), so a
blocked user's content disappears everywhere; manage/undo from Settings →
Blocked users. Reports land in content_reports for out-of-band review. Account
creation also requires agreeing to the Terms (EULA) and Privacy Policy.
Posting a review additionally requires sign-in (see Reviews). Setup: run
supabase/schema/10_moderation.sql.
Every icon-only control carries an accessibilityLabel (+ accessibilityRole)
so VoiceOver announces it instead of reading a raw glyph — the sport & filter FABs,
the recenter button, all ✕ close buttons (card / Settings / Blocked users), the
location-banner dismiss, the bottom-nav tabs (with a selected state), and the
review Report action. This is an App Store review consideration as much as a UX one.
Real pushes (expo-notifications) for the social moments that matter, so they
land even when the app is closed:
| Event | Who gets it |
|---|---|
| A friend posts "down to play" | their friends |
| A friend plans a run | their friends |
| Someone joins your run / session | the host |
| A session's court + time is confirmed | the other participants |
| Your friend request is accepted | the requester |
How it works. On sign-in the app registers the device's Expo push token
in a device_tokens table (lib/push.js). Postgres triggers on rec_signals,
rec_runs, the participant tables, check_ins, and friendships call a
send_push() helper that POSTs to Expo's push API straight from the database
via pg_net — no Edge Function or server to run. send_push() sends one
request per token (Expo rejects a batch that mixes tokens from different
projects, so one stale token can't silence a broadcast), and crowd-update pushes
are rate-limited to one per voter+court per 10 min (crowd_notify_log) so
rapid level-switching doesn't spam friends. Tapping a push deep-links into the app (the
court for a run, the Activity feed for signals/sessions, the Friends sheet for a
friend accept).
Setup (one-time):
getExpoPushTokenAsync needs a project id. Run eas init
(it writes extra.eas.projectId into app.json). Without it, registration
no-ops and nothing else breaks.npx expo run:ios / npx expo run:android
(or an EAS build) and run it on a physical device.supabase/schema/07_push.sql
once. It enables pg_net, adds device_tokens (+ RLS) and the trigger
functions. (pg_net may need enabling under Database → Extensions in the
Supabase dashboard first.)Testing status. The backend is verified end to end: with pg_net,
device_tokens, and the trigger functions applied, send_push() POSTs a
well-formed request to Expo's push API and gets back a 200 (confirmed by
seeding a token and inspecting net._http_response). On-device delivery is
not yet verified — the iOS Simulator has no APNs and can't receive remote
push, a free Apple developer account can't sign the Push Notifications
capability (it needs the paid $99/yr Apple Developer Program), and no Android
device was on hand. Verifying delivery to a screen just needs a physical Android
device/emulator (free) or a paid Apple account.
Limitations (v1): pushes are fire-and-forget — Expo delivery receipts aren't
checked, and tokens aren't pruned when a device unregisters at the OS level
(DeviceNotRegistered). Both are easy follow-ups (a receipts poller / cleanup).
Swimming is a sport on the map (not a separate tab): SF's 9 public pools
and NYC's 92 show as pins in the Swimming view, judged "open now" for their
public-swim sessions like any court. lib/poolCourts.js shapes each pool (data/pools.js)
into a court record — a dropins.swimming week built from the public-swim
sessions (lap/family/senior/water-exercise/parent-tot, so lessons/camps/rentals
don't count as "open swim") plus a pool block the card renders. Because it's a
real sport, you can favorite / plan / signal a swim, and the map's filter row
gains session-type chips (Lap / Rec-Family / Senior / Lessons / Water
exercise), self-hiding for every other sport.
Each pool only posts its schedule as a seasonal PDF, so
scripts/build-pools.js discovers each pool's current schedule-PDF link live,
downloads it, extracts the text with pdfjs-dist, and reconstructs the weekly
grid (merge text fragments → map cells to day columns by x-position → pair each
activity label with the time below it → classify into a session kind). Output is
sessions[dow] = [{ kind, start, end }] — minutes-from-midnight, 0=Sun..6=Sat,
same convention as courts. North Beach has two pools under one roof with
separate warm-/cool-pool PDFs, so its sessions also carry pool: "warm" | "cool"
and render as separately labeled ♨️/❄️ groups.
Session kinds (lap / family / senior / lessons / adult lessons / parent-child /
water exercise / camps / rentals) render as color-coded, localized pills in the
court card (components/PoolDetail.js). Tapping a pool opens the same compact
card as any court, plus a "Now:" line naming the sessions on (or starting
later today) and any closure notice; Schedule & reviews expands to the full
weekly schedule starting from today — in place of the generic week rows, which
would repeat it flattened to bare times — a
collapsible fees table (city-wide aquatics prices), the blurb, and a link to
the official PDF (the source of truth). Pool coordinates, addresses, phones, season labels, fees, and
holiday closures are curated in the build script (the facility pages have no
lat/lng, and fees change ~annually). npm run build:pools, same cache + gate
resilience (scripts/pools-cache.json). pdfjs-dist is a build-only dependency
— it isn't bundled into the app.
The schedules are machine-parsed from PDFs, so dense multi-lane grids can miss the odd concurrent session — the card always links the official PDF to confirm.
NYC's 92 pools (scripts/build-nyc-pools.js) reach the same records by a much
shorter road — no PDF geometry, because NYC publishes its schedules plainly:
#Pool-schedule
table on each rec center's schedule page — year-round, rec-center membership.Fees travel on the pool record, so SF's per-visit prices and NYC's free-outdoor / membership-indoor model both render from one component.
Ask the app a question in words — "is it open tomorrow?", "cheapest beginner tennis class in the Mission", "the two closest pickleball courts to Golden Gate Park" — instead of hunting through filters.
It's a floating launcher over every tab: a small ✨ button (a slim tab on the screen edge on the map, where five controls already float) that opens a bottom sheet. Because it travels with you, it knows what you're looking at — tap it with a court card open and "is it open tomorrow?" means that court, no re-asking which one. The conversation is owned above the tab switch, so it survives navigating away and back.
Answers come from the app's data, not from a model's memory. A Python
service (chatbot/) does all retrieval in deterministic code; the model only
picks a tool, fills its arguments, and writes the sentence. It can't decide
whether a court is open, so it can't invent an opening. What's on screen is
passed as a pointer, not a fact — the court id tells the service what to look
up, and every hour and price still comes from a tool.
It's off by default. lib/assistant.js is gated on
EXPO_PUBLIC_ASSISTANT_URL exactly the way lib/supabase.js is gated on its
keys: unset — what production ships — and the launcher never renders. No API key
lives in the app; the service holds it server-side. Turn it on with:
npm run export:chatbot # data → chatbot/data/
cd chatbot && .venv/bin/python -m uvicorn app:app --port 8000
# then set EXPO_PUBLIC_ASSISTANT_URL=http://localhost:8000 and: npx expo start -c
Every answered question costs a model call, so the service ships with a bearer
token, per-IP rate limits and a global daily budget — all off by default for
localhost, all one env var each. The token is inlined in the app bundle and so
isn't a secret; the budget is what actually bounds the bill. Your coordinates
are used to measure distance and then dropped — never stored, and never sent to
the model, which only ever sees "1.2 miles" — an invariant the test suite
enforces. Details: chatbot/README.md.
Runs on Ollama (free, local, offline) or Anthropic. Full setup, the tool
list, and the security caveats are in chatbot/README.md.
When the service is unreachable, questions about the app itself ("how do I save
a favorite?") are still answered on-device from lib/assistantFallback.js — eight
topics in all three languages, rendered in an amber "offline" bubble so they can't
read as live. Live-data questions are refused rather than guessed at: hours and
prices need the service, and saying so is the honest answer.
The entire UI is localized in English, 中文, and Español — switch in Profile →
⚙️ Settings (the choice persists in AsyncStorage). React components read
const { t, lang } = useI18n(); plain non-React modules (date helpers, crowd "X ago",
court/pool hours, data-layer error messages) translate through a module-level
tg() that mirrors the active language into a global, so they localize without React
context. All strings live in one STRINGS dictionary in lib/i18n.js, kept at full
key parity across the three languages. External data stays in its source language
(court/pool names, addresses, scraped schedules) — the one exception is scraped class
titles, pre-translated at build time (see Classes & activities above).
Profile → ⚙️ Settings opens a sheet with the language switch, a Legal &
Support section (links to the Privacy Policy, Terms (EULA), and Support
pages), an optional Share activity with friends toggle, and a Delete my
account action. Deletion asks the user to type a confirmation word, then calls a
delete_account() SECURITY DEFINER Postgres RPC that deletes their auth.users
row; FK ON DELETE CASCADE then clears all of their app data (profile, check-ins,
runs, signals, friendships, chats, push tokens). Setup: run
supabase/schema/09_account_deletion.sql
(or supabase/migrations/005_account_deletion.sql
on an existing DB).
The App Store privacy nutrition label (configured in App Store Connect, not the
repo) must match the written Privacy Policy.
docs/privacy-nutrition-label.md reconciles the
two: it maps every data type the app actually collects (email, name, user id, push
token, user content — all linked, not tracking, app-functionality) to the exact
ASC selections, and flags that Location must stay "Not Collected" because it's
used on-device only (declaring it would contradict the policy). The same collected
types are declared in app.json → ios.privacyManifests so the shipped
PrivacyInfo.xcprivacy is consistent too.
The web build is a static SPA export — npm run build:web (= expo export --platform web → dist/, then scripts/postbuild-web.js, which injects SEO
metadata into index.html and prerenders crawlable landing pages, sitemap, and
robots.txt from the bundled data). vercel.json configures that build command,
dist as the publish dir; set the three EXPO_PUBLIC_* vars in the Vercel dashboard
since a CI build has no local .env. There is deliberately no catch-all rewrite
to index.html — the app routes on query params so it only loads at /, and a
catch-all made every unknown path answer 200 with the app shell (a soft 404).
Unmatched paths hit a generated 404.html instead.
The landing pages are generated per city from CITY_CFG in
scripts/postbuild-web.js: one page per sport that city has courts for, plus its
class index (SF also gets /golf and /pools). SF keeps its bare, already-indexed
paths (/basketball, /pools, …) and every other city is namespaced under a prefix
(/nyc/basketball); a sport too big for one page splits by subregion
(/nyc/basketball/queens — NYC has 620 basketball courts). Every page carries the
App Store smart banner and install links, and links into the app via ?city= so a
NYC search result opens the app on NYC. Adding a city to lib/cities.js and
data/cities/index.js gets its landing pages for free.
Below the sport indexes sits the long tail — ~600 pages in all: one per court
(/court/<id>), pool (/pools/hamilton), and golf course (/golf/lincoln-park),
plus multi-sport area hubs (/sf/mission, /nyc/brooklyn). A court earns a
page only where there's something to say: SF courts and NYC rec centers all carry
real schedules, while NYC's first-come outdoor pins must be a genuine destination
(more than one sport, or 4+ courts) — the rest stay listed on their borough page
rather than becoming near-identical stubs. Every page is reachable by internal
link, not just from sitemap.xml; scripts/postbuild-web.js is the only thing
that needs to change to add a page type.
Vercel Analytics is mounted
web-only (components/WebAnalytics.web.js, @vercel/analytics/react) and only collects
when served from Vercel. The iOS app is iPhone-only (ios.supportsTablet: false);
cloud EAS builds don't read the local .env, so EXPO_PUBLIC_* vars are set in the EAS
production environment.
scripts/build-nyc-directory.js | NYC Parks' own facility directories (tennis surface/phone/notes), the official pickleball page (the one source allowed to add a sport to a pin), and nycpickleball.com community colour (nets/BYON, open play, Slack/TeamReach) |
scripts/build-nyc-pools.js | NYC's 79 free outdoor + 13 indoor rec-center pools, season-aware |
scripts/lib/courts-common.js · scripts/lib/translate-titles.js | Shared builder helpers (cache/slug/time; Claude title translation) |
scripts/build-indoor-courts.js | Builds the SF data files; scrapes live schedules |
scripts/build-sanbruno-court.js | Builds the San Bruno court; parses the city gym Google Sheet |
scripts/schedule-cache.json | Last-good scraped schedule per facility (fallback) |
.github/workflows/refresh-schedules.yml | Weekly cron that re-scrapes + commits |
lib/useCourts.js | Fetches/caches court data at launch (bundled→cached→remote) |
lib/hours.js | Open-now + basketball open-gym logic from per-weekday schedules |
lib/crowd.js | Crowd check-in store (levels, freshness, "voted X ago") |
lib/reviews.js | Per-court reviews store (Supabase + local fallback) |
lib/datetime.js | Shared date helpers (time picker + run form) |
lib/auth.js | Account state (Supabase Auth: session, profile, sign in/out) |
lib/runs.js | "Plan a run" store (create/join/leave/cancel pickup runs) |
components/AuthModal.js | Sign in / create account / account sheet |
components/RunModal.js | Top-level "Plan a game" form — sport, Indoor/Outdoor filter, court + day/time in either order |
lib/friends.js | Friends graph (codes, add/accept/remove) |
components/FriendsModal.js | Friends sheet (your code, add by code, requests, friends list) |
lib/feed.js | Activity feed: merges signals + runs into one stream; unread tracking |
components/FeedModal.js | Activity sheet — friends' signals + upcoming runs, with composers |
lib/signals.js | "Down to play" signals + joinable sessions (friends-only, realtime) |
components/SignalModal.js | "Down to play" composer (now / at a time, optional sport · place · court + note) |
components/SessionModal.js | Session sheet (join, suggest a time, host confirms) |
lib/distance.js | Haversine distance + formatting (miles) |
lib/push.js | Expo push-token registration + notification-tap handling |
components/NearbyList.js | Nearby courts ranked by distance, with a min-open filter |
components/BottomNav.js | Four-tab bottom bar (Home / Classes / Social / Profile) |
components/ClassesScreen.js | Classes tab — browse programs, classes & volunteer workparties by category, with filters + live openings |
components/ClassDetail.js | Class/activity detail sheet (schedule, location, cost, ages, availability + a source-aware CTA: ActiveNet, NYC Parks, or the SF volunteer calendar) |
data/classes.js · scripts/build-classes.js | Generated classes catalog + its ActiveNet scraper (with build-time title translation) |
data/volunteer.js · scripts/build-sf-volunteer.js | Generated SF Rec & Park volunteer workparties, from the Salesforce Aura endpoint behind their JS-only calendar (one card per recurring job) |
data/sf-classes.js | Hand-written aggregator merging the ActiveNet catalog + volunteer workparties into SF's one program list |
lib/classesLive.js | Runtime ActiveNet fetch for "right now" class openings (native only; CORS-blocked on web) |
lib/poolCourts.js · components/PoolDetail.js | Pools as swimming courts on the map: poolCourts shapes each city's pools (SF data/pools.js + NYC data/cities/nyc/pools.js) into court records (open-now from public-swim sessions), PoolDetail renders the tap-state "Now:" line and, behind "Schedule & reviews", the schedule/fees/PDF block in the court card. Fees travel on the pool record, so SF's per-visit prices and NYC's free/membership model both render |
data/pools.js · scripts/build-pools.js | Generated pools + schedules parsed from seasonal PDFs (pdfjs-dist) |
components/SettingsScreen.js | Settings sheet — language switch (en/zh/es), Legal & Support links, activity-sharing toggle, delete account |
docs/privacy-nutrition-label.md | Reconciles the Privacy Policy with the App Store Connect privacy label (2.1 reference) |
components/SocialScreen.js · ChatsScreen.js · ChatThread.js | Social tab shell + 1:1 / group chat |
lib/chat.js | Chat data layer (run / signal / direct threads) |
lib/blocks.js · lib/reports.js | Trust & safety: block users (filtered into every social loader) + content reports, incl. one-tap "data looks wrong" flags (court/class/pool) and Settings' free-text "Report a problem" |
lib/sports.js | The tracked sports table (id, label, emoji) |
lib/favorites.js | On-device court→sport favorites (useFavorites) behind the ⭐ Favorites map view |
lib/recommend.js · components/RecommendPane.js | "Recommended for you" — interest-based games + classes in the Social tab |
lib/playerCheckins.js | Per-user visit stats (per-sport counts, favorite park) |
lib/i18n.js | i18n: STRINGS dict (en/zh/es), I18nProvider, useI18n(), and tg() for non-React modules |
components/Onboarding.js · lib/interests.js | First-launch onboarding overlay (welcome → city/location → slides → interests → account) + on-device interest store |
chatbot/ | The assistant service (Python/FastAPI) — deterministic retrieval + a model that only picks tools and phrases results; see its own README |
scripts/export-chatbot-data.js | Exports the app's merged court/class data to chatbot/data/*.json (npm run export:chatbot) |
lib/assistant.js · lib/assistantFallback.js | The app's only link to the service (gated on EXPO_PUBLIC_ASSISTANT_URL) + on-device answers for app questions when it's down |
components/AssistantHost.js · AssistantThread.js | Floating ✨ launcher + sheet (owns the conversation, carries on-screen context) and the thread UI inside it |
components/WebAnalytics.js · .web.js | Vercel Analytics, mounted web-only via the .web.js platform split |
components/ScrollTopFab.js | Shared back-to-top arrow (useScrollTop hook + FAB) used by Classes / feed |
vercel.json · scripts/postbuild-web.js | Web static-export deploy config (build → SEO postbuild → dist; no catch-all rewrite — unknown paths 404) |
slots"YYYY-MM-DD HH:MM"pcturllib/useCourts.jsreservedbookedAtbookLevelcomponents/CourtMap*.jsAMENITIESApp.jsscripts/reservations-cache.jsonscripts/build-court-directory.js, which scrapes SF Rec & Park's public
tennis and
pickleball directories (single
HTML tables, parsed with cheerio), matches each facility to one of our courts by name
(sport-gated, with a few manual aliases), and writes a court id →
{ sport: { total, reservable, walkup, lights, restrooms, nets } } map
(data/court-directory.js, npm run build:directory). lib/useCourts.js merges it onto
courts as directory, shown as facility chips on the detail card. Same last-good cache +
gate resilience (scripts/directory-cache.json).scripts/build-classes.js, which scrapes SF Rec & Park's ActiveNet catalog
(anc.apm.activecommunities.com/sfrecpark). It does the ActiveNet CSRF + session
handshake, pages the activity-search API one category id at a time (ActiveNet's
multi-id search silently drops categories) across the full catalog (33 source
categories), and re-buckets every item by keyword into the app's canonical category
set (fitness, dance, music, arts & crafts, sports & rec, aquatics, nature, learn,
camps, performances, social & games, plus the youth / volunteer / accessible facet
tags — 14 in all, shared with NYC so the filter row is identical in every city),
writing data/classes.js — each class
{ name, category, location, when, dropIn, cost, ages, url, name_zh?, name_es? }.
Drop-in series published as one row per date are collapsed into a single card
spanning the term (keyed by the latest instance id, so delist-hiding doesn't eat
live series); drop-ins for sports the map already tracks are skipped as
duplicates; and classes whose list fee is the "View Fee Details" placeholder get
real prices (or "$lo–$hi" ranges) from the detail-page price-estimate endpoint.
npm run build:classes, same last-good cache + gate resilience (scripts/classes-cache.json).
Class titles are scraped English, so the build optionally pre-translates each
distinct title to zh/es via the Anthropic API (Claude Haiku) when ANTHROPIC_API_KEY
is set — cached by title in scripts/classes-i18n-cache.json so each run only spends
tokens on new titles, and degrading gracefully to English without a key. Live "right
now" openings are refreshed in-app by lib/classesLive.js (a second, more frequent
ActiveNet fetch; native only — browsers block it via CORS, so web shows the bundled
baseline).scripts/build-sf-volunteer.js reads the public Apex controller (SFRPCalendar)
behind Salesforce's Aura endpoint instead. It needs no auth and doesn't validate
the framework nonce, so there's nothing to scrape off the page first; only its READ
methods are called (the same controller exposes the signup write path). The feed is
an archive, not a calendar — ~87% of its ~1,700 rows are shifts that already
happened, back to 2020 — and each recurring workparty emits one row per date, padded
with placeholder instances as far out as 2030. The build keeps upcoming shifts inside
a 180-day horizon and collapses them to one card per job, the same way the ActiveNet
drop-in series are collapsed, writing ~36 entries to data/volunteer.js. They file
under the philanthropy category (the app's Volunteer chip) with a secondary
nature/social tag, are free, and carry a live "volunteers still needed" count.
npm run build:volunteer, same last-good cache + gate resilience
(scripts/sf-volunteer-cache.json), titles translated like class titles. Park coords
come from the bundled court data plus a curated table in the script — the feed has a
street address but no lat/lng, and uses internal park names (GGP Sec 2 Rhododendron Dell) the public court data has no row for.data/sf-classes.js merges those two generated files into the one SF catalog every
consumer reads (Classes tab, recommendations, the prerendered /classes page, the
assistant export). Merging there rather than inside either generated file keeps each
build owning exactly one output, so a failed volunteer scrape can't blank the class
catalog. Volunteer records carry source: 'sfrpd-volunteer', which the live-ActiveNet
delist check skips (they aren't in ActiveNet) and which swaps ClassDetail's
"Register on ActiveNet" CTA for the volunteer calendar.