Skip to content

SimonSchubert/Betabase

Repository files navigation

Betabase

Upcoming sport climbing competitions, in your pocket.

A Jetpack Compose Android app that aggregates climbing competitions from multiple sources into a single, filterable list.

Main list Youth filter Offline state

Sources

  • IFSC (World) — live ICS feed from calendar.ifsc.stream covering World Cups, World Championships, Continental Cups, etc. Refreshed on app launch and pull-to-refresh.
  • NKBV (Netherlands) — Dutch federation national + regional series, scraped from was2.shiftf5.nl/competitions. Bundled JSON, refreshed by re-running the scraper and shipping a new app version.
  • SCA (Australia) — Sport Climbing Australia upcoming events from sportclimbingaustralia.org.au/Upcoming-Events. Bundled JSON, same refresh model as NKBV.

Features

  • 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, u14u20).
  • 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.

Build

Requirements:

  • JDK 17
  • Android SDK with platforms 35 + build-tools
./gradlew :app:installDebug

The Gradle wrapper handles itself. local.properties should point to your Android SDK (auto-generated by Android Studio on first open).

Architecture

  • data/EventSource — the plug-in interface. Implementations: IfscEventSource (live HTTP + ICS parser) and BundledJsonEventSource (reads app/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 — flat CompetitionsUiState with CompetitionsFilters. Factory registers all sources via APPLICATION_KEY.
  • ui/theme — custom design system (BetabaseColors/Typography/Shapes) on CompositionLocals. No MaterialTheme.
  • ui/componentsBetaText, BetaCard, BetaPill, BetaChip, BetaButton, CompetitionCard. Built from androidx.compose.foundation only.

Adding a new data source

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):

  1. Drop a Python scraper into scripts/ (gitignored). Stdlib-only is encouraged. Output to app/src/main/assets/<source>_competitions.json matching 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": "..." }
      ]
    }
  2. Add the value to SourceTag with a regionLabel.
  3. In CompetitionsViewModel.Factory, add a BundledJsonEventSource(app, "<file>.json", SourceTag.X).
  4. In CompetitionsScreen.sourceColor / sourceOnColor, give the new region a chip color.
  5. Bump versionCode in app/build.gradle.kts before releasing.

Refreshing scraped data

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 release

The scraper scripts themselves are gitignored — they're operator tooling, not shipped code. The JSON they produce is committed.

Athlete data (IFSC)

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_competed year (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_competed in the most recent 1–2 seasons). Spot-check that current stars have good photos and reasonable last_competed values.
  • 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.json

Release

Tagging v* triggers .github/workflows/release.yml:

  • apk job — builds a signed APK and attaches it to a GitHub release.
  • play-store job — builds an AAB and uploads it to the Play Store production track via fastlane. Gated on the PLAY_STORE_ENABLED repo 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)

Play Store first publish (one-time bootstrap)

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_release

That 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.

Ongoing release flow

Local signed builds:

export KEYSTORE_FILE=/path/to/keystore.jks
export KEYSTORE_PASSWORD=...
export KEY_ALIAS=...
./gradlew :app:assembleRelease   # APK
./gradlew :app:bundleRelease     # AAB

The 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.

Screenshot tests

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.

License

MIT — see LICENSE.

Background image: https://www.pexels.com/photo/hand-grip-of-a-rock-during-wall-climbing-6700631/

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages