Upcoming sport climbing competitions, in your pocket.
A Jetpack Compose Android app that aggregates climbing competitions from multiple sources into a single, filterable list.
- IFSC (
World) — live ICS feed fromcalendar.ifsc.streamcovering World Cups, World Championships, Continental Cups, etc. Refreshed on app launch and pull-to-refresh. - NKBV (
Netherlands) — Dutch federation national + regional series, scraped fromwas2.shiftf5.nl/competitions. Bundled JSON, refreshed by re-running the scraper and shipping a new app version. - SCA (
Australia) — Sport Climbing Australia upcoming events fromsportclimbingaustralia.org.au/Upcoming-Events. Bundled JSON, same refresh model as NKBV.
- Region toggle chips (
World/Netherlands/Australia) — all on by default. - Discipline chips (
Boulder,Lead,Speed,Combined) — Boulder + Lead on by default. - Round chips (
Qualifier,Semi,Final) — Final only by default. - Audience chips (
Women,Men,Youth) — Women + Men on by default; Youth detection via title keywords (jeugd,youth,junior,u14–u20). - Tap a card to open the source's event page (livestream player on IFSC events; registration / detail page on NKBV and SCA) — uses Compose's
LocalUriHandler. - Pull-to-refresh fetches the live IFSC feed; bundled sources reload from assets.
Requirements:
- JDK 17
- Android SDK with platforms 35 + build-tools
./gradlew :app:installDebugThe Gradle wrapper handles itself. local.properties should point to your Android SDK (auto-generated by Android Studio on first open).
data/EventSource— the plug-in interface. Implementations:IfscEventSource(live HTTP + ICS parser) andBundledJsonEventSource(readsapp/src/main/assets/*.json).data/IcsParser— RFC 5545 line-unfolding ICS parser, TZID-aware. Stdlib only.data/EventClassifier— title keyword →(Gender, Discipline, Round). Multilingual for the gender/youth axis (English + Dutch).data/CompetitionsRepository— fans out to all sources in parallel, merges, filters past events, sorts by start time.ui/screens/CompetitionsViewModel— flatCompetitionsUiStatewithCompetitionsFilters.Factoryregisters all sources viaAPPLICATION_KEY.ui/theme— custom design system (BetabaseColors/Typography/Shapes) onCompositionLocals. NoMaterialTheme.ui/components—BetaText,BetaCard,BetaPill,BetaChip,BetaButton,CompetitionCard. Built fromandroidx.compose.foundationonly.
The two patterns:
Live HTTP feed (IFSC-style): Implement EventSource. Fetch in Dispatchers.IO, parse, return List<CompetitionEvent>. Add a value to SourceTag with a regionLabel. Register in CompetitionsViewModel.Factory. Done — chip and per-card label appear automatically.
Scraped bundled JSON (NKBV / SCA-style):
- Drop a Python scraper into
scripts/(gitignored). Stdlib-only is encouraged. Output toapp/src/main/assets/<source>_competitions.jsonmatching this schema:{ "source": "...", "source_url": "...", "fetched_at": "ISO-Z", "events": [ { "id": "...", "title": "...", "series": null, "location": "...", "date": "YYYY-MM-DD", "time": "HH:MM" | null, "discipline": "Boulder" | null, "url": "..." } ] } - Add the value to
SourceTagwith aregionLabel. - In
CompetitionsViewModel.Factory, add aBundledJsonEventSource(app, "<file>.json", SourceTag.X). - In
CompetitionsScreen.sourceColor/sourceOnColor, give the new region a chip color. - Bump
versionCodeinapp/build.gradle.ktsbefore releasing.
python3 scripts/scrape_nkbv.py
python3 scripts/scrape_sca.py
python3 scripts/scrape_ifsc_athletes.py
# review the diff in app/src/main/assets/*.json (and overrides if edited), then bump versionCode and releaseThe scraper scripts themselves are gitignored — they're operator tooling, not shipped code. The JSON they produce is committed.
scripts/scrape_ifsc_athletes.py (which also runs the event-medals pass) populates ifsc_athletes.json from Wikipedia. After running it:
- Many more athletes will now have a populated
last_competedyear (sourced from the same World Cup season tables used for medal counts). This powers the active/retired distinction in the Athletes tab. - Review the active cohort (those with
last_competedin the most recent 1–2 seasons). Spot-check that current stars have good photos and reasonablelast_competedvalues. - For the small number of active athletes where you want a better recent photo or a manual correction, edit
ifsc_athletes_overrides.json(the repository merges it at load time; the base file is still overwritten by the scraper on every run). - Important active athletes who do not (or no longer) appear on Wikipedia's career victories ranking table must be listed in
scripts/featured_athletes.json. The scraper will always fetch and include them with full photo / medal / activity enrichment. This is the main mechanism to protect data quality for the athletes you care most about. Example: Brooke Raboutou was dropped from the ranking table between scrapes; adding her to featured ensures she (and her photo + recent results) stays in the app. - Commit the regenerated
ifsc_athletes.json+ any override or featured changes together.
Example jq snippet to list currently active athletes after a scrape:
jq '.athletes[] | select(.last_competed != null) | select( (2026 - .last_competed) <= 2 ) | "\(.first_name) \(.last_name) (\(.last_competed))"' \
composeApp/src/commonMain/composeResources/files/ifsc_athletes.jsonTagging v* triggers .github/workflows/release.yml:
apkjob — builds a signed APK and attaches it to a GitHub release.play-storejob — builds an AAB and uploads it to the Play Store production track via fastlane. Gated on thePLAY_STORE_ENABLEDrepo variable so it stays off until the Play Console + service account are ready.
Required GitHub Actions secrets:
| Secret | Purpose |
|---|---|
KEYSTORE_B64 |
base64-encoded release keystore (base64 -i keystore.jks) |
KEYSTORE_PASSWORD |
keystore + key password |
KEY_ALIAS |
key alias inside the keystore |
PLAY_STORE_KEY_JSON |
base64-encoded Play Store API service-account JSON (only needed when enabling the play-store job) |
A brand-new draft Play Console app rejects every API commit on the production track. The trick is to push the very first build to the internal track as a draft:
KEYSTORE_FILE=/path/to/keystore.jks KEYSTORE_PASSWORD=... KEY_ALIAS=... \
./gradlew :androidApp:bundleRelease
bundle exec fastlane android first_releaseThat uploads the AAB + all metadata + all screenshots + icon + feature graphic to the internal-testing draft. Then in Play Console: review the listing, fill in the compliance fields (privacy policy, content rating, target audience, data safety), and promote the internal release to production via the Console UI. The first review goes through Google.
After that initial publish, the first_release lane is no longer needed — deploy and upload_screenshots work as in Braincup.
Local signed builds:
export KEYSTORE_FILE=/path/to/keystore.jks
export KEYSTORE_PASSWORD=...
export KEY_ALIAS=...
./gradlew :app:assembleRelease # APK
./gradlew :app:bundleRelease # AABThe signing config in app/build.gradle.kts falls back to debug-signing when no keystore is found, so unsigned local builds still work for development.
Paparazzi tests live in app/src/test/kotlin/.../screenshots/. Snapshots are committed under app/src/test/snapshots/images/.
./gradlew :app:verifyPaparazziDebug # CI mode: fail on visual diff
./gradlew :app:recordPaparazziDebug # rewrite snapshots after intentional changes
./gradlew :app:updateScreenshots # also copies labelled PNGs into media/Add new snapshots by writing a @Test in ScreenshotTest.kt that calls snap { CompetitionsScreenContent(state = …, on… = {}) } with hand-built CompetitionsUiState. Map the test name to a media filename inside the updateScreenshots task in app/build.gradle.kts.
MIT — see LICENSE.
Background image: https://www.pexels.com/photo/hand-grip-of-a-rock-during-wall-climbing-6700631/


