Bydleni.rs
Czech housing affordability dashboard built with Rust, Axum, Askama, HTMX, and Chart.js.
This application aggregates data from four sources (FRED, CNB, CZSO, Sreality) to answer a simple question:
How many years of net salary does it take to buy a 60m2 flat in the Czech Republic?
Key capabilities
- Regional affordability comparison across all 14 Czech regions
- Interactive SVG map with heat-colored data
- Affordability and rent-burden forecasts
- Mortgage and savings calculators
- Personalized “My Budget Scenario” mode (localStorage, no account)
- Automatic background data refresh every 6 hours
Tech stack
| Layer | Technology |
|---|---|
| Backend | Rust + Axum |
| Templates | Askama (Jinja2-like) |
| Interactivity | HTMX |
| Charts | Chart.js |
| Database | SQLite (WAL mode) |
| Scheduling | tokio-cron-scheduler |
Architecture Overview
Bydleni.rs is a CLI application with three subcommands:
fetch– download data from external sources into SQLitecompute– calculate affordability metrics from raw dataserve– start the Axum web server
Module structure
src/
main.rs -- clap CLI entry point
config.rs -- env-based Config struct
db.rs -- SQLite connection pool + migrations
fetchers/ -- data acquisition (FRED, CNB, CZSO, Sreality)
models/ -- database row types
compute/ -- business logic (affordability, tax, forecast, scenario)
server/ -- web server (routes, API, templates, scheduler)
Data flow
- Fetch: Each fetcher downloads data from its source and stores raw time-series rows in the
time_seriestable (EAV model). - Compute: The affordability module reads time-series data, applies Czech tax rules, and writes pre-computed rows to the
affordabilitytable. - Serve: The web server reads from
affordabilityand renders pages. Background scheduler re-runs fetch + compute every 6 hours.
Database
SQLite with WAL mode for concurrent reads. Two migration files:
001_init.sql–time_series,affordability,fetch_logtables002_phase3.sql– net wage columns, savings columns,example_listingstable
Data Pipeline
Fetchers
Each fetcher implements a similar pattern: HTTP request, parse response, insert into time_series table.
| Fetcher | Source | Format | Key challenges |
|---|---|---|---|
| FRED | JSON API | Standard REST | Rate limiting |
| CNB | Plain text files | Pipe-delimited, Czech decimals | Encoding, number parsing |
| CZSO | CSV via package API | Standard CSV | Finding the right dataset ID |
| Sreality | Internal JSON API | _embedded field | Locality mapping, non-breaking spaces |
Compute pipeline
The compute subcommand runs compute_all() which iterates over all regions:
- Look up latest
avg_price_m2from Sreality data - Look up latest wage from CZSO data, mapped via
city_to_kraj() - Look up mortgage rate (CNB or fallback synthesis)
- Calculate: flat price, mortgage payment, months-to-buy, net wage, savings timeline
- Write one row per region to
affordabilitytable
Historical snapshots
compute --historical generates affordability rows for 2010, 2015, and 2020 by:
- Using FRED property price index to scale current prices backward
- Using historical CZSO wage data
- Estimating rent by scaling current rent with price index ratio
Server Architecture
Router structure
The Axum server has two route groups:
Page routes (server/routes/)
GET /– index page (SVG map, chart, data table)GET /region/:slug– region detail pageGET /compare– regional comparison page
API routes (server/api/)
GET /api/chart/*– Chart.js JSON data endpointsPOST /api/mortgage-calc– HTMX mortgage calculatorPOST /api/recalc-savings– HTMX savings recalculatorPOST /api/scenario/*– personalized scenario endpointsGET /api/status– refresh status
AppState
Shared state contains:
pool: SqlitePool– database connectionrefreshing: AtomicBool– whether a background fetch is runninglast_refresh: RwLock<Option<String>>– timestamp of last successful refresh
Templates
Askama templates with a custom HtmlTemplate<T> wrapper that implements IntoResponse. Display helpers (fmt_thousands, severity_color, slug_to_display_name) are in templates.rs.
Background scheduler
On startup, the server triggers an immediate fetch+compute, then schedules a cron job every 6 hours via tokio-cron-scheduler. The server starts serving immediately with whatever data exists in SQLite.
Frontend Architecture
Stack
- Askama templates (Jinja2-like, compiled at build time)
- HTMX for interactive form submissions without full page reloads
- Chart.js for data visualization
- Vanilla JS for map interaction and scenario management
Template hierarchy
templates/
base.html -- nav, footer, theme toggle, script includes
index.html -- homepage (map, charts, scenario panel, data table)
pages/
region.html -- region detail (metrics, calculators, charts, listings)
compare.html -- comparison table + charts
404.html -- not found page
CSS design system
FT (Financial Times) editorial-inspired design with:
- Dark mode default, light mode toggle
- CSS custom properties for all colors
- Heat scale (
--heat-1to--heat-5) for affordability severity - Salmon accent color (
--salmon) throughout - JetBrains Mono for numeric values
- Playfair Display for headings
JavaScript
charts.js– Chart.js wrappers (loadBarChart,loadLineChart,loadForecastChart), dark mode supportscenario.js– My Budget Scenario (localStorage, form prefill, auto-submit, cross-page sync)
FRED (Federal Reserve Economic Data)
Overview
FRED provides historical property price indices and interest rate data used for trend analysis and historical comparisons.
API
Standard JSON REST API at https://api.stlouisfed.org/fred/series/observations.
Requires a free API key (set via FRED_API_KEY environment variable).
Data used
- Property price index for Czech Republic
- Used to scale current prices backward for historical snapshots
- Approximately 1,060 records
CNB (Czech National Bank)
Overview
CNB provides monetary policy data, including the 2-week repo rate used to estimate mortgage rates.
Data access
The ARAD REST API requires registration. Instead, we use freely available plain-text history files at cnb.cz/cs/casto-kladene-dotazy/.galleries/.
Format
- Pipe-delimited text files
- Czech decimal commas (
,instead of.) - Approximately 306 records
Mortgage rate
The mortgage rate is synthesized as repo_rate_2w + 2.5 percentage points (spread). If real MFI data (mortgage_rate_avg) is available, it takes priority.
CZSO (Czech Statistical Office)
Overview
CZSO provides average wage data by region (kraj), essential for computing affordability ratios.
Data access
Use the package API: https://vdb.czso.cz/pll/eweb/package_show?id=DATASET_ID to get CSV download URLs.
Dataset 110080 contains wage data.
Key details
- Wage data is per kraj (region), not per city
city_to_kraj()mapping connects Sreality city slugs to CZSO region slugs- Approximately 210 records
Mapping example
| City (Sreality) | Kraj (CZSO) |
|---|---|
| brno | jihomoravsky |
| ostrava | moravskoslezsky |
| plzen | plzensky |
Sreality
Overview
Sreality is the largest Czech real estate portal. We use its internal JSON API to fetch current flat prices (sale and rent) across regions.
API
Endpoint: https://www.sreality.cz/api/cs/v2/estates
Key parameters:
category_main_cb=1– flatscategory_type_cb=1– sale (2for rent)locality_region_id– for Praha (spans multiple districts)locality_district_id– for other cities
Challenges
- SPA with internal API (no official documentation)
- JSON uses
_embeddedfield (requires serde rename) - Listing names contain non-breaking spaces (
\u{a0}) before “m2” - Detail URLs constructed from
hash_id+ SEO locality slug
Data extracted
- Average price per m2 (sale and rent)
- Median price per m2
- Example listings near median for display on region pages
Affordability Metrics
Primary metric
Months of net salary to buy a 60m2 flat – computed as:
months = flat_60m2_price / avg_monthly_wage_net
On the homepage and compare page, this is displayed as years (months / 12).
Net wage calculation
Gross wages from CZSO are converted to net using Czech 2025 tax rules:
- 12.2% social/health insurance
- 15% / 23% income tax (progressive brackets)
- 2,570 CZK/month personal tax credit
Implemented in compute/czech_tax.rs.
Additional metrics
| Metric | Formula |
|---|---|
| Flat price | avg_price_m2 * 60 |
| Mortgage payment | Standard amortization formula (default: 80% LTV, 30y) |
| Payment-to-wage ratio | monthly_payment / net_wage * 100 |
| Rent vs mortgage | monthly_rent / monthly_payment |
| Monthly savings | net_wage - living_expenses |
| Years to save | Future value of annuity formula (default: 7% annual return) |
Severity colors
Based on months-to-buy:
- heat-1 (green): < 100 months
- heat-2: 100-110
- heat-3: 110-130
- heat-4: 130-160
- heat-5 (red): > 160 months
Forecasts
Method
Forecasts use recent-slope extrapolation based on the last 2 data points, projecting 5 years forward.
Two forecast charts are displayed on the homepage:
Affordability forecast
- Shows housing affordability index over time
- Solid line = historical data, dashed line = projection
- Y-axis: index (base year = 100)
Rent burden forecast
- Shows what percentage of net salary rent costs
- Solid line = historical data, dashed line = projection
- Y-axis: % of net salary
Implementation
src/compute/forecast.rs contains:
linear_regression()– full dataset regression (kept but unused)recent_slope()– slope from last 2 data pointsextrapolate_recent()– project values forwardbuild_affordability_forecast()– Chart.js-ready databuild_rent_burden_forecast()– Chart.js-ready data
Chart rendering
Forecast data is served as JSON from /api/chart/forecast and /api/chart/rent-burden, rendered client-side by loadForecastChart() in charts.js.
My Budget Scenario
Overview
The scenario mode lets visitors enter their personal financial details and see personalized affordability across all regions. No account needed – data is stored in the browser’s localStorage.
Inputs
| Field | Default | Description |
|---|---|---|
| Net income | (required) | Monthly net income in CZK |
| Current savings | 0 | Existing savings in CZK |
| Flat size | 60 m2 | Target flat size |
| Mortgage rate | 5.0% | Annual mortgage interest rate |
| LTV | 80% | Loan-to-value ratio |
| Mortgage term | 30 years | Loan duration |
| Monthly expenses | 17,000 CZK | Living expenses (excl. housing) |
| Investment return | 7% | Expected annual return on savings |
Outputs per region
- Flat price:
avg_price_m2 * flat_size_m2 - Deposit needed:
flat_price * (1 - LTV/100) - Deposit gap:
max(deposit_needed - current_savings, 0) - Monthly payment: Standard mortgage amortization
- Payment/income %: Affordability ratio (< 40% = affordable)
- Monthly surplus:
max(net_income - expenses, 0) - Years to deposit: Time to save the deposit gap (with investment returns)
Cross-page behavior
- Index page: Full scenario form + personalized region cards sorted by affordability
- Region page: Personalized hero stats + prefilled calculators
- Compare page: Toggle between default view and personalized ranking table
- Persistence: Form values auto-restore on page reload from localStorage
Implementation
- Backend:
src/compute/scenario.rs(engine) +src/server/api/scenario.rs(4 HTMX endpoints) - Frontend:
static/scenario.js(localStorage + form management + cross-page sync)
Calculators
Mortgage calculator
Available on each region detail page. Computes:
- Monthly payment using standard amortization formula
- Total paid over the loan term
- Total interest paid
Default values are prefilled from the region’s data (flat price, current mortgage rate). If a scenario is active, values are prefilled from the scenario instead.
Formula
P = principal * (r * (1+r)^n) / ((1+r)^n - 1)
Where r = monthly rate, n = number of months.
Savings calculator
Also on each region detail page. Computes years to save the full flat price by investing monthly savings at a given return rate.
Uses the future value of annuity formula:
n = ln(1 + target * r / pmt) / ln(1 + r)
Where r = monthly rate, pmt = monthly savings amount.
HTMX integration
Both calculators use hx-post to submit the form and replace the result div with an HTML fragment returned by the server. No JavaScript framework needed.
Methodology Transparency
The dashboard displays inline expandable disclosures next to key metrics so visitors can understand how numbers are calculated, which data sources are used, and when data was last refreshed.
How It Works
Each metric group has a <details> disclosure rendered server-side. Clicking the summary label expands a paragraph with the formula, assumptions, and source attribution. No JavaScript required.
Metric Groups
Years/Months to Buy
Formula: (avg_price_m2 x 60) / net_monthly_wage (months variant), divided by 12 for years.
Net wage: Czech 2025 tax rules — 12.2% social/health insurance + 15%/23% income tax brackets, minus 2,570 CZK/month personal tax credit.
Sources: CZSO (wages by region), Sreality (asking prices).
Price per m2
Average asking price from current Sreality listings for the region. National figure is the average across all tracked regions.
Source: Sreality.
Mortgage Payment
Formula: Standard amortization M = P[r(1+r)^n] / [(1+r)^n - 1].
Defaults: 80% LTV, 30-year term. Interest rate from CNB MFI survey (mortgage_rate_avg); fallback: CNB 2-week repo rate + 2.5pp spread.
Source: CNB.
Rent vs Mortgage
Formula: (avg_rent_m2 x 60) / monthly_mortgage_payment. Values above 1.0 mean renting is more expensive.
Sources: Sreality (rent listings), CNB (mortgage rates).
Savings & Time to Ownership
Formula: monthly_savings = net_wage - living_expenses. Years to save for a 20% down payment via compound annuity formula at 7% annual investment return.
Source: CZSO (consumer basket, region-specific).
Affordability Forecast
Price and wage indices extrapolated 5 years forward using the slope between the two most recent data points (recent-slope method). This is a trend projection, not a prediction.
Sources: FRED (property price index), CZSO (wage index).
Rent Burden Forecast
Rent as percentage of net wage, projected via recent-slope extrapolation over 5 years.
Sources: Sreality (rent listings), CZSO (wages).
Data Freshness
Data is refreshed automatically every 6 hours (startup fetch + cron). The last_refresh timestamp is displayed in the hero section on the index page and appended to each methodology disclosure when available.
Assumptions & Limitations
- Prices are asking prices, not transaction prices
- Wage data is per kraj (region), not per city
- Mortgage rate fallback (repo + 2.5pp) is an approximation
- Forecasts assume recent momentum continues unchanged
- Living expenses use national/regional consumer baskets, not individual spending
Extending
To add a new disclosure:
- Add a field to the relevant
*Methodologystruct insrc/server/methodology.rs - Build the
MethodologyNotein the correspondingbuild_*_methodology()function - Add the
explain_*: Stringfield to the template struct insrc/server/templates.rs - Pass the value in the route handler
- Emit
{{ explain_*|safe }}in the Askama template
Development Setup
Prerequisites
- Rust toolchain (stable, edition 2024)
- SQLite3
Environment
Copy .env.example to .env and set:
DATABASE_URL=sqlite:data.db
FRED_API_KEY=your_key_here
Get a free FRED API key at https://fred.stlouisfed.org/docs/api/api_key.html.
First run
# Fetch data from all sources
cargo run -- fetch --all
# Compute affordability metrics
cargo run -- compute
# Start development server
cargo run -- serve
The server starts on port 3000. Open http://localhost:3000.
Development workflow
The server auto-refreshes data every 6 hours. For development, you can re-run fetch and compute manually.
# Re-fetch a single source
cargo run -- fetch --source sreality
# Recompute with historical snapshots
cargo run -- compute --historical
Testing
Unit tests
cargo test
31 tests covering:
- Mortgage calculation: standard and zero-rate scenarios
- Area/layout extraction: parsing Sreality listing names
- Czech tax: gross-to-net wage conversion
- Savings calculator: various input combinations
- Living expenses: regional lookup
- Linear regression: statistical functions
- Forecast: slope calculation and extrapolation
- Scenario: input validation, computation, sorting, severity colors
Linting
cargo clippy -- -D warnings
Must pass clean (zero warnings as errors).
Formatting
cargo fmt --check
Uses project rustfmt.toml settings:
max_width = 100use_small_heuristics = "Max"
Deployment
Build
cargo build --release
The binary is at target/release/bydleni_rs.
Running in production
DATABASE_URL=sqlite:data.db FRED_API_KEY=xxx ./bydleni_rs serve
The server listens on port 3000. Use a reverse proxy (Caddy, nginx) for:
- TLS termination
- Compression (gzip/brotli) – the app does not compress responses itself
- Static file caching
Caddy example
bydleni.example.com {
reverse_proxy localhost:3000
encode gzip
}
Data persistence
All data is in a single SQLite file (data.db by default). The background scheduler fetches and recomputes every 6 hours, so the database stays current without manual intervention.
Documentation
Documentation is built with mdBook and deployed to GitHub Pages via the .github/workflows/docs.yml workflow. It triggers on pushes to master that change files in docs/.