A tour of the Vermont Data Exploration App for someone new to the repository:
why it exists, how the pieces fit, where things live, and how we work with git
and GitHub. It reflects main as of 2026-09-28. Commands and setup live in the
README; this page explains the shape of the system behind them.
Vermont's housing, demographic, zoning, flood, and soil data is spread across Census APIs, state GIS portals, federal agencies, and regional planning commissions, each in a different format. The app puts it in one place so non-technical users (Regional Planning Commissions, Select Boards, developers, reporters) can filter, compare, map, and export it without writing code.
The design goals that shape the code:
- Separate the stages. Collecting, cleaning, querying, and serving are different jobs in different folders, so each can be tested and re-run alone.
- Reproducible data. The data is rebuilt from its sources by code. The built database is not committed to git; the logic is.
- Cross-dataset filtering. A zoning district, a flood zone, a soil type, and a population count should be filterable together even though they come from unrelated sources.
- Agent-ready. The same query layer backs the website and an MCP server, so LLM agents get the same vetted data access as the UI.
flowchart LR
S["External sources<br/>Census, CDC, BLS, VCGI, FEMA,<br/>VERSO GitHub repos"] --> C[Collect]
C --> RAW[("lake.RAW")]
RAW --> CL[Clean]
CL --> CLEANED[("lake.CLEANED")]
CLEANED --> L[Load]
L --> W[("warehouse.duckdb")]
W --> Q["Query layer<br/>backend/query"]
Q --> API["FastAPI<br/>backend/api"]
Q --> MCP["MCP server<br/>backend/mcp_server"]
API --> FE["Next.js frontend<br/>frontend/src"]
The top row runs offline as the ETL pipeline, whenever data needs
refreshing. The bottom row runs continuously as the web app, and only ever
reads warehouse.duckdb.
| Stage | What happens | Entry point |
|---|---|---|
| Collect | Scrapers fetch source data and write it, minimally changed, to lake.RAW. |
just get-data {start} {end} → run_data_collection.py |
| Clean | Each cleaner reshapes one or more RAW tables into analysis-ready tables in lake.CLEANED. |
just transform-data → run_data_cleaning.py |
| Load | Every CLEANED table is copied into warehouse.duckdb, plus a warehouse.schema.json snapshot. |
just load-data → run_data_loading.py |
| Query | Python functions and Jinja SQL templates turn filter requests into DuckDB queries. | backend/query/ |
| Serve | FastAPI routes validate requests and call the query layer; the MCP server exposes the same data. | backend/api/main.py, backend/mcp_server/ |
| Render | Next.js pages fetch from /api and draw charts, tables, and maps. |
frontend/src/app/ |
just run-etl {start_year} {end_year} runs collect → clean → load in order.
The "lake" is a DuckLake: a DuckDB catalog with
Parquet files beside it (lake and lake.files/ under DATA_DIR). RAW holds
what the source gave us; CLEANED holds what the app uses. Raw tables are
documented column by column in datasets/.
.
├── backend/ Python 3.13, managed with uv
│ ├── data_collection/ raw data scrapers: one module per source, write to lake.RAW
│ ├── data_cleaning/ raw data cleaners: RAW → CLEANED; sql/ holds their SQL
│ ├── query/ run-time query logic; sql/ holds Jinja SQL templates
│ │ ├── sql_render.py Jinja rendering + the filter compiler ($N params)
│ │ └── production_db.py lazy DuckDB connection
│ ├── api/ FastAPI app: routes/, models/, schema.json (filter tree)
│ ├── data_tools/ shared, bounded data operations (used by MCP)
│ ├── mcp_server/ MCP server (Streamable HTTP, bearer-token auth)
│ ├── ETL/ dockerfiles for the lake, collect, clean, load steps
│ ├── notebooks/ .qmd walkthroughs of data decisions
│ ├── tests/ pytest suite (golden/ holds expected CSVs)
│ ├── lake_build.py creates/attaches the DuckLake; insert/replace helpers
│ └── run_data_*.py the three ETL orchestrators
├── frontend/ Next.js (App Router), React 19, Mantine, Tailwind
│ ├── src/app/ pages: data-viewer, data-comparison, mapping,
│ │ working-report, data-export, resources, about
│ ├── src/components/ Charts, FilterRedux/FilterUI, mapping, Reports,
│ │ ItemsProvider (Zustand store for saved charts)
│ ├── src/types/ shared TypeScript types
│ └── nginx.conf serves the static export and proxies /api
├── docs/ you are here; see the index in README.md
├── justfile every dev, container, ETL, and lint command
├── Procfile `just local-dev` runs API + frontend together
└── .github/workflows/ CI: format check, MCP tests, Pages deploy
Design notes to know about:
- Routes stay thin. Business logic belongs in
backend/query/; routes validate input and format output. - Filtering is schema-driven.
backend/api/schema.jsondefines which columns can be filtered and how datasets join. See architecture/etl.md and architecture/filtering.md. - SQL is templated. Templates under
query/sql/anddata_cleaning/sql/are Jinja, linted with sqlfluff. The filter compiler emits$Nplaceholders and a params list; execute withDB.execute(sql, params), never string-format values into SQL. - The frontend talks to the API through one base URL, set by
NEXT_PUBLIC_API_URLlocally and same-origin/apiin containers.
Run everything from the repository root; just --list shows all our defined recipes.
| I want to… | Run |
|---|---|
| Run the app with hot reload locally | just local-dev (API on :6767, site on :3000) |
| Run the production-like stack | just dev (podman pod, nginx, same-origin /api) |
| Rebuild all data | just run-etl 2009 2024 (needs CENSUS_API_KEY; about 20–30 minutes) |
| Re-run one collector | just get-data 2020 2024 zoning |
| Re-run one cleaner | just transform-data clean_zoning |
| Lint everything | just lint |
| Format | just format (frontend + SQL); ruff format for Python |
| Run backend tests | just test |
| Run MCP tests (no data needed) | just test-mcp |
| Run the MCP server locally | just mcp → http://127.0.0.1:6768/api/mcp |
- Collect. Add
backend/data_collection/<source>.pywith acollect()that returns a DataFrame, or a dict of{table_name: DataFrame}. Register the module inYEARLY_SCRAPERS(needs ayearcolumn;collect(years)) orSTATIC_SCRAPERS(replaced wholesale each run) inrun_data_collection.py. - Clean. Add
backend/data_cleaning/clean_<source>.pywith amain(con)that writeslake.CLEANED.<table>. The orchestrator discovers it automatically. - Query and serve. Add a function and SQL template under
backend/query/, a route underbackend/api/routes/, and (if it should be filterable) an entry inschema.json. Expose it to agents throughdata_tools/if appropriate. - Document. Add
docs/datasets/<raw_table>.md, copyingdatasets/zoning.md. - Test. Add a pytest under
backend/tests/, and a golden CSV if the output is a published number.
mainis the deployable branch. Never commit to it directly.- Commit small:
- Open a pull request into
main. We merge with merge commits, so history readsMerge pull request #140 from …. Keep your branch current by mergingorigin/maininto it before making a PR. - Pre-commit hooks run
ruff formaton Python andnpx prettier --write .on frontend TypeScript. Install once withpre-commit install. A Claude Code hook (.claude/hooks/format.py) also formats files it edits. - Large files. CDC CSVs and
*.duckdbare tracked with Git Large File Storage (LFS) (git lfs installbefore cloning). The DuckLake catalog andlake.files/are gitignored; build them with the ETL processjust run-etlrecipe (this one takes a while). - Secrets go in
.env(gitignored). Add just the new variable names to.env.examplewith a description. - The repository lives at
VERSO-UVM/react-vt-dataon GitHub. Older clones may still haveoriginpointing atFWJK1/react-vt-data; checkgit remote -vto verify.
| Workflow | Runs on | Checks |
|---|---|---|
| Format Check | every PR, push to main |
prettier --check (frontend), ruff format --check (backend) |
| MCP tests | PRs and pushes touching backend/ |
MCP lint, format, and fixture-based tests |
| Deploy | push to main |
builds the static frontend and publishes it to GitHub Pages |
Run just lint and just test before pushing; CI runs the same checks.
The API, frontend, and ETL run as rootless podman containers on a UVM
virtual machine. The API mounts the data directory read-only; the ETL mounts it
read-write. The frontend is a static Next.js export served by nginx, which also
proxies /api/ to the API so the browser sees one origin.
To ship:
- SSH to the VM using
ssh <netid>@vtdatacollab.uvm.edu - Switch to
appuser0usingsudo su - appuser0. - Ensure the current directory is "react-vt-data" using
cd react-vt-dataandpwd. - Pull the new changes on the main branch using
git pull. - If data collection, cleaning, or loading code changed (ETL), run
just run-etl 2009 2024. If these files have no changes, you can skip this step. If there is a DB connection issue forwarehouse.duckdb, runjust down, and rerunjust load-data. - Run
just devto restart the app containers.
The step-by-step version is in the README; the verification checklist is in architecture/containers.md, and MCP rollout is in guides/mcp.md.
- Ports 3000 and 6767 are shared by the local and container workflows. Only
one can run at a time;
just downstops the pod. - Known open data issues are tracked in reports/2026-09-16-data-issues.md.
| If you want to… | Read |
|---|---|
| Understand the pipeline in depth | architecture/etl.md |
| Understand containers and rollout | architecture/containers.md |
| Understand filter object design | architecture/filtering.md |
| Look up a raw table's columns | datasets/ |
| Use or deploy the MCP server | guides/mcp.md |
| See past decisions and old designs | archive/ |