Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

LayerTechnology
BackendRust + Axum
TemplatesAskama (Jinja2-like)
InteractivityHTMX
ChartsChart.js
DatabaseSQLite (WAL mode)
Schedulingtokio-cron-scheduler

Architecture Overview

Bydleni.rs is a CLI application with three subcommands:

  • fetch – download data from external sources into SQLite
  • compute – calculate affordability metrics from raw data
  • serve – 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

  1. Fetch: Each fetcher downloads data from its source and stores raw time-series rows in the time_series table (EAV model).
  2. Compute: The affordability module reads time-series data, applies Czech tax rules, and writes pre-computed rows to the affordability table.
  3. Serve: The web server reads from affordability and renders pages. Background scheduler re-runs fetch + compute every 6 hours.

Database

SQLite with WAL mode for concurrent reads. Two migration files:

  • 001_init.sqltime_series, affordability, fetch_log tables
  • 002_phase3.sql – net wage columns, savings columns, example_listings table

Data Pipeline

Fetchers

Each fetcher implements a similar pattern: HTTP request, parse response, insert into time_series table.

FetcherSourceFormatKey challenges
FREDJSON APIStandard RESTRate limiting
CNBPlain text filesPipe-delimited, Czech decimalsEncoding, number parsing
CZSOCSV via package APIStandard CSVFinding the right dataset ID
SrealityInternal JSON API_embedded fieldLocality mapping, non-breaking spaces

Compute pipeline

The compute subcommand runs compute_all() which iterates over all regions:

  1. Look up latest avg_price_m2 from Sreality data
  2. Look up latest wage from CZSO data, mapped via city_to_kraj()
  3. Look up mortgage rate (CNB or fallback synthesis)
  4. Calculate: flat price, mortgage payment, months-to-buy, net wage, savings timeline
  5. Write one row per region to affordability table

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 page
  • GET /compare – regional comparison page

API routes (server/api/)

  • GET /api/chart/* – Chart.js JSON data endpoints
  • POST /api/mortgage-calc – HTMX mortgage calculator
  • POST /api/recalc-savings – HTMX savings recalculator
  • POST /api/scenario/* – personalized scenario endpoints
  • GET /api/status – refresh status

AppState

Shared state contains:

  • pool: SqlitePool – database connection
  • refreshing: AtomicBool – whether a background fetch is running
  • last_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-1 to --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 support
  • scenario.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)
brnojihomoravsky
ostravamoravskoslezsky
plzenplzensky

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 – flats
  • category_type_cb=1 – sale (2 for 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 _embedded field (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

MetricFormula
Flat priceavg_price_m2 * 60
Mortgage paymentStandard amortization formula (default: 80% LTV, 30y)
Payment-to-wage ratiomonthly_payment / net_wage * 100
Rent vs mortgagemonthly_rent / monthly_payment
Monthly savingsnet_wage - living_expenses
Years to saveFuture 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 points
  • extrapolate_recent() – project values forward
  • build_affordability_forecast() – Chart.js-ready data
  • build_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

FieldDefaultDescription
Net income(required)Monthly net income in CZK
Current savings0Existing savings in CZK
Flat size60 m2Target flat size
Mortgage rate5.0%Annual mortgage interest rate
LTV80%Loan-to-value ratio
Mortgage term30 yearsLoan duration
Monthly expenses17,000 CZKLiving expenses (excl. housing)
Investment return7%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:

  1. Add a field to the relevant *Methodology struct in src/server/methodology.rs
  2. Build the MethodologyNote in the corresponding build_*_methodology() function
  3. Add the explain_*: String field to the template struct in src/server/templates.rs
  4. Pass the value in the route handler
  5. 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 = 100
  • use_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/.