Skip to content

Changelog & fixes

← Back to index

Two changelogs, different audiences

This page is the narrative history — beta tester feedback, bugs, and the why behind decisions. It's organized by theme, not strict chronology.

For the structured release log (versions, tags, Keep a Changelog format), see CHANGELOG.md at the repo root. Current release: v0.9.0 — Late beta (2026-04-11).

This is the place to capture the why behind the code — beta tester feedback, painful bugs, and the rationale for big decisions. When future-you looks at a config and asks "why did I set this to 15?", the answer should be here.


Beta tester feedback (mother & brother and others)

"Why is the Théâtre de Carouge listed for Chêne-Bougeries?"

Issue: The "About the area" hoodmaps-style descriptions had a local tip claiming Théâtre de Carouge was "à deux pas" of Chêne-Bougeries. It's not — they're on opposite sides of the city. Root cause: The local tips were hand-curated and the Chêne-Bougeries entry had a fabricated tip. Fix: Replaced with a tip about the Arve and Seymaz rivers that border the commune (the user provided the correct context). File: frontend/src/utils/areaVibes.ts Lesson: Don't write geographic claims you can't verify. Let the user (David) curate area tips since he knows Geneva.

"Cologny Smart Living is only 65 — that doesn't feel right"

Issue: A beta tester complained that Chemin du Sechard 10 in Cologny scored Smart Living 65 (grade C "Good"). Cologny is one of Geneva's most upscale residential communes with the lowest tax rate in the canton (25 centimes) and lake views of the Mont-Blanc range. Root cause: 94 of 257 Cologny cells had pct_composite_vibrancy = 25 (bottom quartile tie). These are the hillside residential areas with no nearby businesses by design. The Smart Living formula penalized them for the very thing that makes them desirable. Fix: Apply a floor of 50 to amenity_density and dev_context sub-scores in the Smart Living formula. Residential cells get a neutral contribution instead of a harsh penalty. Urban areas with high density still benefit (they're above the floor). Result: Chemin du Sechard 10 → 76 (grade B "Very good"). Cologny median 88 → 91. Vandoeuvres +5, Genthod +4, Bellevue +4. Trade-off: dense urban areas drop ~10 points on Smart Living, which is philosophically correct (they're rewarded in Daily Life instead). Files: code/scores.py, code/config.py See: Scoring > The Cologny fix

"Plainpalais → Cornavin in 4 minutes?"

Issue: Beta tester noticed car travel times were wildly optimistic. Plainpalais → Cornavin showed 4 minutes. Google Maps says 8-14. Root cause: We were using a single flat speed (20-30 km/h) on the OSM drive network, which is much shorter than real driving routes because OSM doesn't model one-way restrictions, traffic lights, or turn penalties. Pandana also treats edges as undirected (a known limitation). Fix (multi-iteration): 1. Per-edge speed from OSM maxspeed tag where available, falling back to highway-type defaults 2. Intersection penalty per edge (15s on primary/secondary, 8s on tertiary, 6s on residential, 0 on motorway) 3. Hypercenter congestion multiplier (1.5×) on minor roads inside the Plainpalais–Cornavin–Eaux-Vives bounding box, to compensate for pandana ignoring one-ways Result: Plainpalais → Cornavin: 4 → 8 min. Most routes within Google's range. Versoix and CERN routes still slightly off due to missing A1 on-ramps in OSM data. Files: code/kpis.py::build_car_travel_time_edges, build_car_pandana_network See: Data pipeline > Car calibration

"WTF is Le Conches golf club, I have never heard of it"

Issue: An AI assistant (yours truly) hallucinated a "Conches golf club" as a local tip for Chêne-Bougeries. Root cause: The first replacement tip after the Théâtre de Carouge fix was made up. Fix: User provided the correct geographic context — Chêne-Bougeries is bordered by the Arve and the Seymaz rivers — and that became the tip. Lesson: Never invent geographic claims. Always defer to the user for area-specific details.

"International school list has fabricated entries"

Issue: The first version of code/data/international_schools.json contained "Swiss International School Genève" (which doesn't exist; SIS has 19 Swiss campuses, none in Geneva) and "Geneva Montessori Bilingual School" (unverifiable, wrong postal code). Two other schools had wrong addresses. Root cause: An AI agent generated the curated list based on its training data, which included plausible-sounding but incorrect schools. Fix: Code review caught the fabrications. Removed the two fake schools. Corrected addresses for British School of Geneva (real: Avenue de Châtelaine 95A, Châtelaine), Institut International de Lancy (real: Avenue Eugène-Lance 24, Grand-Lancy), and École Moser Genève (corrected street number 77 → 81). Lesson: Always verify hardcoded location data against official school websites or independent directories before shipping. Code review by a separate agent (not the same one that wrote the data) catches this kind of issue. File: code/data/international_schools.json (now 8 verified schools)

"Geneva city has null tax rate"

Issue: Cells in the city of Geneva proper had null tax rates, breaking Smart Living scores. Root cause: SITG splits Geneva into 4 sub-municipalities (Genève-Cité, Genève-Plainpalais, Genève-Eaux-Vives, Genève-Petit-Saconnex) but the tax rate dict in config.py only had the entry "Genève". Fix: Map the 4 sub-municipalities back to "Genève" before tax lookup. Commit 833d300. File: code/data_sources.py::assign_communes

"Quiz preference labels show literal i18n keys"

Issue: After the preference quiz, the labels on personalized sub-scores showed literal text like quiz.urban_right instead of the translated label. Root cause: PREF_LABELS used quiz.urban_right but the actual i18n keys were quiz.q_urban_right (the q_ prefix was added when the quiz questions were renamed). Fix: Updated PREF_LABELS to use the correct prefix. Commit 46a7ad9.

"FR preview subtitle reads awkwardly"

Issue: The French translation of the landing page preview subtitle was a literal translation that didn't sound natural to a native French speaker. Fix: Rewritten to be more idiomatic. Commit 2487e2c. Lesson: Don't machine-translate user-facing copy. Get a native speaker (or at least a careful re-read in the target language).


Critical bugs fixed

Pipeline OOM during multimodal travel times

Symptom: Pipeline died with exit code 137 (OOM kill) when computing walk + bike + car travel times. Root cause: Three OSM networks (walk ~70K nodes, bike ~37K nodes, drive ~10K nodes) loaded simultaneously alongside pandana's contraction hierarchy precomputes. Memory peaked at 8+ GB. Fix iterations: 1. Tried simplify=False on the drive network — made it worse (177K nodes) 2. Tried different precompute distances — partial improvement 3. Final fix: Load one network at a time, compute its KPIs, then del net + gc.collect() before loading the next. See code/pipeline.py for the sequential walk → bike → drive pattern. Commits: ac94198, 022fd6f, 3e29351 File: code/pipeline.py

Unlock returns 500 (RATE_LIMIT_KV crash)

Symptom: POST /api/report/unlock was returning 500 in production. The user paid, the webhook fired, but the unlock endpoint failed. Root cause: We had KV-based rate limiting on the unlock endpoint that hit the daily KV write limit on the free tier. The KV binding threw an unhandled exception, surfaced as 500. Fix: Removed KV rate limiting from unlock entirely (use Cloudflare WAF rules in the dashboard instead). Wrapped all rate-limit calls in try/catch so a broken KV binding never bricks the API. Commit 6d83581. Files: worker/src/routes/unlock.ts, worker/src/middleware/rateLimit.ts Lesson: Public-facing endpoints should fail-open on rate limit infrastructure, not fail-closed.

Stripe webhook didn't credit tokens

Symptom: User paid via Stripe checkout, returned to the teaser, saw "0 tokens" and couldn't unlock. Root cause: process_purchase() RPC didn't exist in the database — migration_005 hadn't been run yet. Fix: Ran migration_005 in Supabase dashboard. Token credit worked immediately. Lesson: Database migrations must be deployed BEFORE the code that calls them. Add a runbook step for "verify migrations are run before deploying worker."

API URL trailing slash bug

Symptom: Some API calls failed silently because the URLs had double slashes (//api/unlock). Root cause: VITE_API_URL had a trailing slash, and we were constructing URLs as ${API_BASE}/api/unlock. Fix: Strip trailing slashes from API_BASE at module load time: (import.meta.env.VITE_API_URL || '').replace(/\/+$/, ''). Commit a2c948d. File: frontend/src/api/client.ts

Parks cache index mismatch

Symptom: After a pipeline re-run, parks coordinates were assigned to the wrong H3 cells. Root cause: The parks cache stored both the GeoDataFrame and a separate centroid pickle, but the indices got out of sync after a filtering operation. Fix: Reset both indices before caching, and load them as a single unit. Commit 94df335. File: code/data_sources.py::load_osm_parks

POI ID float handling

Symptom: nearest_pois from pandana returned float POI IDs, but the lookup dictionary used integer keys. Half the POI mappings came back as NaN. Root cause: Pandana returns NaN for "no POI within range", and we cast the result column to int without handling NaN first. Fix: int_ids = nearest['poi1'].fillna(-1).astype(int), then map -1 to None. Commit 6413650. File: code/kpis.py (every compute_*_kpis function)

Migration 006 fails on missing functions

Symptom: Running migration_006_lock_rpc.sql failed with "function does not exist" if the previous migrations had been edited. Root cause: REVOKE EXECUTE FROM authenticated ON FUNCTION ... errors out if the function doesn't exist. Fix: Wrap each REVOKE in a DO $$ ... $$ block with exception handling. Commit e088d63. File: supabase/migration_006_lock_rpc.sql

Symptom: The whole app went blank after adding the GDPR cookie consent banner. Root cause: The <CookieBanner> component was placed outside the <RouterProvider>, but it called useNavigate() which requires the router context. Fix: Moved the banner inside the router context. Commit 3ff16c4.

My Reports page missing report_data

Symptom: The saved reports list showed addresses but no scores or grades. Root cause: The list endpoint (GET /api/reports) returned only metadata (id, address, generated_at) without report_data. Fix: Added report_data to the SELECT in the worker. Commit 5669b89. File: worker/src/routes/unlock.ts::handleListReports

Email enumeration oracle in /api/email-capture

Symptom: Code review found that the /api/email-capture endpoint returned { already_captured: true } on duplicate submissions. Risk: An attacker could probe whether a given email had already captured a given location, by hitting the endpoint twice and comparing the responses. Fix: Treat both new captures and duplicate-key conflicts (HTTP 409 from Postgres) as identical 201 success responses. The client can no longer distinguish them. File: worker/src/routes/email_capture.ts See: Backend > Security notes

Dark mode contrast issues

Symptom: Several components had poor contrast in dark mode (white text on light backgrounds, etc.). Root cause: Tailwind dark: classes added to ~half the components during the dark mode pass. Fix: Comprehensive audit of all 18 components, added dark: variants. Commit 27a4d99. Lesson: When introducing dark mode, audit every single component in both modes — don't trust visual inspection of one or two pages.

Quiz prefs not persisted across sessions

Symptom: User completed the personalization quiz, refreshed the page, and the personalized scores were gone. Fix: Persist quiz prefs to localStorage with key locascore_preferences, load on mount. Commit 3742783. File: frontend/src/pages/ReportPage.tsx


Major decisions

Frozen reports → Live KV reads

Original design: Saved reports stored a JSONB snapshot in Supabase at unlock time. Users always saw the snapshot when revisiting a saved report. Problem: When the pipeline re-runs with new features (e.g., daycare data, new car travel times), existing users couldn't see the updates. They were stuck with the version from purchase day. Decision: Switch saved reports to read live KV (using the stored location_key H3 cell), with the snapshot as a fallback if KV fails. Existing reports automatically benefit from new features. Trade-off: Slight inconsistency — if we ever make a change that makes a cell worse (rare), saved reports would silently degrade. But the user is paying for "the data about this location" not "the data about this location at this exact moment." Files: worker/src/routes/unlock.ts::handleGetReport, handleGetReportByAddress See: Backend > Reports table

6 page loads → 3 (AuthCheckoutModal)

Original flow for an anonymous user buying a report: 1. Teaser page 2. Click "Unlock" → redirected to SignInPage 3. Sign in → redirected back to teaser 4. Click "Unlock" again → pricing modal opens 5. Pick a plan → Stripe checkout 6. Payment success → returned to teaser → auto-navigate to report

Problem: Six page loads. Step 3-4 is especially confusing because the user has to click "Unlock" twice.

Fix: New AuthCheckoutModal component that combines auth (sign-in or sign-up) AND pricing into a single modal. Step is derived from auth state, so the moment Supabase reports a session, the modal flips to the pricing step in place — no navigation.

New flow: Teaser → (modal stays open through auth + pricing) → Stripe → return → Report. 3 page loads.

Files: frontend/src/components/ui/AuthCheckoutModal.tsx, frontend/src/pages/TeaserPage.tsx See: Frontend > AuthCheckoutModal

Removed air quality from paid report

Problem: SPAIR (the cantonal air quality data provider) has restricted commercial use terms. Permission request was sent but not yet granted. Decision: Show air quality in the FREE teaser (advertising-like use, lower legal risk) but remove it from the paid report (which is clearly commercial). When SPAIR licensing is resolved, we'll move it back to the paid report. Commit 35ddfbe.

A-F grades → Descriptive bands

Original: Letter grades A/B/C/D/F shown on every gauge. Problem: Beta testers didn't immediately understand what "C" meant in context. "Average" or "Good" is more intuitive. Decision: Replace letter grades with descriptive bands ("Excellent", "Very good", "Good", "Moderate", "Limited"). The underlying numeric score is unchanged. Commit 5f5d9d5. Note: Internally we still use Grade letters (A/B/C/D/F) for the aria-labels and the data type, but the user-visible label is the descriptive band.

KV daily write limit → Workers Paid plan

Problem: Cloudflare Workers Free plan caps KV writes at 1,000/day. One full pipeline upload uses 17,097 writes — we'd need 18 days to do a single upload. Beta iteration was bottlenecked. Decision: Upgrade to Workers Paid (CHF 5/month). Bumps the KV write limit to 1M/month. Velocity > cost. Side benefit: Also unlocks 30s CPU limits (vs 50ms free) and Durable Objects, useful for future features. See: Stack > Cloudflare Workers

Rate limit fail-open vs fail-closed

After the RATE_LIMIT_KV crash that broke the unlock endpoint, we adopted a per-endpoint policy:

  • Email capture (public, PII collection): Fail-CLOSED on the per-minute limit. Better to briefly reject legit users than serve an unmetered PII endpoint during a KV outage.
  • Email capture daily cap: Fail-OPEN. The per-minute limit is the primary defense.
  • Feedback (authenticated): Fail-OPEN. Auth is the primary defense; rate limit is just spam mitigation.
  • Unlock: No KV rate limit at all (use Cloudflare WAF instead).

Supabase auth → Worker JWT verification

Original: Worker called Supabase's auth.getUser(token) REST API on every request. Problem: Adds ~100ms latency per request because of the round trip to Supabase. Fix: Verify JWTs locally in the Worker against Supabase's JWKS. Cached JWKS after first fetch. Now <5ms per auth check. Commit 05a1070. File: worker/src/middleware/auth.ts

Mapbox geocoding → Swiss Federal API

Original: Mapbox geocoding for the address autocomplete. Problem: Mapbox has commercial use restrictions for "real estate" applications that LocaScore arguably falls under. Risk of license violation. Decision: Switch to Swiss Federal geocoding API (api3.geo.admin.ch) — free, no API key, no commercial restrictions, and it handles Swiss address formats natively. Trade-off: Less polished autocomplete UX (the Swiss Fed API is slower and returns fewer suggestions), but legally bulletproof.

No CI/CD pipeline yet

Decision: Deploys are manual via wrangler. Tests run manually via npm run test:e2e. No GitHub Actions yet. Rationale: Solo dev, low velocity right now. Adding CI introduces maintenance burden + secret management complexity. Will revisit when a second developer joins or when soft-launch traffic justifies automated regression checks on every PR.

Score re-percentile at the end

Why: After the weighted sum of sub-components, we re-percentile the result across all 17K cells. This is because no real cell excels on EVERY sub-component, so the raw weighted averages bunch between 40 and 70 — users would never see grade A or grade F. Re-percentiling gives a proper 0-100 distribution and meaningful grade thresholds. See: Scoring > The algorithm


Pipeline iterations (in order)

Iteration What changed Why
1 Initial pipeline with basic SITG data + walk network Baseline
2 Added vibrancy diversity (Shannon entropy, k-ring) Raw weighted sums favored bank-heavy areas
3 Added noise rasters (OFEV) Critical for Family/Smart Living scores
4 Added air quality rasters Asked for by beta testers
5 Added GTFS transit times via r5py Euclidean distances were misleading
6 Added supermarket brands (Coop/Migros/Denner) Beta tester wanted brand-specific
7 Added school names (cleaned French articles) Generic "school" was less useful than the actual name
8 Added real estate prices (OCSTAT 2024) High-signal data
9 Added nuisances (bars, nightclubs, construction, waste) Hidden disadvantage data
10 Added multimodal travel times (walk/bike/car) Beta tester asked for car times
11 Per-edge car travel times + intersection penalties + hypercenter multiplier Flat speeds gave 4-min Plainpalais → Cornavin
12 Added daycares (crèches) from SITG business registry Family segment
13 Added curated international schools list Expat/UN segment
14 Smart Living amenity floor at 50 Cologny scoring fix

Each iteration is captured in git log --oneline code/ with detailed commit messages.


← Back to index