Can I climb there tomorrow? cragcast answers it for crags in Lombardy and, since CG-43, in Piedmont and Trentino-Alto Adige: for each crag, tomorrow's weather, the rain of the last two days and the aspect of the wall become a verdict — go, maybe, no go — with the reasons.
Wet rock is the main thing that ruins a climbing day, and whether a wall has dried depends on how much it rained and how much sun the wall gets. A south-facing slab after 8 mm of rain is usually fine the next day; a north-facing one often is not. cragcast puts that judgement in code, with the thresholds in one place so they can be argued with.
Live: allan-nava.github.io/cragcast — 780 crags (Lombardy 222, Piedmont 317, Trentino-Alto Adige 241), updated every 3 hours by a GitHub Action, no server.
How it works, in Italian: cragcast: si arrampica domani? — the verdict's rules, the Oracle VM that never came, and the free stack it runs on instead.
Status: the page is live on GitHub Pages and is the way cragcast runs. The Spring service is complete for one verdict per crag and day but not hosted: the Oracle Always Free VM could not be created (no A1 capacity in Milan), so that path is parked. What comes next is in the roadmap.
- dark or light, as the system asks (CG-60): the same contrast rules in both, the verdict marks unchanged, the text and focus rings a darker green, amber and red on white;
- a map and a list of the crags with the verdict for today, tomorrow or the day after — never by colour alone: go is a green circle S, maybe a yellow diamond F, no a red square N — and the reasons under each crag (rain, recent rain, wind, cold or heat, an unmapped aspect);
- on a phone the controls take two rows — the days, then the search and a "Filtri" button that holds the verdict, province, grade, order, near-me and favourite filters and counts those on ("Filtri (2)"); the day's summary and the number of crags shown share one line;
- on a phone, "Lista | Mappa" shows one at a time, the list first, the choice kept in the
URL (
show=map); a crag chosen in the list opens on the map; - the day's summary stays at the top while the crags scroll, and "Torna su" shows once they have been scrolled (the page on a phone, the list's column on a desktop);
- compact crag cards: the name and verdict, then the place, the aspect — an "esposizione ?" tag where it is not mapped — the reasons and the details on one line, then the three days and "Dettagli" ("›" in a narrow list); about twice as many per screen;
- search by name or municipality, ignoring case and accents, toggles for go / maybe / no go, and a province filter; each crag shows where it is ("Canzo (CO)");
- grades and order (CG-61): a grade filter — "fino al 5c" to "fino al 6c", "dal 7a" to "dall'8a" — over the grades OSM gives, keeping a crag only when they settle it, and the count saying how many were left out for having none ("· 180 senza gradi su OpenStreetMap, escluse"); the list ordered by verdict, name, or elevation from the lowest or the highest, and by distance once "Vicino a me" has the position (distance stays out of the URL, like the position);
- crowded areas (Lecco, Val Masino) gather into one mark with the count and the split by
verdict written out at a readable size, the parts at zero left out (e.g.
20 · 7S·8F·5N,9 · 9S); zooming in or picking a crag in the list opens it; - favourites: a star on each crag, in the list and the panel, and a "Preferite" filter; kept in this browser only — never in the URL, never sent — and the page works when the browser refuses to store them (they then last for the visit);
- near me: the list sorted by distance from the reader, with the km, the map centred on them, and (CG-128) the region of the nearest crag chosen when it is another — the position is used in the browser only, never sent and never put in the URL;
- three days at a glance: each crag in the list shows today, tomorrow and the day after side by side — verdict mark, max temperature, and a drop with the mm when it rains (the data has no cloud cover, so no sun is drawn) — and each switches the page to that day; above the list, the day's verdicts counted ("Domani, venerdì 16 ottobre: 19 si va · 0 forse · 0 no") over the crags matching the search, province and area, whatever the verdict toggles;
- map and list together: pointing at a crag in the list (mouse or keyboard) lights its mark, or the group it is in; choosing one flies the map to it and opens it; a crag opened on the map is outlined in the list; after moving the map, "Cerca in quest'area" limits the list to what the map shows, until it is cleared;
- the view in the URL (
?q=&v=&p=&g=&o=&a=&crag=&show=&day=): a link opens the same view, and back works; - a link per crag:
?crag=<id>opens the page on that crag's panel with the map on it; opening a panel puts it in the URL and back closes it; "Condividi" in the panel uses the phone's share sheet, or copies the link where there is none; the link shared is the crag's own page (c/<id>.html, rebuilt with the data), so WhatsApp or Telegram show its name and the verdict and weather for the next day, with the date ("Falesie di Galbiate — venerdì 16 ottobre: si va"), and a browser goes straight on to the panel; - per crag, where OpenStreetMap has them: grades, route length, rock, style, website; the elevation of every crag; a link to the crag on OSM and to its OSM edit view; a crag folded from several OSM objects says how many ("mappata 14 volte"), and its panel lists them;
- the day's weather behind the verdict as a small table — rain in the 48 hours before, rain during the day, the maximum, the wind — in the popup and the panel;
- a panel per crag ("Dettagli") — from 900 px in the list's column, below the controls, with the map still usable and the list back when it closes, and a sheet from the bottom on phones; the day switches inside it with buttons or a sideways swipe, and a downward swipe closes the sheet: the day's hours from 06:00 to 21:00 as two small charts — temperature and rain, never one chart with two axes — a readout that follows the pointer or the arrow keys, the same hours as a table, and directions (Google Maps, OSM);
- in the avalanche season, the avalanche bulletin for the crag's zone — ARPA Lombardia's, AINEVA Piemonte's or the Euregio's for Trentino and Alto Adige (CG-120), each named on its link — in the popup and the panel: the danger level (1–5) by elevation band and part of the day, the avalanche problems, and a link to the official bulletin. Only the crags inside one of the bulletins' micro-regions get it — 608 of 780: Lombardy 121, Piedmont 246, Trentino-Alto Adige 241; the low crags lie outside them. The verdict does not change: it is about the rock, the bulletin about the approach and winter routes;
- three regions (CG-43): Lombardy by default, Piedmont or Trentino-Alto Adige — or all of
them — from a choice next to the province, kept in the URL (
?r=); the provinces follow the region, the map frames its crags (CG-123), and another region's crags load only when it is chosen, so a first visit stays light; a search with no match in the region shown looks in the others and offers them ("1 falesia in Piemonte — mostra", CG-124); the last region chosen is remembered in this browser for the next visit (CG-129) (ADR 0026); - the via ferratas (CG-114): a "Falesie / Vie ferrate" switch shows them instead of the
crags, each with its own verdict read at the top — a thunderstorm is never "si va" — and a
peak beside its mark; the difficulty as OpenStreetMap gives it (
via_ferrata_scale0–6, or "non mappata"), the length, the start and top elevations, the top's weather and hours; a share link of its own (CG-119), whose preview says it is a via ferrata; and, everywhere, that conditions are checked on the spot (ADR 0025); - how the verdict is made (CG-130): "Come decidiamo il verdetto" in the help says, with the scorers' own numbers, what makes a crag or a via ferrata "si va", "forse" or "no";
- installable on a phone's home screen (a web app manifest and a service worker): it shows the last data offline and says how old it is when it is more than 7 hours old (two missed runs);
- live: the footer says how long ago the data was made ("aggiornato 12 min fa") and keeps it current; an open page fetches new data every 10 minutes and when the reader returns to it, without a reload — the view, the open panel and the position stay; a skeleton holds the list's place while the first data loads;
- updated every 3 hours by
.github/workflows/pages.yml, at about :17 — Open-Meteo's free tier counts every place, and about 820 places every hour would be twice its 10,000 a day (CG-43); - built with React, TypeScript and Vite from
web/, Leaflet and its cluster plugin bundled from npm — no third-party script at runtime.
Every evening the verdict of the day and the weather behind it are saved for every crag, one
CSV per month, on the history branch:
date, crag_id, crag_name, province, verdict, rain_last48h_mm, rain_day_mm, max_temp_c, max_wind_kmh, reasons, generated_at. It answers "how was last week at this crag" and is the
data the calibration (CG-27) needs next to the observations. The Spring service copies it into
PostgreSQL (crag_history, CG-101): every month at startup, the last two each night; the CSV
stays the source (ADR 0016).
The Spring service also keeps every refresh's answer (CG-106): each refresh appends, per crag and
day, the verdict, the reasons, the four numbers and the day's hours to crag_forecast, keyed by
the time it ran — so "what did cragcast say at 18:00 the day before" stays answerable. Those
rows exist only on the Pi; the nightly backup is their copy
(ADR 0021).
The same data as the page, as static JSON on GitHub Pages — rebuilt every 3 hours, free to call,
no key, no server. Version 1: fields are only ever added; a field is never renamed or
removed (a breaking change would go to /api/v2/).
curl https://allan-nava.github.io/cragcast/api/v1/crags.json
curl https://allan-nava.github.io/cragcast/api/v1/crags/osm-node-1739484761.json
curl https://allan-nava.github.io/cragcast/api/v1/ferrate.jsoncrags.json—meta(version,generatedAt, timezone, thedayscovered, the credits for crags, weather, elevation and avalanche data) andcrags: for each oneid,name,municipality,provinceCode,latitude,longitude,aspect(null when not mapped),elevation,verdictsby day (GO,MAYBE,NO_GO) andurl, the path of its own file.crags/<id>.json—metaand the wholecrag: the fields above plussourceUrl,alsoMapped,details(grades, route length, rock, style, website — where OSM has them),province,region(CG-43: the crag's Italian region, also incrags.json's entries) anddays: per day theverdict, thereasons, theweatherit was computed from (rain in the previous 48 h, rain, max temperature and max wind from 08:00 to 18:00) and thehoursfrom 06:00 to 21:00, andavalancheonly when a bulletin covers the crag that day:regionandnameof the EAWS micro-region,level(the day's highest, 1–5),ratings(level,above,below— metres ortreeline— andperiod,earlier,lateror null for all day),problems(CAAML v6 types such aswind_slab),bulletin, the official page, and (CG-120)provider, who issues it. Since CG-117 alsosectors—id,name,aspect,gradeMin,gradeMaxof each sector folded into the crag (CG-104) — androutes—id,name,grade,length,bolts,pitchesandsector(a sector'sidor null) of each single route OSM links to it (CG-105), easiest first; both empty lists where OSM has none.ferrate.json(CG-115) —metaandferrate: for each via ferrataid,name,latitudeandlongitudeof its start,topElevation,scaleMinandscaleMax(via_ferrata_scale0–6, null when not mapped),verdictsby day andurl, the path of its own file, and (CG-121)municipality,provinceCodeandregion. Its verdict is its own, read at the top — a thunderstorm is neverGO(ADR 0024).ferrate/<id>.json—metaand the wholeferrata: the fields above plussourceUrl,pieces(the OSM ways it is drawn in),startandtop(latitude,longitude,elevation),lengthM,scaleDropped(values on OSM off the 0–6 scale, such as2+) andmunicipality,province,provinceCode,region(CG-121),daysas for a crag — theweatherandhoursbeing the top's, the window 07:00–19:00.
Credit OpenStreetMap (ODbL), Open-Meteo (CC BY 4.0) and Copernicus when you show the data, and ARPA Lombardia, AINEVA Piemonte or the Euregio for the avalanche levels.
How the parts fit, with diagrams: docs/ARCHITECTURE.md. Why it is built this way: the
Architectural Decision Records in docs/adr/.
| Part | What |
|---|---|
backend/ |
Java 21, Spring Boot 4, PostgreSQL, Flyway. GET /api/crags and GET /api/crags/{slug} (position, aspect, elevation, OSM details), GET /api/crags/{slug}/conditions?date=, GET /api/crags/{slug}/routes (CG-105: the crag's single routes from OSM, easiest first — name, French grade, length, bolts, pitches, sector, OSM link); imports the crags from data/crags.json at startup and refreshes the weather every hour, appending each hour's verdicts to crag_forecast (CG-106); CG-113: ferrata/ holds the via ferratas' own verdict, read at the route's top and stricter than a crag's — a thunderstorm is never "si va" (ADR 0024), not yet on the page |
backend/.../conditions/ConditionsScorer.java |
the verdict: weather + aspect → go / maybe / no go, with reasons |
backend/.../weather/ |
Open-Meteo client and the reduction of an hourly forecast to one day: rain in the previous 48 h, and rain, max temperature and max wind from 08:00 to 18:00 |
docker-compose.yml |
PostgreSQL (and the backend) for local development |
deploy/pi/compose.yml |
the Spring service, PostgreSQL and (CG-110) the page on a Raspberry Pi, as a Portainer Git stack that polls main and re-pulls the arm64 image from GHCR; the home network and the owner's tailnet, PostgreSQL on the Pi's localhost only, no self-hosted runner (ADR 0015, 0017) |
deploy/k8s/ |
Kubernetes manifests for k3s on an Oracle Cloud Always Free Ampere VM; parked with infra/oci/ |
infra/oci/ |
Terraform for that VM and its network, with retry-apply.sh for "Out of host capacity"; parked — nothing is applied (the VM never got capacity, the network was destroyed) |
data/crags.json, tools/osm_crags.py |
the crags, from OpenStreetMap — climbing=crag, and named outdoor sport=climbing places (a cliff, a rock, a climbing site), never gyms, shops or single routes — with the sectors mapped separately folded into their crag and named by it (CG-42), and listed by OSM id in its sectors (CG-104); each with its OSM links, its aspect where mapped, its validated climbing details, its elevation (Copernicus DEM), its municipality and province (OSM administrative boundaries); a crag mapped twice under one name less than 50 m apart is folded into one, keeping both OSM links; ODbL. Rebuilt monthly by crags-refresh.yml through a reviewed PR, or by hand: python3 tools/osm_crags.py; the folding rules are tested in tools/test_osm_crags.py |
data/osm-climbing.json, osm_object |
CG-103: every object of the same OSM query, not only the crags — crags, cliffs, loose points, single routes, gyms and shops — with its category, name, position, raw climbing tag, validated climbing tags and a route's own grade; written by osm_crags.py (--objects-only for this file alone), refreshed with crags.json by the monthly PR, and copied at startup into PostgreSQL's osm_object table (replaced whole; the file stays the source); ODbL. CG-104: the sectors each crag lists are copied with them into sector, linked to the crag, each with its own position, aspect and grades. CG-105: each single route (climbing=route, route_bottom, route_top) carries the crag and sector it belongs to and linkedBy — relation when an OSM relation that is the crag, a sector or a duplicate of it lists the route, distance for the nearest crag or sector within 400 m, null when none is near (mountain routes) — plus its bolts and pitches; copied into route with the grade as a rank, never into the crag list |
backend/.../avalanche/, data/avalanche-regions.geojson, tools/avalanche_regions.py |
CG-11: the avalanche bulletins — Lombardy's (ARPA Lombardia) and, since CG-120, Piedmont's (AINEVA Piemonte) and Trentino-Alto Adige's (the Euregio) — CAAML v6 JSON published by the EAWS on static.avalanche.report, issued each morning in season) and its micro-regions (EAWS, CC0), which place each crag in a zone; a missing or failed bulletin leaves the line out and never stops the hourly build; the Spring service keeps each day's levels, bands and problem types (never the text) in avalanche_bulletin (CG-107) |
data/ferrate.json, docs/ferrate-todo.md, tools/osm_ferrate.py |
CG-112: the via ferratas of Lombardy and, since CG-121, Piedmont and Trentino-Alto Adige, from OpenStreetMap — highway=via_ferrata ways grouped into routes (a relation, or the same name within 500 m), each with its OSM links, start and top with their elevation (Copernicus DEM), length and via_ferrata_scale 0–6 where mapped; never in crags.json; ODbL. The todo lists those without a difficulty or a name. Rebuilt by the monthly refresh, or python3 tools/osm_ferrate.py; rules tested in tools/test_osm_ferrate.py (ADR 0023) |
docs/aspects-todo.md, tools/aspects_todo.py |
CG-16: the crags whose wall aspect is not yet on OpenStreetMap, by province, each with its OSM edit link and how to add climbing:orientation; regenerated by the monthly refresh |
backend/.../calibration/, data/observations.csv |
the replay of observed days against the verdict (CG-27); CG-108: ObservationSync copies the CSV from main into observation, replaced whole at startup and at 04:40 |
docs/ferrate-survey.md |
CG-111: what OpenStreetMap holds on Lombardy's via ferratas before any import — the query, the counts (about 42, 35 named, 22 with a plain 0–6 scale, 5 route relations) and what CG-112 does with them |
docs/pi-backup.md |
CG-109: the Pi's nightly pg_dump onto a USB disk, 14 days kept, never onto the SD card; how to set up the disk and how to restore, tried (ADR 0019) |
web/Dockerfile, web/nginx.conf, backend/.../export/SiteData.java |
CG-110: the page on the Pi, next to GitHub Pages — the same build behind nginx, its data served live by the Spring service from the forecasts it already fetched, at Pages' paths (ADR 0018) |
backend/.../history/ |
CG-101: HistorySync copies the history branch's CSVs into crag_history — at startup and at 23:50 and 05:50, each day replaced whole |
backend/.../export/ConditionsExport.java |
the serverless version: scores every 3 hours to site/data/conditions.json and the static API in site/api/v1/, on GitHub Pages; with FerrateExport (CG-114) the via ferratas of data/ferrate.json, scored at their tops, under ferrate |
web/ |
the page: React 19, TypeScript, Vite, Leaflet; npm test runs the Vitest unit and component tests; npm run build writes site/ (generated, never committed) |
backend/.../export/SharePages.java |
CG-45: one static page per crag in site/c/, rebuilt with the data, whose Open Graph tags carry the name, the next day's verdict and weather; it forwards a browser to ?crag=<id>. The preview image is assets/social-preview.png, published as og.png |
.github/workflows/ci.yml |
on every push and PR: actionlint on the workflows and shellcheck on the scripts, backend build and tests (Testcontainers on a real PostgreSQL), the page's types, tests and build, the page end to end in Chromium (Playwright, desktop and phone, axe), the tools' tests (every script, network paths against a fake local server) — each with its coverage in the run summary and a floor (backend 95%, page 99% of lines, tools 97%) that fails the job below it, Terraform fmt and validate, backlog check |
.github/workflows/pages.yml |
every 3 hours and on push: scores every crag and publishes site/ on GitHub Pages |
.github/workflows/image.yml |
on push to main: the linux/arm64 backend image to GHCR, on a native ARM runner |
.github/workflows/lighthouse.yml, lighthouserc.json |
on PRs touching the page or the data (a bigger crag list weighs on it): builds it with real data and fails when Lighthouse (desktop, median of 3) drops below performance 0.88 or accessibility, best practices, SEO 0.95, or (CG-127) a layout shift goes above 0.02 — measured on 2026-10-15 at 0.95 / 1 / 1 / 1; CI's performance medians 0.92–0.96 since. The page's code, React and Leaflet come in separate chunks, and the crag's panel is fetched on demand (CG-64) |
tools/build_site.sh |
builds site/ as Pages serves it (the page from web/, then scores, data, API, logo); used by pages.yml and lighthouse.yml |
.github/workflows/uptime.yml, monitoring/checkfleet.yml |
hourly at :47: checkfleet checks the page, the data, the API and the TLS certificate, and a step fails when the data is over 150 minutes old; a failed run is GitHub's email alert |
.github/workflows/history.yml, tools/history_append.py |
every evening: today's verdict and weather for every crag, one CSV per month on the history branch, never on main |
.github/workflows/crags-refresh.yml |
on the 1st of every month: rebuilds data/crags.json (and, CG-112, data/ferrate.json) from OSM and opens a PR with the changes (tools/crags_diff.py) when there are any |
.github/workflows/backlog-issues.yml |
on push to main touching BACKLOG.md: issues and milestones follow the backlog |
.github/ISSUE_TEMPLATE/crag-data.yml |
the form to report a crag's aspect, grades or length for someone to map on OSM |
BACKLOG.md, ROADMAP.md |
planned work (CG-n); the roadmap is generated by npx backlogsync roadmap |
Weather comes from Open-Meteo (free, no key, CC BY 4.0). Crags come
from OpenStreetMap — © OpenStreetMap contributors, ODbL — and each carries the link it was
taken from. Elevation: Copernicus DEM GLO-90 (2021,
doi:10.5270/ESA-c5d3d65) through Open-Meteo's elevation API —
the ground of the area at 90 m resolution, not of a given route. A crag without climbing:orientation on OSM is judged as a shaded wall, the
cautious reading; adding the tag on OpenStreetMap sharpens its verdict at the next refresh.
Avalanche danger: the bulletins of ARPA Lombardia,
AINEVA Piemonte and the
Euregio for Trentino and Alto Adige, through the
EAWS; none states a licence for it, so cragcast shows only their facts — levels, bands, problem
types — never their text, and always names and links the one it comes from
(ADR 0027). Micro-regions:
EAWS regions, CC0.
The thresholds in ConditionsScorer are a first heuristic. They are checked against days
someone actually saw the rock, in data/observations.csv (date,crag,rock,observer,note, rock
dry, damp or wet); the Spring service copies the file from main into its observation
table at startup and each night (CG-108), the CSV staying the source. The replay fetches each day's weather from Open-Meteo's Historical
Forecast API — the same kind of data the live verdict reads — scores it and prints a Markdown
table:
cd backend && ./mvnw -q -DskipTests package dependency:build-classpath -Dmdep.outputFile=target/cp.txt -Dmdep.includeScope=runtime
java -cp "target/classes:$(cat target/cp.txt)" io.github.allannava.cragcast.calibration.Calibrate ../data/observations.csv ../data/crags.jsonEach day is AGREES, TOO_CAUTIOUS (dry rock, cragcast said maybe or no), MISSED_WET_ROCK
(damp or wet rock, cragcast said go — the error that matters) or TOO_HARSH (damp rock, no go).
A threshold changes only when the table shows why, with a test for the day that moved it.
cragcast knows what OpenStreetMap knows. Most crags still lack their wall aspect, so cragcast judges them as shaded walls; grades and route lengths are rarer still. If you know a crag, open it on OpenStreetMap (every crag on the page links to its OSM edit view) and add:
| Tag | What | Example |
|---|---|---|
climbing:orientation |
which way the wall faces — sharpens the verdict most | SW |
climbing:grade:french:min, …:max |
easiest and hardest route | 5a, 7b |
climbing:length:max |
longest routes, in metres | 30 |
climbing:rock |
the rock | limestone |
Only what you know first-hand: OpenStreetMap does not accept data copied from guidebooks or websites under copyright. Rather not edit OSM yourself? Open a crag data issue. Changes reach cragcast when the crag list is rebuilt: every month a workflow opens a pull request with what changed on OSM, and the page shows it once that PR is merged.
Java 21 and Docker.
docker compose up -d db # PostgreSQL on localhost:5432
cd backend && ./mvnw spring-boot:run
curl localhost:8080/api/crags
curl localhost:8080/api/crags/osm-node-1739484761/conditions # tomorrow's verdict
curl localhost:8080/api/crags/osm-relation-11740994/routes # a crag's routes, easiest firstTests: ./mvnw verify. The tests that need PostgreSQL start it with Testcontainers; on a
machine without Docker they are skipped, not failed, and CI always runs them. The *IT
classes run the export and the calibration whole, against fake Open-Meteo and avalanche
servers on localhost (FakeHttp), in verify's integration-test phase (failsafe). Coverage is
measured on every run (CG-47) and fails below a floor set from CI's figures — without Docker
the backend reads lower, so there run ./mvnw verify -Djacoco.haltOnFailure=false. The page:
cd web && npm run coverage. The tools: python3 -m unittest discover -s tools -p 'test_*.py'.
Visual snapshots (CG-63): web/e2e/snapshots.spec.ts compares the page, a crag's panel, the
map and the phone's filters, desktop and phone, with CI's own images in
web/e2e/__snapshots__/, on fixed data at a fixed moment; a layout change fails the PR with its
diff, and is accepted by committing CI's snapshots artifact.
End to end (CG-51): cd web && npx playwright install chromium && npm run e2e builds the page and
drives it in Chromium, desktop and phone, on fixture data (web/e2e/fixtures.ts), with an axe
accessibility scan.
The page as Pages serves it, with real data — Java 21 and Node 22:
tools/build_site.sh # builds web/ into site/, then scores every crag into site/data/
cd web && npx vite preview # http://localhost:4173
cd web && npm test # unit and component testsReleases are named by their day, vYYYY.MM.DD (ADR 0020).
Run Actions → Release → Run workflow on main: it builds the backend's and the page's arm64
images tagged with the release and carrying it (/actuator/info, the page's footer), then
publishes the GitHub Release with notes from
the merged PRs. The Raspberry Pi runs the release its Portainer stack names in
CRAGCAST_VERSION; set it and Pull and redeploy to move, set the previous one to go back.
Unset, the Pi follows :main, rebuilt at every merge. GitHub Pages is not versioned: it follows
main, rebuilt every 3 hours.
MIT — see LICENSE.