Enter a city or town and get a ranking of how desirable it is to visit over the next 7 days for four activities — skiing, surfing, outdoor sightseeing, indoor sightseeing — derived from Open-Meteo weather data.
React (Vite) ──GraphQL──▶ Apollo Server ──▶ Domain (pure scoring) ──▶ Open-Meteo
Requires Node 20+.
npm install # installs both workspaces
npm run dev:backend # GraphQL at http://localhost:4000/
npm run dev:frontend # UI at http://localhost:5173/
npm run test # backend unit tests (15)
npm run typecheck # strict TS, both workspaces
npm run build # production build, both workspacesTry Chamonix (skiing/inland), Newquay (real surf/marine data), Tokyo.
The codebase is a two-workspace npm monorepo (backend/, frontend/). The
guiding principle is a strict, one-directional dependency flow with a pure
core:
data layer ─▶ domain layer ─▶ GraphQL layer ─▶ index (composition root)
(I/O, fetch) (pure logic) (thin mapping)
| Layer | Location | Responsibility |
|---|---|---|
| Data | data/openMeteo/ |
The only code that performs network I/O. Owns the Open-Meteo wire types (types.ts) and maps them into the domain model. Applies timeouts and converts failures into a typed OpenMeteoError. |
| Domain | domain/ |
Pure, I/O-free business logic. One scorer per activity (domain/activities/*), shared scoring primitives (scoreUtils.ts), and the ranking engine (ranking.ts). |
| API | graphql/ |
Schema + deliberately thin resolvers: fetch → compute → map. No scoring logic. Dependencies arrive via an injected context. |
| Root | index.ts, config.ts |
Composition and configuration only. |
Key decisions and why:
- TypeScript everywhere, strict mode (
noUncheckedIndexedAccess,exactOptionalPropertyTypes). The brief weights maintainability heavily; strictness catches the array-indexing and optional-field bugs that this kind of "map an external JSON payload" code is prone to. - Wire types vs. domain types are separate. Open-Meteo's field names
(
temperature_2m_max) never leak pastdata/openMeteo/. The domain speakstempMaxCwith explicit units. A provider change is contained to one mapping function. - Scoring is a registry of pure functions.
ActivityScorer = (day) => { score, reason }. Adding an activity is one file + one line inactivities/index.ts; the ranking engine, GraphQL enum, and UI all derive from that registry. This is the main extensibility seam. - Trapezoidal preference curves (
band/penaltyinscoreUtils.ts) instead of ad-hocifladders. Each scorer reads as a declarative statement of "what good weather looks like" and is trivially unit-testable. Every score is 0–100 and carries a human-readablereasonfor explainability. - GraphQL via Apollo Server v5. Chosen over v4 because v4 reached
end-of-life on 26 Jan 2026; v5's API is compatible. A typed
code(NOT_FOUND/UPSTREAM_ERROR/BAD_USER_INPUT) on every error lets the client branch reliably. Stack traces are suppressed unlessNODE_ENV !== "production". - Graceful marine degradation. The Marine API only covers coastal cells.
Forecast and marine calls run concurrently (
Promise.all); a marine failure degrades towaveHeightM: nullrather than failing the request, and surfing falls back to a wind-sea proxy. Verified live: Newquay uses real swell, Chamonix uses the wind proxy.
React + Vite + Apollo Client. Intentionally minimal (the brief makes UX
secondary): a search box (useLazyQuery), a results list, and small
presentational components (RankingCard, ScoreBar). The GraphQL document is
typed end-to-end via TypedDocumentNode, so the query and the rendered fields
share one source of truth. Loading, empty, error, and result states are all
handled.
| Activity | Rewards | Penalises |
|---|---|---|
| Skiing | cold (≤ −2 °C ideal), fresh snowfall | wind gates the score (multiplier) |
| Surfing | wave height ~1–3 m (real), else wind-sea proxy | flat / oversized / cold |
| Outdoor sightseeing | mild temp (16–26 °C), dry, calm | rain is the strongest disqualifier |
| Indoor sightseeing | rises as outdoor conditions worsen; never below a floor (always an option) | — |
This project was built with Claude Code as a pair-programming assistant.
- API contract discovery. Claude fetched and summarised the Open-Meteo
geocoding, forecast, and marine docs, which surfaced the modern field names
(
wind_speed_10m_max,weather_code) and thetimezone-required-for-daily constraint — avoiding a round of trial-and-error. - Scaffolding & boilerplate. The monorepo, tsconfigs, Apollo/Vite setup, and typed GraphQL document were generated, freeing time for the parts that matter (the scoring model and layering).
- Scoring design as a conversation. The trapezoidal-curve abstraction and the activity-registry extension point were arrived at by discussing trade-offs, not dictated.
- Caught a real modelling bug. A unit test written alongside the code failed: a warm, windless day scored 20 for skiing because the calm-wind term contributed on its own. This drove a deliberate redesign — wind now gates (multiplies) the snow/cold score rather than adding to it.
- Operational catches. Claude flagged that Apollo Server v4 was EOL
(upgraded to v5) and that error stack traces were leaking to clients
(gated on
NODE_ENV).
Every suggestion was reviewed before acceptance; AI accelerated delivery and breadth of checks, it did not replace the design judgement.
Deliberately scoped out to keep one well-built vertical slice over four rushed ones (per the brief's "quality > quantity"):
| Omitted / shortcut | Why | How I'd address it |
|---|---|---|
| No response caching | Out of scope for the core demo. Open-Meteo is hit on every query. | Add a short-TTL cache keyed by rounded lat/lon (forecasts change ~hourly); Apollo response cache or a small LRU in the data layer. |
| Geocoding takes the top match only | Keeps the UX a single input box. Ambiguous names (e.g. "Springfield") silently pick one. | countryCode is already plumbed through the schema and client; surface a disambiguation dropdown in the UI. |
| Surfing wind proxy is approximate | True surf needs swell period/direction and a coastline model; out of scope. The proxy keeps the activity meaningful inland and is clearly labelled in the reason. |
Use Marine API swell period/direction where available; treat the proxy as a low-confidence estimate with a confidence flag. |
| Scoring weights are hand-tuned constants | No labelled "good day" dataset to fit against; expert-judgement defaults are defensible and transparent. | Externalise weights to config; calibrate against historical data / user feedback. |
| Frontend tests omitted | Test budget spent where risk is highest — the pure scoring/ranking engine (15 unit tests). UI is thin and presentational. | Add React Testing Library tests for the search/error/empty states and a mocked Apollo provider. |
| No rate-limiting / observability / Docker | Not needed to demonstrate the architecture. | Add request rate-limiting, structured logging/metrics, and a compose file for one-command spin-up. |
| Single best geocoding result not shown on a map | UX is explicitly secondary. | Add a small map / coordinates confirmation. |
console.log/console.errorinindex.tsstand in for a real logger (fine for a single-process demo; would be structured logging in production).- No CI pipeline committed;
npm run typecheck && npm test && npm run buildis the intended gate and all pass locally.