Changelog & fixes¶
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
Blank page after merging cookie banner¶
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.