Local-first dashboard for tracking home prices over time. Scrapes property
data directly from Realtor.com's frontdoor/graphql endpoint server-side and
stores the latest fetched state on each tracked property in SQLite. The
Property view can also backfill Realtor historical AVMs, sparse market events,
and tax assessment history. Refreshes record app-observed list-price changes
for active listings.
- Backend: FastAPI + sqlite3 (stdlib). Scraping is a thin GraphQL client
over
requestsinbackend/app/scraper.py. - Frontend: React (via UMD + Babel-standalone) served as static files by the backend. No build step.
- Storage:
data/app.db(SQLite, WAL mode). Auto-created on first run.
Use Python 3.12 for this project.
python3.12 -m venv .venv312
.venv312/bin/python -m pip install \
fastapi==0.136.1 \
uvicorn==0.47.0 \
requests==2.34.1 \
pydantic==2.13.4./run.sh
# or:
.venv312/bin/python -m uvicorn backend.app.main:app --reload --port 5173Then open http://127.0.0.1:5173.
run.sh reads PORT (default 5173) and HOST (default 127.0.0.1). Set
HOST=0.0.0.0 to reach the app from other devices on your network, e.g. an
iPhone over Tailscale: HOST=0.0.0.0 ./run.sh.
AI features are optional and use DeepSeek when enabled. Keep API keys out of SQLite and source control:
cp .env.example .env
# edit .env and set:
# DEEPSEEK_API_KEY=...
# BRAVE_API_KEY=... # optional, enables web search (see below)The Admin panel stores only the ai_enabled toggle in SQLite. The backend
detects keys from the process environment first, then from the ignored local
.env file, and never returns them through the API.
The AI ask endpoint answers primarily from each property's stored context
(prices, AVMs, events, taxes, schools). If BRAVE_API_KEY is set, the assistant
can also run web searches for facts not in the stored data (neighborhood, local
market, etc.), capped per question. Without it, geocoding tools still work and
the assistant falls back to local context only.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/properties |
List properties with current state |
| GET | /api/admin/ai-settings |
AI enabled/key-present status |
| PATCH | /api/admin/ai-settings |
Update non-secret AI settings |
| GET | /api/properties/{id} |
Property + history + events + taxes + schools |
| POST | /api/properties/{id}/ai/ask |
Ask an AI question about a property (local context + optional web search) |
| POST | /api/properties |
Add property, refresh current state, and backfill history |
| PATCH | /api/properties/{id} |
Edit address/display fields and active state |
| POST | /api/properties/{id}/archive |
Hide from default dashboard and refresh-all |
| POST | /api/properties/{id}/restore |
Restore an archived property |
| DELETE | /api/properties/{id} |
Delete property plus history/events/taxes |
| POST | /api/properties/{id}/refresh |
Refresh current property state |
| POST | /api/properties/{id}/backfill |
Upsert AVMs, market events, taxes |
| POST | /api/properties/refresh-all |
Refresh current state for active properties |
| POST | /api/properties/backfill-all |
Backfill history/events/taxes for all records |
POST /api/properties returns one of:
matched, candidate_mismatch, no_candidates, error. A
candidate_mismatch requires a second call with confirm_mismatch: true to
save.
properties— one row per tracked address, including optional user-defined display name, the latest normalized AVM, list/sale prices, listing state, property facts/features, risk flags, match status, and raw Realtor JSON.property_schools— the current Realtor school list for a property, replaced on add/refresh and returned asschoolsin the detail API response.historical_estimates— monthly historical AVM series keyed by property, source, and date.property_events— Realtor market events such as listed, sold, relisted, listing removed, and price changed.observed_events— app-observed events detected during refresh, currently list-price increases/drops on the same active listing.tax_history— yearly Realtor tax bills and county assessment values.
Archived properties stay in SQLite with their history intact, but the dashboard defaults to active rows and refresh-all skips archived rows. Delete is permanent and relies on SQLite foreign-key cascades to remove history, events, and taxes.
AVM normalization picks the "best" current estimate from either of the two
shapes Realtor returns: raw["current_estimates"] (flat snake_case, returned
by the legacy search-results path) or raw["estimates"]["currentValues"]
(nested camelCase, returned by GetHomeDetails).
The Properties page uses properties.listing_state, normalized server-side from
the latest Realtor raw JSON. The dashboard buckets are:
sold— explicit current status text indicatessold/closed, and the sale date is no more than 180 days old. This wins over stalepending_datevalues left on sold listings.pending—pending_dateexists, or Realtor status text containspending,contingent, orunder contract, unless the current status is already sold/closed.for_sale— status text indicatesfor_sale,active,coming soon, or similar active listing state; as a fallback, a row with bothlisting_idandlist_priceis treated as for sale unless it has sold/closed cues.sold— sale price/date exists without active or pending cues, and the sale date is no more than 180 days old.off_market— no active/pending signal, or a sold/closed signal whose sale date is older than 180 days.
The sold window is intentionally finite so old Realtor sold records do not stay
visually "Sold" forever on the Properties page. Change
SOLD_TO_OFF_MARKET_DAYS in backend/app/scraper.py if you want a different
threshold.
The Property page is event-oriented:
- The chart renders Cotality and Quantarium as continuous AVM lines.
- Realtor listing/sale/price-change records render in the timeline and ownership history strip.
- App-observed refresh events render in the timeline separately from Realtor market history.
- The ownership-history strip zooms out across recorded sales while the chart focuses on the denser AVM period.
- The Timeline tab uses
Date,Event,Value, andChangecolumns. Estimate rows keep low/high range inline with the estimate value; market rows show list/sale/price-change values independently.
Newly added properties populate historical_estimates, property_events, and
tax_history before the add response returns. Use Backfill history on a
Property page to retry or refresh those imported history rows later.
Manual refreshes compare the previous stored list price with the newly fetched
list price. If the property stays on the same active listing and the price
changes, the app writes an observed_events row such as Price dropped or
Price increased.
Run the backend unittest suite from the repo root:
PYTHONPATH=backend .venv312/bin/python -m unittest discover -s backend -p 'test_*.py'Use the manual smoke flow when touching live Realtor fetch behavior:
./run.sh &
curl -s -X POST -H 'Content-Type: application/json' \
-d '{"address":"5907 Cape Hatteras Dr, Houston, TX 77041"}' \
http://127.0.0.1:5173/api/properties
curl -s http://127.0.0.1:5173/api/propertiesThe gear icon in the top bar opens the Admin panel (#admin). The first admin
function is Refresh jobs, which shows:
- latest sweep and active-property counts
- current properties with match/errors/no-candidate issues
- a recent manual job log stored in
localStorage - a cadence selector, defaulting to twice per month
The Refresh active now button calls POST /api/properties/refresh-all and
then reloads current property state. The cadence selector is stored in
localStorage; it does not start background work inside FastAPI. Additional
admin functions can be added alongside Refresh jobs later.
Scheduled refreshes are not implemented in this checkout. The backend exposes the hook that an external scheduler should call:
curl -s -X POST http://127.0.0.1:5173/api/properties/refresh-allKeep the HomeIndexr server running on the same port the external job calls. If scheduling is added later, use cron, launchd, or another runner outside the FastAPI process; do not add an internal background loop to the app server.
None. Local-only for v1. The frontend talks to the backend over plain HTTP, and the backend has no user model — easy to bolt a session layer on top later without disturbing storage.