100 Cims
Private code
All 522 summits of the challenge, with infinite scroll 
Table mode, with filtering and sortable columns 
Summit detail, with routes and directions
An application for the FEEC 100 Cims challenge. It uses Angular to browse the peaks, a Spring Boot and PostgreSQL REST API, and Python scripts to collect and prepare the data and images.
- REST API with Maven and PostgreSQL 17 in a container.
- Python pipeline that collects the peak data and loads it into the database.
- Image download and optimisation to WebP.
- Dataset restore from CSV.
The problem
The FEEC's 100 Cims challenge is 522 summits, and the official list lives in documents meant to be printed: there is no way to filter it, sort it or mark how far you have got. Anyone who tries ends up with a hand-made spreadsheet that goes stale on its own. The idea was a public catalogue of all 522 and, behind a session, each person's progress.
My role
Everything: the Angular interface, the Spring Boot API, the PostgreSQL schema, the Python data pipeline and the Docker packaging.
Decisions
A pipeline prepares the data, not a person
Seven Python scripts collect the summits, download their images, optimise them to WebP, insert them and can rebuild the whole dataset from the CSV. Typing 522 rows by hand happens once; when the FEEC corrects an altitude or swaps a photo you start again, and whoever gets tired rewrites only what they remember. With the pipeline, redoing it means running it again.
The load demands exactly 522, or it fails
The Flyway migration is idempotent and checks the count. A load that inserts 500 of 522 and carries on leaves the application standing, looking right, with twenty-two summits that exist for nobody; no one notices until someone looks for theirs and it is not there. Failing at startup is loud, and that is exactly what you want from data loaded once.
Images to R2, verified by hash
The Cloudflare R2 sync is idempotent, downloads every object back, compares its SHA-256 and demands exactly 522 keys. An «uploaded successfully» that nobody reads back says nothing: what matters is that what is up there is byte for byte what was sent. In development Nginx serves them from the local copy, so bringing the project up does not depend on the bucket.
Integration tests run against a real database
Testcontainers brings up PostgreSQL 18.4 for the backend tests, and when Docker is missing they are skipped rather than falling back to a local database. A test against a mocked database passes while the real migration fails, which is the failure mode that matters here; and one using the laptop's database drags yesterday's data along. The Docker images are pinned by version **and** digest, so next year's is the same one.
Outcome
- All 522 summits in a catalogue with filters, detail pages, photographs and a sortable table, rebuilt in full from the CSV with one command.
- Personal progress sits behind Google OAuth, with SPA CSRF and `HttpOnly`, `SameSite=Lax`, `Secure` session cookies: the catalogue is public and what you have climbed is not.
- The whole stack starts with one `docker compose up`: PostgreSQL 18, Flyway, the Java 25 API and the Angular app served by Nginx, every image pinned by digest.
What I would change
The 522 WebP images went into the repository before the bucket existed, and they are still there: the Git history carries them and will need purging with `filter-repo` as soon as the media domain is connected. A binary that enters Git does not leave on its own, and rewriting the history of a shared repository is expensive. Today I would start with object storage even if it looked like overkill for a folder of photos.