openvibers/openvibe.rent Commits

Rent: home prices around a searched place, from the FHFA House Price Index (#30)

OP OpenVibers committed 12 Oct 2026 · 04db94e26f · 12 files, +602 −16

Rent's affordable-housing search (`/housing?q=<place>`) now says what is happening to home prices there, from the **FHFA House Price Index**. That data is public domain (a U.S. government work), and `fhfa.gov/robots.txt` allows `/hpi/download`.

- **Ingest** (`server/housing/hpi.js`):
  - Downloads FHFA's master CSV at most once every 7 days and reads it line by line with a quote-aware CSV reader (metro names are quoted and contain commas).
  - Keeps the quarterly all-transactions index for every state and metro from the last six years and replaces them in one transaction (migration `0006_area_price_index.sql`, expand).
  - Runs behind `RENT_HPI` (on in production). A failure keeps the old rows.
- **Lookup:** the searched place's metro (its city in the metro's name and its state among the metro's states), otherwise its state. Changes over the past year and five years come from the index, and a missing quarter is never filled in.
- **Page:** a "Home prices around <metro or state>" card above the results, with two stat tiles (design pass), the index name and quarter, "an index of repeat sales, not a price", and the source link. FHFA is named next to HUD in the attribution line. `GET /api/v1/housing` adds `area_prices`.

Checked against FHFA's real file: 186,021 rows scanned and 9,415 kept in 5.7 s.
- Denver → the Denver-Aurora-Centennial metro, −0.5 % over the past year and +21.4 % over five years (Q2 2026).
- Gadsden, AL → its metro.
- St. Louis → St. Louis, MO-IL.
- A small Colorado town → Colorado.

Tests: the full suite (21 files), including the new `housing-hpi` (ingest filtering and replacement, failure keeps old rows, metro-then-state match, percentages, the card and the API field).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex b8eea68..1c53466 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,20 @@
What changed in OpenVibe.Rent, newest first. Each site also publishes its patch notes at /updates.
++## 0.3.0 — 2026-10-12
++
++"Home prices around here" beside the affordable-housing results.
++
++- **FHFA House Price Index context.** `/housing` shows a small card above the results: the change in the FHFA
++ all-transactions index over the past year and five years, for the MSA that matches the place (or the state), rounded
++ to 0.1 %, with the latest quarter. The index is a repeat-sales index, not a price, and the card links back to FHFA.
++- **Ingest.** The FHFA master CSV is streamed line by line (never held whole), keeps only the traditional,
++ all-transactions, quarterly State and MSA rows from the last six years, and replaces them in one transaction — a
++ failed refresh keeps the previous rows. It runs from the background timer behind `RENT_HPI` (on in production) and
++ fetches at most once every 7 days.
++- **API.** `GET /api/v1/housing` answers `area_prices` (`{ place, level, year_change_pct, five_year_change_pct,
++ latest, source }`), or `null` when there is no data.
++
## 0.2.0 — 2026-10-08
The product: people post listings, anyone searches them, and reports bring a listing to staff.
ddiff --git a/README.md b/README.md
iindex 710cf89..6622ac4 100644
---- a/README.md
++++ b/README.md
@@@ -4,7 +4,7 @@
**Status:** v1 works end to end: people post listings, anyone searches them, three reports hide one until staff look, and
"Affordable housing near you" shows HUD's public-domain housing data (public housing, LIHTC, HUD-assisted multifamily,
--public housing authorities and housing counselling agencies) near a place a visitor types.
++public housing authorities and housing counselling agencies) near a place a visitor types, with FHFA home-price context beside it.
**Domain:** `openvibe.rent` · **Port:** 5010 · **Service id:** `rent` · **Env prefix:** `RENT`
**License:** AGPL-3.0 (same as every OpenVibe service).
@@@ -20,7 +20,8 @@ Separately, the site is useful before anyone has posted anything: **"Affordable
public housing, tax-credit (LIHTC) and HUD-assisted multifamily properties, public housing authorities and housing
counselling agencies near a place a visitor types, from the U.S. Department of Housing and Urban Development's open
data (public domain). It is the same "near you" pattern as OpenVibe.Food's food banks, but from HUD's ArcGIS instead
--of OpenStreetMap.
++of OpenStreetMap. Beside those results, a small card shows what is happening to home prices there, from the FHFA House
++Price Index — a repeat-sales index, not a price.
Three rules shape the product:
@@@ -51,6 +52,9 @@ Rent's own data is its PostgreSQL database ([server/db.js](server/db.js)); the `
Nominatim geocode and HUD ArcGIS query answer, kept for `RENT_HOUSING_CACHE_TTL_MS` (7 days), so a place is
geocoded once and an area is asked once, not once per visitor
([migrations/0005_housing_cache.sql](migrations/0005_housing_cache.sql)).
++- **`area_price_index`** — the FHFA House Price Index rows behind "Home prices around here": the last six years of
++ traditional, all-transactions, quarterly State and MSA index values, replaced whole on each refresh
++ ([migrations/0006_area_price_index.sql](migrations/0006_area_price_index.sql)).
`migrations/0001_initial.sql` creates no tables (the skeleton). Every row keys a person as `user:usr_…`, and the
namespace is `rent.*`.
@@@ -84,9 +88,10 @@ namespace is `rent.*`.
`legal`, `cache-policy`, `serve`, `frame`, `shell`, `seo`, `app-icon`).
For listings it fetches from no source and no third party: the only outbound calls are the Network and Events above.
--The "Affordable housing near you" feature adds two more outbound hosts, both open data with published usage policies —
--Nominatim (a place name → coordinates) and HUD's ArcGIS FeatureServer (the six housing layers) — paced and cached in
--[server/housing/upstream.js](server/housing/upstream.js).
++The "Affordable housing near you" feature adds three more outbound hosts, all open data with published usage policies —
++Nominatim (a place name → coordinates), HUD's ArcGIS FeatureServer (the six housing layers) and the FHFA House Price
++Index master CSV — paced and cached in [server/housing/upstream.js](server/housing/upstream.js) and streamed into
++[server/housing/hpi.js](server/housing/hpi.js).
## Capabilities
@@@ -109,6 +114,7 @@ Nominatim (a place name → coordinates) and HUD's ArcGIS FeatureServer (the six
| Staff | [server/listings/staff.js](server/listings/staff.js) | Who is staff is the Network **role claim** (`admin`, `global_mod`), decided in one place, exactly as at OpenVibe.Help. The pages and the API cannot drift apart. |
| Saving a search | `/saved` | Save the current filters (signed in) and see, each time you open the page, how many listings have appeared since you last looked. Alerts by notification come later through OpenVibe.Watch. |
| Affordable housing | [server/housing/places.js](server/housing/places.js), [upstream.js](server/housing/upstream.js) | `/housing` geocodes a place with Nominatim, then reads six HUD ArcGIS layers — public housing, LIHTC, HUD-assisted multifamily, public housing authorities, housing counselling agencies and HUD homes for sale (`SF_REO`, HUD's March 2026 snapshot, each linked to HUD Homestore to confirm it is still for sale) — normalised, distance-sorted and grouped, with the HUD attribution on the page. |
++| Home prices | [server/housing/hpi.js](server/housing/hpi.js) | A small card on `/housing` shows the FHFA House Price Index change over the past year and five years for the matching MSA (or the state), as a repeat-sales index — never a price — with the source linked back. |
| Pages | [server/http/pages.js](server/http/pages.js) | Home, `/housing`, `/listings`, `/listings/:id`, `/listings/:id/edit`, `/post`, `/mine`, `/saved`, `/safety`, `/staff`, `/updates` — all server-rendered, all complete without JavaScript, forms are plain POSTs. |
| Discovery | [server/http/discovery.js](server/http/discovery.js) | `robots.txt` (disallows `/auth/`, `/api/`, `/post`, `/mine`, `/saved`, `/staff`), `sitemap.xml` (every active `/listings/:id`, up to 1000), `llms.txt`, `llms-full.txt`. |
| Sign-in | [server/auth/sso.js](server/auth/sso.js) | OAuth 2 authorization code with PKCE (S256) against OpenVibe.Network; httpOnly `rent_at` / `rent_rt` cookies; `/auth/me` for the shared navbar. |
@@@ -147,8 +153,8 @@ scraped, imported or fetched from anywhere. There is no outbound fetch for a lis
as text and shown as a link, and this service never opens it (a URL a caller sent must never decide what this service
requests).
--**Affordable housing near you** has two verified, open sources, read live and cached for 7 days
--([server/housing/upstream.js](server/housing/upstream.js)):
++**Affordable housing near you** has three verified, open sources ([server/housing/upstream.js](server/housing/upstream.js)
++and [server/housing/hpi.js](server/housing/hpi.js)):
- **U.S. Department of Housing and Urban Development (HUD)** open data, on ArcGIS — public housing, LIHTC, HUD-assisted
multifamily, public housing authorities and housing counselling agencies. Public domain (USA.gov
@@@ -158,6 +164,9 @@ requests).
the property or authority.
- **Nominatim** (OpenStreetMap) — the place name → coordinates and bounding box for the search box only; the housing
results themselves come from HUD. One request a second at most, with a descriptive `RENT_USER_AGENT`.
++- **FHFA House Price Index** (U.S. Federal Housing Finance Agency) — the `hpi_master.csv` file, streamed line by line
++ and kept for the last six years; public domain (U.S. government work). The card says it is a repeat-sales index, not
++ a price, and links to https://www.fhfa.gov/data/hpi.
The only other external service this process talks to is **OpenVibe.Network** (its own OAuth token endpoint and its
JWKS), with its terms as the network's own service. When listing sources are added they will be named here with their
@@@ -182,6 +191,10 @@ defaults):
- `RENT_HOUSING_CACHE_TTL_MS` — `604800000` (7 days)
- `RENT_HOUSING_MIN_INTERVAL_MS` — `1000` (one request a second to a host)
- `RENT_HOUSING_TIMEOUT_MS` — `25000`
++- `RENT_HPI` — on in production, off elsewhere; turns the FHFA House Price Index ingest and card on
++- `RENT_HPI_URL` — `https://www.fhfa.gov/hpi/download/monthly/hpi_master.csv`
++- `RENT_HPI_TIMEOUT_MS` — `60000`
++- `RENT_HPI_REFRESH_TTL_MS` — `604800000` (one fetch at most every 7 days)
## Development and tests
@@@ -218,6 +231,9 @@ through PostgreSQL and PgBouncer (see [.github/workflows/ci.yml](.github/workflo
geocoding and bounding-box clipping, the ArcGIS query URL, normalisation of all six HUD layers (null for a missing
value), distance sort, the cache (a second request does not fetch), the upstream-failure and non-US states, the page
signed out with attribution, and the API.
++- [test/housing-hpi.test.js](test/housing-hpi.test.js) — the FHFA House Price Index ingest (streamed, matching rows only,
++ replaced whole), the MSA-then-state match, the percentages and the missing-quarter rule, the page card and its
++ absence without data, and the API `area_prices` field.
- [test/account-data.test.js](test/account-data.test.js) — ADR-033 export and deletion through `/internal/events`
with a stand-in Network: only the person's rows, a redelivery changes nothing, and a bad signature or forwarded
request is refused.
ddiff --git a/STATUS.json b/STATUS.json
iindex db61bb1..897a643 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -16,7 +16,7 @@
"openvibe-shared v4.0.2",
"openvibe-publishing v1.4.0"
],
-- "summary": "OpenVibe.Rent — Find a place, or rent yours out. One place to find and offer things to rent: apartments, rooms, houses, commercial space, parking and storage, and equipment. In this first version every listing is posted by a person on this site: no scraped listings and no partner feeds, no payments, no deposits and no messaging. Search with filters and facets, a listing page with a plain safety note, reports that hide a listing at 3 until staff look, saved searches that count what is new, and a 30-day expiry an owner can renew.",
++ "summary": "OpenVibe.Rent — Find a place, or rent yours out. One place to find and offer things to rent: apartments, rooms, houses, commercial space, parking and storage, and equipment. In this first version every listing is posted by a person on this site: no scraped listings and no partner feeds, no payments, no deposits and no messaging. Search with filters and facets, a listing page with a plain safety note, reports that hide a listing at 3 until staff look, saved searches that count what is new, a 30-day expiry an owner can renew, and a FHFA House Price Index card on the affordable-housing page.",
"works": [
"Posting: a signed-in person only (an app, agent or service token is refused), at most 10 active listings and 5 new ones a day, 30 days until expiry",
"The field rules: kind, title ≤120, description ≤4000, price with an ISO 4217 currency from a short list, period, city, ISO 3166-1 alpha-2 country, optional region/neighbourhood/bedrooms/available-from, https or mailto contact link",
@@@ -32,7 +32,8 @@
"Same-origin writes for the session cookie, and per-caller budgets shared by the form and the API route",
"Discovery: robots.txt (disallows /auth/, /api/, /post, /mine, /saved, /staff), sitemap.xml with every active listing (≤1000), llms.txt, llms-full.txt",
"Tests: posting rules, search and paging and facets, contact hidden from signed-out viewers, report → hide at 3 → staff restore, expiry, owner-only edits, same-origin writes, saved searches, hostile text escaped",
-- "Account export and deletion (ADR-033): the listings a person posted, the reports they filed and the searches they saved are exported to OpenVibe.Network on network.account.export_requested and deleted whole on network.account.deleted (a report others left on a deleted listing cascades away with it), with the counts confirmed back; the two Events topics are subscribed at boot (server/identity/account-data.js)"
++ "Account export and deletion (ADR-033): the listings a person posted, the reports they filed and the searches they saved are exported to OpenVibe.Network on network.account.export_requested and deleted whole on network.account.deleted (a report others left on a deleted listing cascades away with it), with the counts confirmed back; the two Events topics are subscribed at boot (server/identity/account-data.js)",
++ "Housing price context: a card on /housing shows the FHFA House Price Index year and five-year change for the matching MSA or state, as a repeat-sales index (not a price), with the source linked back"
],
"notYet": [
"Listing sources beyond people posting here: scraped and partner feeds arrive only when their terms are verified, and then each listing will carry its provenance (source name, link back, fetched_at)",
@@@ -55,8 +56,9 @@
"server-rendered pages",
"api mount",
"limits and readiness",
-- "deploy files"
++ "deploy files",
++ "home-price context (FHFA)"
],
"deployment": "openvibe-ovh: unit openvibe-rent.service on 127.0.0.1:5010 (git checkout /opt/openvibe.rent, env /etc/openvibe/rent.env, database ov_rent), deployed with `sudo ovhost deploy rent`; public at https://openvibe.rent since 2026-10-08.",
-- "updated": "2026-10-11"
++ "updated": "2026-10-12"
}
ddiff --git a/migrations/0006_area_price_index.sql b/migrations/0006_area_price_index.sql
nnew file mode 100644
iindex 0000000..c3a11bb
---- /dev/null
++++ b/migrations/0006_area_price_index.sql
@@@ -0,0 +1,19 @@
++-- phase: expand
++-- OpenVibe.Rent: the FHFA House Price Index behind "Home prices around here" on the affordable-housing page
++-- (server/housing/hpi.js). One row per place per quarter, only the matching rows the ingest keeps (traditional,
++-- all-transactions, quarterly, State or MSA), replaced whole on each refresh. `index_nsa` is the index value, not
++-- a price; `states` is a comma-separated list of the two-letter state codes the place covers. Nothing here is a
++-- credential, key or token.
++
++CREATE TABLE area_price_index (
++ level text COLLATE "C" NOT NULL, -- 'state' | 'msa'
++ place_id text COLLATE "C" NOT NULL, -- state: the two-letter code; msa: FHFA's CBSA code
++ place_name text NOT NULL, -- state: 'Colorado'; msa: 'Denver-Aurora-Centennial, CO'
++ states text NOT NULL, -- comma-separated two-letter codes, e.g. 'CO' or 'DC,VA,MD,WV'
++ yr integer NOT NULL, -- the calendar year of the quarter
++ quarter integer NOT NULL, -- 1..4
++ index_nsa numeric NOT NULL, -- FHFA all-transactions index, not seasonally adjusted
++ fetched_at text COLLATE "C" NOT NULL, -- when this refresh was fetched (ISO 8601, UTC)
++ PRIMARY KEY (level, place_id, yr, quarter)
++);
++CREATE INDEX area_price_index_lookup ON area_price_index (level, place_id);
ddiff --git a/public/css/housing.css b/public/css/housing.css
iindex 22d60b0..1ab51d2 100644
---- a/public/css/housing.css
++++ b/public/css/housing.css
@@@ -30,3 +30,14 @@
.housing-more { margin-top: 10px; }
.housing-more summary { cursor: pointer; color: var(--accent-light, #60a5fa); padding: 6px 0; }
.housing-more .housing-list { margin-top: 8px; }
++
++/* "Home prices around here": the FHFA House Price Index card, two stat tiles and a source line. */
++.hpi-card { margin: 14px 0 6px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
++.hpi-title { margin: 0 0 10px; font-size: 1rem; line-height: 1.35; overflow-wrap: anywhere; }
++.hpi-stats { display: flex; flex-wrap: wrap; gap: 10px; }
++.hpi-stat { display: grid; gap: 2px; min-width: 132px; flex: 1 1 132px; padding: 10px 12px; border-radius: 10px; background: var(--bg-tertiary, #16223a); border: 1px solid var(--border, #1f2d47); }
++.hpi-num { font-size: 1.45rem; font-weight: 800; line-height: 1.1; font-variant-numeric: tabular-nums; color: var(--text-primary, #e6edf7); }
++.hpi-num.hpi-down { color: var(--accent-light, #60a5fa); }
++.hpi-label { font-size: .82rem; color: var(--text-secondary, #96a7c2); }
++.hpi-note { margin: 10px 0 0; font-size: .82rem; line-height: 1.5; color: var(--text-secondary, #96a7c2); }
++.hpi-note a { white-space: nowrap; }
ddiff --git a/server/config.js b/server/config.js
iindex f3ab92b..61e4ec3 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -54,6 +54,16 @@ function load(env = process.env) {
maxResults: 500,
},
++ // FHFA House Price Index, behind "Home prices around here" on the affordable-housing page
++ // (server/housing/hpi.js). The ingest respects a 7-day gate, so RENT_HPI_REFRESH_TTL_MS is a floor, not a
++ // cadence. Off in tests and wherever RENT_HPI is unset; on in production by default.
++ hpi: {
++ enabled: env.RENT_HPI != null ? (env.RENT_HPI === 'true' || env.RENT_HPI === '1') : isProduction,
++ url: trim(env.RENT_HPI_URL || 'https://www.fhfa.gov/hpi/download/monthly/hpi_master.csv'),
++ timeoutMs: Math.max(1000, int(env.RENT_HPI_TIMEOUT_MS, 60_000)),
++ refreshTtlMs: Math.max(60_000, int(env.RENT_HPI_REFRESH_TTL_MS, 7 * 24 * 60 * 60 * 1000)),
++ },
++
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
networkUrl,
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
ddiff --git a/server/housing/hpi.js b/server/housing/hpi.js
nnew file mode 100644
iindex 0000000..86b0824
---- /dev/null
++++ b/server/housing/hpi.js
@@@ -0,0 +1,279 @@
++'use strict';
++
++/**
++ * "Home prices around here" on Rent's affordable-housing page: the FHFA House Price Index (U.S. Federal Housing
++ * Finance Agency; public domain, U.S. government work). The monthly master CSV is streamed line by line (never held
++ * whole), the rows this product uses are kept — traditional, all-transactions, quarterly, State or MSA — and replaced
++ * in one transaction, so a failed refresh leaves the previous rows untouched.
++ *
++ * The index is a repeat-sales index, not a price: callers must say so and link it back (the page and API do).
++ */
++
++const SOURCE = {
++ name: 'FHFA House Price Index',
++ url: 'https://www.fhfa.gov/data/hpi',
++};
++const ATTRIBUTION = {
++ text: 'Source: FHFA House Price Index',
++ url: SOURCE.url,
++ license: 'Public domain (U.S. government work)',
++};
++const DEFAULT_URL = 'https://www.fhfa.gov/hpi/download/monthly/hpi_master.csv';
++const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
++const YEARS = 6;
++
++const norm = (v) => String(v == null ? '' : v).toLowerCase().replace(/[^a-z0-9]/g, '');
++const upper = (v) => String(v == null ? '' : v).trim().toUpperCase();
++const round1 = (n) => Math.round(n * 10) / 10;
++const pct = (latest, earlier) => (Number.isFinite(earlier) && earlier !== 0 ? (latest / earlier - 1) * 100 : null);
++
++/**
++ * A streaming CSV reader over decoded string chunks. It handles quoted fields (with commas) and a quoted field that
++ * happens to span a chunk boundary, while holding only the current record plus the unread tail — never the file.
++ */
++async function* csvRecords(chunks) {
++ let buf = '';
++ let row = [];
++ let field = '';
++ let quoted = false;
++ let i = 0;
++
++ for await (const chunk of chunks) {
++ buf += chunk;
++ while (i < buf.length) {
++ const c = buf[i];
++ if (quoted) {
++ if (c === '"') {
++ if (i + 1 >= buf.length) break; // need the next chunk to tell "" from a closing quote
++ if (buf[i + 1] === '"') { field += '"'; i += 2; continue; }
++ quoted = false; i += 1; continue;
++ }
++ field += c; i += 1; continue;
++ }
++ if (c === '"') { quoted = true; i += 1; continue; }
++ if (c === ',') { row.push(field); field = ''; i += 1; continue; }
++ if (c === '\n') { row.push(field); field = ''; const done = row; row = []; i += 1; yield done; continue; }
++ if (c === '\r') { i += 1; continue; }
++ field += c; i += 1; continue;
++ }
++ if (i > 0) { buf = buf.slice(i); i = 0; }
++ }
++ if (field !== '' || row.length) { row.push(field); yield row; }
++}
++
++const mapRow = (header, cells) => {
++ const o = {};
++ for (let i = 0; i < header.length; i++) o[header[i]] = cells[i];
++ return o;
++};
++
++const eq = (v, want) => String(v == null ? '' : v).trim().toLowerCase() === want;
++
++const matches = (o) => eq(o.hpi_type, 'traditional')
++ && eq(o.hpi_flavor, 'all-transactions')
++ && eq(o.frequency, 'quarterly')
++ && ['state', 'msa'].includes(String(o.level == null ? '' : o.level).trim().toLowerCase());
++
++/** The two-letter state code(s) a place covers: a state's own code, or the code(s) after an MSA's last comma. */
++function statesOf(level, placeId, placeName) {
++ if (level === 'state') {
++ const code = upper(placeId);
++ return /^[A-Z]{2}$/.test(code) ? [code] : [];
++ }
++ const part = String(placeName == null ? '' : placeName).split(',').pop() || '';
++ return part.split('-').map((x) => upper(x)).filter((x) => /^[A-Z]{2}$/.test(x));
++}
++
++/** The city names that make up an MSA's place_name: "Denver-Aurora-Centennial, CO" → denver, aurora, centennial. */
++function cityListOf(placeName) {
++ const head = String(placeName == null ? '' : placeName).split(',')[0] || '';
++ return new Set(head.split('-').map(norm).filter(Boolean));
++}
++
++/** Replace every row in one transaction; on any failure the DELETE and INSERTs roll back together. */
++async function replaceAll(s, rows, fetchedAt) {
++ await s.db.tx(async (t) => {
++ await t.query('DELETE FROM area_price_index', []);
++ for (const r of rows) {
++ await t.query(
++ `INSERT INTO area_price_index (level, place_id, place_name, states, yr, quarter, index_nsa, fetched_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
++ [r.level, r.place_id, r.place_name, r.states, r.yr, r.quarter, String(r.index_nsa), fetchedAt]);
++ }
++ });
++}
++
++/**
++ * Stream `chunks` (an async iterable of decoded CSV text chunks) and replace the table with the rows this product
++ * keeps. Returns the number of data rows scanned and kept; the latest six calendar years are kept.
++ */
++async function ingestFromStream(s, chunks, { fetchedAt = new Date().toISOString(), log = console } = {}) {
++ let header = null;
++ let maxYr = null;
++ let scanned = 0;
++ const matching = [];
++
++ for await (const cells of csvRecords(chunks)) {
++ if (!header) { header = cells; continue; }
++ scanned += 1;
++ const o = mapRow(header, cells);
++ if (!matches(o)) continue;
++ const yr = Number.parseInt(o.yr, 10);
++ const quarter = Number.parseInt(o.period, 10);
++ const index = Number(o.index_nsa);
++ if (!Number.isInteger(yr) || !Number.isInteger(quarter) || quarter < 1 || quarter > 4) continue;
++ if (!Number.isFinite(index)) continue;
++ const level = String(o.level).trim().toLowerCase() === 'msa' ? 'msa' : 'state';
++ const placeId = String(o.place_id == null ? '' : o.place_id).trim();
++ const placeName = String(o.place_name == null ? '' : o.place_name).trim();
++ if (!placeId) continue;
++ const states = statesOf(level, placeId, placeName);
++ if (!states.length) continue;
++ matching.push({ level, place_id: placeId, place_name: placeName, states: states.join(','), yr, quarter, index_nsa: index });
++ if (maxYr == null || yr > maxYr) maxYr = yr;
++ }
++
++ if (header == null || maxYr == null) throw new Error('FHFA HPI refresh found no usable rows');
++ const kept = matching.filter((r) => r.yr >= maxYr - (YEARS - 1));
++ await replaceAll(s, kept, fetchedAt);
++ return { scanned, kept: kept.length };
++}
++
++const latestFetchedAt = (s) => s.db.value('SELECT max(fetched_at) AS v FROM area_price_index');
++
++/**
++ * The background refresh: one fetch at most every `ttlMs` (7 days by default), streamed and replaced in a
++ * transaction. A fetch or parse failure throws — the caller logs one line and the old rows stay.
++ */
++async function refresh(s, {
++ url = DEFAULT_URL,
++ fetchImpl = globalThis.fetch,
++ now = () => Date.now(),
++ ttlMs = DEFAULT_TTL_MS,
++ timeoutMs = 60_000,
++ userAgent = 'OpenVibeRent/0.1',
++ force = false,
++ log = console,
++} = {}) {
++ if (!force) {
++ const last = await latestFetchedAt(s);
++ if (last && now() - Date.parse(last) < ttlMs) return { skipped: true };
++ }
++
++ let res;
++ try {
++ res = await fetchImpl(url, {
++ headers: { 'User-Agent': userAgent, Accept: 'text/csv' },
++ signal: AbortSignal.timeout(timeoutMs),
++ });
++ } catch (err) {
++ throw new Error(`could not reach FHFA right now (${err && err.name === 'TimeoutError' ? 'timed out' : 'network error'})`);
++ }
++ if (!res.ok) throw new Error(`could not reach FHFA right now (FHFA answered ${res.status})`);
++ if (!res.body) throw new Error('could not reach FHFA right now (the answer had no body)');
++
++ const decoder = new TextDecoder();
++ const chunks = (async function* () {
++ for await (const part of res.body) yield decoder.decode(part, { stream: true });
++ yield decoder.decode();
++ })();
++
++ const out = await ingestFromStream(s, chunks, { fetchedAt: new Date(now()).toISOString(), log });
++ return { ...out, skipped: false };
++}
++
++/** The change over the past year and five years, and the latest quarter label, from an ordered quarter series. */
++function changes(series) {
++ if (!Array.isArray(series) || !series.length) return null;
++ const sorted = [...series].sort((a, b) => a.yr - b.yr || a.quarter - b.quarter);
++ const byKey = new Map();
++ for (const r of sorted) {
++ const v = Number(r.index_nsa);
++ if (Number.isFinite(v)) byKey.set(`${r.yr}:${r.quarter}`, v);
++ }
++ const latest = sorted[sorted.length - 1];
++ const latestVal = byKey.get(`${latest.yr}:${latest.quarter}`);
++ if (latestVal == null) return null;
++ const yearAgo = byKey.get(`${latest.yr - 1}:${latest.quarter}`);
++ const fiveAgo = byKey.get(`${latest.yr - 5}:${latest.quarter}`);
++ return {
++ year_change_pct: yearAgo == null ? null : round1(pct(latestVal, yearAgo)),
++ five_year_change_pct: fiveAgo == null ? null : round1(pct(latestVal, fiveAgo)),
++ latest: `Q${latest.quarter} ${latest.yr}`,
++ };
++}
++
++const stateOf = (loc) => {
++ const s = upper(loc && loc.state);
++ return /^[A-Z]{2}$/.test(s) ? s : null;
++};
++
++async function pickMsa(s, city, state) {
++ const rows = await s.db.many(
++ "SELECT DISTINCT level, place_id, place_name, states FROM area_price_index WHERE level = 'msa' AND $1 = ANY(string_to_array(states, ',')) ORDER BY place_name",
++ [state]);
++ const want = norm(city);
++ if (!want) return null;
++ return rows.find((r) => cityListOf(r.place_name).has(want)) || null;
++}
++
++const pickState = (s, state) => s.db.maybe(
++ "SELECT level, place_id, place_name, states FROM area_price_index WHERE level = 'state' AND place_id = $1",
++ [state]);
++
++/**
++ * The area price context for a geocoded location (its `city` and `state` from searchPlaces' location): the MSA whose
++ * city list contains the place's city, else the state. Null when there is no data or the location has no usable state.
++ */
++async function areaPricesFor(s, location) {
++ const state = stateOf(location);
++ if (!state) return null;
++ const city = location && location.city ? String(location.city).trim() : null;
++
++ let pick = null;
++ if (city) pick = await pickMsa(s, city, state);
++ if (!pick) pick = await pickState(s, state);
++ if (!pick) return null;
++
++ const series = await s.db.many(
++ 'SELECT yr, quarter, index_nsa FROM area_price_index WHERE level = $1 AND place_id = $2 ORDER BY yr, quarter',
++ [pick.level, pick.place_id]);
++ const c = changes(series);
++ if (!c) return null;
++ return { place: pick.place_name, level: pick.level, ...c, source: { name: SOURCE.name, url: SOURCE.url } };
++}
++
++/** The background ingest timer, started only by the process entry (server/index.js), never by a request or a test. */
++function startHpiTimer(config, s, {
++ log = console,
++ fetchImpl = globalThis.fetch,
++ now = () => Date.now(),
++ everyMs = 24 * 60 * 60 * 1000,
++} = {}) {
++ const hpi = config.hpi || {};
++ let running = false;
++ const tick = async () => {
++ if (running) return { skipped: true };
++ running = true;
++ try {
++ return await refresh(s, {
++ url: hpi.url, fetchImpl, now, ttlMs: hpi.refreshTtlMs, timeoutMs: hpi.timeoutMs,
++ userAgent: config.housing && config.housing.userAgent, log,
++ });
++ } catch (err) {
++ log.warn('[OpenVibe.Rent] FHFA HPI refresh failed:', err && err.message ? err.message : err);
++ return { skipped: true, error: err && err.message ? err.message : String(err) };
++ } finally {
++ running = false;
++ }
++ };
++ tick();
++ const timer = setInterval(tick, everyMs);
++ if (timer && typeof timer.unref === 'function') timer.unref();
++ return { timer, tick };
++}
++
++module.exports = {
++ SOURCE, ATTRIBUTION, DEFAULT_URL, DEFAULT_TTL_MS, YEARS,
++ csvRecords, ingestFromStream, refresh, replaceAll, areaPricesFor, changes, startHpiTimer,
++};
ddiff --git a/server/housing/places.js b/server/housing/places.js
iindex f17f6db..927da20 100644
---- a/server/housing/places.js
++++ b/server/housing/places.js
@@@ -326,7 +326,13 @@ async function searchPlaces(upstream, { q = null, kinds = null } = {}, config) {
if (!Number.isFinite(la) || !Number.isFinite(lo)) throw new UpstreamError('housing.place.upstream', 'could not reach the place service right now (no coordinates for that place)');
const country = hit.address && (hit.address.country_code || hit.address.country)
? String(hit.address.country_code || hit.address.country).toLowerCase() : null;
-- place = { name: hit.display_name || String(q), lat: la, lon: lo, from: 'geocode', geocoded_at: g.fetched_at, country_code: country, bbox: parseBbox(hit.boundingbox) };
++ // The two pieces the FHFA House Price Index lookup matches on (server/housing/hpi.js): a city and the
++ // two-letter state code. Nominatim gives the code as address.state_code for US places.
++ const addr = hit.address || {};
++ const city = str(addr.city) || str(addr.town) || str(addr.village) || str(addr.hamlet) || str(addr.municipality)
++ || str(addr.county) || str(addr.suburb) || str(addr.city_district);
++ const state = str(addr.state_code);
++ place = { name: hit.display_name || String(q), lat: la, lon: lo, from: 'geocode', geocoded_at: g.fetched_at, country_code: country, bbox: parseBbox(hit.boundingbox), city, state };
} else {
throw new UpstreamError('housing.place.invalid', 'q: a place to search for', { status: 422 });
}
@@@ -336,7 +342,7 @@ async function searchPlaces(upstream, { q = null, kinds = null } = {}, config) {
const inUs = place.country_code == null || place.country_code === 'us';
const out = {
-- location: { name: place.name, lat: place.lat, lon: place.lon, from: place.from, geocoded_at: place.geocoded_at, country_code: place.country_code },
++ location: { name: place.name, lat: place.lat, lon: place.lon, from: place.from, geocoded_at: place.geocoded_at, country_code: place.country_code, city: place.city, state: place.state },
in_us: inUs,
box,
kinds: requested,
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 4b7a0c7..b4ecd41 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -37,6 +37,7 @@ const store = require('../listings/store');
const service = require('../listings/service');
const { isStaff } = require('../listings/staff');
const housingPlaces = require('../housing/places');
++const housingHpi = require('../housing/hpi');
/** The listing columns a caller may set, as the validate() input names. */
const INPUT_KEYS = ['kind', 'title', 'description', 'price', 'currency', 'period', 'city', 'region', 'country', 'neighbourhood', 'bedrooms'];
@@@ -68,9 +69,16 @@ function createApi(ctx) {
throw err;
});
if (!out) return undefined;
++ let areaPrices = null;
++ if (out.in_us && config.hpi && config.hpi.enabled) {
++ areaPrices = await housingHpi.areaPricesFor(s, out.location).catch((err) => {
++ ctx.log.warn('[OpenVibe.Rent] FHFA HPI lookup failed:', err && err.message ? err.message : err);
++ return null;
++ });
++ }
// HUD answers are public and change slowly; a short shared cache keeps repeat searches off the upstream.
res.set('Cache-Control', 'public, max-age=300');
-- return res.json(out);
++ return res.json({ ...out, area_prices: areaPrices });
});
// ── Who may act ─────────────────────────────────────────
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 91265aa..1d839d1 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -27,6 +27,7 @@ const store = require('../listings/store');
const service = require('../listings/service');
const { isStaff } = require('../listings/staff');
const housingPlaces = require('../housing/places');
++const housingHpi = require('../housing/hpi');
const SITE_NAME = 'OpenVibe.Rent';
const TAGLINE = 'Find a place, or rent yours out.';
@@@ -200,7 +201,18 @@ ${raw(showcase.cta({ title: 'Have something to rent out?', text: 'Post it in a m
// ── Affordable housing near you ──────────────────────────
// HUD's data is public domain, but the licence still asks for credit; every /housing page carries it, linked back.
-- const hudNote = (fetchedAt) => html`<p class="attribution small muted"><a href="${housingPlaces.ATTRIBUTION.url}" rel="noopener">${housingPlaces.ATTRIBUTION.text}</a>. The data covers the United States, and availability must be confirmed with the property or authority.${fetchedAt ? html` Fetched ${time(fetchedAt)}.` : ''}</p>`;
++ const hudNote = (fetchedAt) => html`<p class="attribution small muted"><a href="${housingPlaces.ATTRIBUTION.url}" rel="noopener">${housingPlaces.ATTRIBUTION.text}</a> · <a href="${housingHpi.ATTRIBUTION.url}" rel="noopener">${housingHpi.ATTRIBUTION.text}</a>. The housing data covers the United States, and availability must be confirmed with the property or authority.${fetchedAt ? html` Fetched ${time(fetchedAt)}.` : ''}</p>`;
++ // FHFA's index is a repeat-sales index, not a price: the card says so and links the source back.
++ const hpiPct = (n) => { const r = Math.round(n * 10) / 10; return `${r > 0 ? '+' : ''}${r.toFixed(1)} %`; };
++ const hpiCard = (p) => {
++ const stat = (n, label) => html`<div class="hpi-stat"><span class="hpi-num${n < 0 ? ' hpi-down' : ''}">${hpiPct(n)}</span><span class="hpi-label">${label}</span></div>`;
++ const stats = [p.year_change_pct != null ? stat(p.year_change_pct, 'over the past year') : '', p.five_year_change_pct != null ? stat(p.five_year_change_pct, 'over five years') : ''];
++ return html`<aside class="hpi-card" aria-labelledby="hpi-title">
++<h3 class="hpi-title" id="hpi-title">Home prices around ${p.place}</h3>
++<div class="hpi-stats">${stats}</div>
++<p class="hpi-note">FHFA all-transactions index, ${p.latest}. This is an index of repeat sales, not a price. <a href="${p.source.url}" rel="noopener">Source: ${p.source.name}</a></p>
++</aside>`;
++ };
// HUD's data covers the United States, so distances read in miles (the API keeps distance_km).
const housingMi = (km) => { const mi = Number(km) * 0.621371; return `${mi < 10 ? mi.toFixed(1) : Math.round(mi)} mi`; };
const housingQuery = (v) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, 200) : null);
@@@ -290,6 +302,15 @@ ${rest.length ? html`<details class="housing-more"><summary>Show ${rest.length}
problem = err && err.code ? err.message : 'The housing search is unavailable right now. Try again in a few minutes.';
}
}
++ let areaPrices = null;
++ if (out && out.in_us && config.hpi && config.hpi.enabled) {
++ try {
++ areaPrices = await housingHpi.areaPricesFor(s, out.location);
++ } catch (err) {
++ // Home prices are context, never a reason for the page itself to fail.
++ ctx.log.warn('[OpenVibe.Rent] FHFA HPI lookup failed:', err && err.message ? err.message : err);
++ }
++ }
const title = out && out.location.name ? `Affordable housing near ${out.location.name.split(',').slice(0, 2).join(',')}` : 'Affordable housing near you';
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
@@@ -303,6 +324,7 @@ ${housingForm({ q, selected })}
${problem ? notice(problem, 'warn') : ''}
${out ? html`<section class="housing-results">
<h2 class="housing-where">${out.location.name || 'Around those coordinates'}</h2>
++${areaPrices ? hpiCard(areaPrices) : ''}
${out.in_us
? html`<p class="muted small">${out.counts.total} found in and around this area, nearest first. Call before you visit: waiting lists and availability change.</p>${housingResults(out)}`
: html`<p class="notice warn">HUD data covers the United States only.</p>`}
ddiff --git a/server/index.js b/server/index.js
iindex 0e1b1b6..91b1614 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -10,6 +10,7 @@
const { createApp } = require('./app');
const { gracefulStop } = require('openvibe-sdk/service');
const { startExpiryTimer } = require('./listings/expiry');
++const { startHpiTimer } = require('./housing/hpi');
/**
* The process stop (openvibe-sdk/service): the HTTP drain runs, then the timers clear, the Events subscriptions stop,
@@@ -38,11 +39,15 @@ async function start() {
const expiry = startExpiryTimer(ctx.s);
await expiry.tick();
++ // FHFA House Price Index: one background refresh, gated to at most one fetch per 7 days (server/housing/hpi.js),
++ // off when RENT_HPI is off (the default in tests).
++ const hpiJob = config.hpi && config.hpi.enabled ? startHpiTimer(config, ctx.s) : null;
++
// Subscribe to the two ADR-033 topics at OpenVibe.Events (idempotent; off without RENT_EVENTS_URL and
// RENT_EVENTS_SECRET). The consumer itself is mounted in server/app.js.
const subscriptions = require('./events-consumer').startSubscriptions({ config, port: config.port, secret: config.events.secrets[0] || '' });
const extra = [() => { if (subscriptions) subscriptions.stop(); }];
-- createLifecycle({ server, ctx, timers: [expiry.timer, ctx.housingPruneTimer], extra });
++ createLifecycle({ server, ctx, timers: [expiry.timer, ctx.housingPruneTimer, ...(hpiJob ? [hpiJob.timer] : [])], extra });
return { server, ctx, expiry };
}
ddiff --git a/test/housing-hpi.test.js b/test/housing-hpi.test.js
nnew file mode 100644
iindex 0000000..ea8c396
---- /dev/null
++++ b/test/housing-hpi.test.js
@@@ -0,0 +1,194 @@
++'use strict';
++/**
++ * "Home prices around here": the FHFA House Price Index ingest, the MSA-then-state lookup, the page card and the
++ * API field. The CSV is streamed straight into the ingest (no network), and the HUD/Nominatim upstream is injected
++ * through opts.housingFetchImpl exactly as in test/housing.test.js.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const hpi = require('../server/housing/hpi');
++
++const HEADER = 'hpi_type,hpi_flavor,frequency,level,place_name,place_id,yr,period,index_nsa,index_sa,rstderr,note';
++const row = (a) => a.join(',');
++const streamOf = (text) => (async function* () { yield text; })();
++
++const fullCsv = [
++ HEADER,
++ // Colorado: the state fallback. Q2 2021 / Q2 2025 / Q2 2026 give a clean year and five-year change.
++ row(['traditional', 'all-transactions', 'quarterly', 'State', 'Colorado', 'CO', '2021', '2', '100.0', '100.0', '0.0', '']),
++ row(['traditional', 'all-transactions', 'quarterly', 'State', 'Colorado', 'CO', '2025', '2', '110.0', '110.0', '0.0', '']),
++ row(['traditional', 'all-transactions', 'quarterly', 'State', 'Colorado', 'CO', '2026', '1', '118.0', '118.0', '0.0', '']),
++ row(['traditional', 'all-transactions', 'quarterly', 'State', 'Colorado', 'CO', '2026', '2', '118.8', '118.8', '0.0', '']),
++ // Older than the six-year window: must be dropped even though it otherwise matches.
++ row(['traditional', 'all-transactions', 'quarterly', 'State', 'Colorado', 'CO', '2015', '2', '90.0', '90.0', '0.0', '']),
++ // Denver MSA, with a quoted place_name that contains a comma.
++ row(['traditional', 'all-transactions', 'quarterly', 'MSA', '"Denver-Aurora-Centennial, CO"', '19740', '2021', '2', '100.0', '100.0', '0.0', '']),
++ row(['traditional', 'all-transactions', 'quarterly', 'MSA', '"Denver-Aurora-Centennial, CO"', '19740', '2025', '2', '120.0', '120.0', '0.0', '']),
++ row(['traditional', 'all-transactions', 'quarterly', 'MSA', '"Denver-Aurora-Centennial, CO"', '19740', '2026', '2', '132.0', '132.0', '0.0', '']),
++ // A multi-state MSA: the state list must parse to DC,VA,MD,WV.
++ row(['traditional', 'all-transactions', 'quarterly', 'MSA', '"Washington-Arlington-Alexandria, DC-VA-MD-WV"', '47900', '2026', '2', '150.0', '150.0', '0.0', '']),
++ // Another state that is kept but never matched for Colorado.
++ row(['traditional', 'all-transactions', 'quarterly', 'State', 'Texas', 'TX', '2026', '2', '105.0', '105.0', '0.0', '']),
++ // Rows that must be ignored: wrong flavor, wrong frequency, wrong levels.
++ row(['traditional', 'purchase-only', 'quarterly', 'State', 'Colorado', 'CO', '2026', '2', '200.0', '200.0', '0.0', '']),
++ row(['traditional', 'all-transactions', 'monthly', 'State', 'Colorado', 'CO', '2026', '6', '119.0', '119.0', '0.0', '']),
++ row(['traditional', 'all-transactions', 'quarterly', 'USA', 'United States', 'USA', '2026', '2', '120.0', '120.0', '0.0', '']),
++ row(['traditional', 'all-transactions', 'quarterly', 'Census Division', 'Mountain', 'MTN', '2026', '2', '121.0', '121.0', '0.0', '']),
++].join('\n');
++
++const replacementCsv = [
++ HEADER,
++ row(['traditional', 'all-transactions', 'quarterly', 'State', 'Texas', 'TX', '2026', '2', '999.0', '999.0', '0.0', '']),
++].join('\n');
++
++const jsonResponse = (obj, status = 200) => new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } });
++
++const denverPlace = [{
++ place_id: 1, lat: '39.7392', lon: '-104.9903', display_name: 'Denver, Colorado, United States',
++ boundingbox: ['39.61', '39.86', '-105.11', '-104.88'],
++ address: { country: 'United States', country_code: 'us', state: 'Colorado', state_code: 'CO', city: 'Denver' },
++}];
++const telluridePlace = [{
++ place_id: 2, lat: '37.9375', lon: '-107.8123', display_name: 'Telluride, Colorado, United States',
++ boundingbox: ['37.92', '37.95', '-107.84', '-107.78'],
++ address: { country: 'United States', country_code: 'us', state: 'Colorado', state_code: 'CO', city: 'Telluride' },
++}];
++
++const mockFetch = async (url) => {
++ const u = new URL(String(url));
++ if (u.pathname.endsWith('/search')) {
++ const q = u.searchParams.get('q') || '';
++ if (/denver/i.test(q)) return jsonResponse(denverPlace);
++ if (/telluride/i.test(q)) return jsonResponse(telluridePlace);
++ return jsonResponse([]);
++ }
++ if (u.pathname.includes('/query')) return jsonResponse({ features: [] });
++ return jsonResponse([]);
++};
++
++(async () => {
++ // ── Unit: percentages and the missing-quarter rule ────────
++ await check('changes: the year and five-year percentages, rounded to 0.1%', () => {
++ const c = hpi.changes([
++ { yr: 2021, quarter: 2, index_nsa: 100 },
++ { yr: 2025, quarter: 2, index_nsa: 110 },
++ { yr: 2026, quarter: 2, index_nsa: 118.8 },
++ ]);
++ assert.strictEqual(c.year_change_pct, 8.0);
++ assert.strictEqual(c.five_year_change_pct, 18.8);
++ assert.strictEqual(c.latest, 'Q2 2026');
++ });
++
++ await check('changes: a missing year-ago quarter is null, never invented', () => {
++ const c = hpi.changes([
++ { yr: 2021, quarter: 2, index_nsa: 100 },
++ { yr: 2026, quarter: 2, index_nsa: 130 },
++ ]);
++ assert.strictEqual(c.year_change_pct, null);
++ assert.strictEqual(c.five_year_change_pct, 30.0);
++ assert.strictEqual(c.latest, 'Q2 2026');
++ });
++
++ await check('csv records keep a quoted comma inside place_name', async () => {
++ const rows = [];
++ for await (const r of hpi.csvRecords(streamOf(`${HEADER}\n${row(['traditional', 'all-transactions', 'quarterly', 'MSA', '"Denver-Aurora-Centennial, CO"', '19740', '2026', '2', '132.0', '132.0', '0.0', ''])}\n`))) rows.push(r);
++ assert.strictEqual(rows[1][4], 'Denver-Aurora-Centennial, CO');
++ });
++
++ // ── Integration: boot with HPI on and a stubbed upstream ──
++ const t = await boot({ housingFetchImpl: mockFetch, env: { RENT_HPI: 'true' } });
++ try {
++ await check('no home-price card or API field before any data is ingested', async () => {
++ const api = await t.get('/api/v1/housing?q=Denver');
++ assert.strictEqual(api.status, 200);
++ assert.strictEqual(api.json().area_prices, null);
++
++ const page = await t.get('/housing?q=Denver');
++ assert.strictEqual(page.status, 200);
++ assert.ok(!page.text.includes('Home prices around'));
++ });
++
++ await check('the ingest keeps only the matching rows from the last six years', async () => {
++ const out = await hpi.ingestFromStream(t.ctx.s, streamOf(fullCsv), { fetchedAt: '2026-10-12T00:00:00.000Z' });
++ assert.strictEqual(out.scanned, 14);
++ assert.strictEqual(out.kept, 9);
++
++ const rows = await t.ctx.s.db.many('SELECT level, place_id, place_name, states, yr, quarter, index_nsa FROM area_price_index ORDER BY level, place_id, yr, quarter');
++ assert.strictEqual(rows.length, 9);
++ assert.ok(rows.every((r) => r.level === 'state' || r.level === 'msa'));
++ assert.ok(!rows.some((r) => r.level === 'USA' || r.level === 'Census Division'));
++ assert.strictEqual(rows.filter((r) => r.place_id === 'CO').length, 4, 'Colorado keeps its four in-window quarters');
++ assert.ok(!rows.some((r) => r.place_id === 'CO' && r.yr === 2015), 'the six-year window drops 2015');
++ assert.strictEqual(rows.filter((r) => r.place_id === '19740').length, 3, 'Denver MSA keeps its three quarters');
++ const dc = rows.find((r) => r.place_id === '47900');
++ assert.strictEqual(dc.states, 'DC,VA,MD,WV');
++ assert.ok(rows.some((r) => r.place_id === 'TX'));
++ });
++
++ await check('a later ingest replaces the old rows whole', async () => {
++ const out = await hpi.ingestFromStream(t.ctx.s, streamOf(replacementCsv), { fetchedAt: '2026-10-12T01:00:00.000Z' });
++ assert.strictEqual(out.kept, 1);
++ const rows = await t.ctx.s.db.many('SELECT place_id, yr, quarter, index_nsa FROM area_price_index');
++ assert.strictEqual(rows.length, 1);
++ assert.strictEqual(rows[0].place_id, 'TX');
++ assert.strictEqual(Number(rows[0].index_nsa), 999.0);
++ });
++
++ await check('a refresh with no usable rows keeps the old rows', async () => {
++ const before = (await t.ctx.s.db.many('SELECT place_id FROM area_price_index ORDER BY place_id')).map((r) => r.place_id);
++ await assert.rejects(
++ () => hpi.ingestFromStream(t.ctx.s, streamOf(`${HEADER}\n${row(['traditional', 'purchase-only', 'quarterly', 'State', 'Colorado', 'CO', '2026', '2', '200.0', '200.0', '0.0', ''])}\n`), { fetchedAt: '2026-10-12T03:00:00.000Z' }),
++ /no usable rows/);
++ const after = (await t.ctx.s.db.many('SELECT place_id FROM area_price_index ORDER BY place_id')).map((r) => r.place_id);
++ assert.deepStrictEqual(after, before);
++ });
++
++ await hpi.ingestFromStream(t.ctx.s, streamOf(fullCsv), { fetchedAt: '2026-10-12T02:00:00.000Z' });
++
++ await check('the MSA wins for a city that is in its name list', async () => {
++ const api = await t.get('/api/v1/housing?q=Denver');
++ assert.strictEqual(api.status, 200);
++ const p = api.json().area_prices;
++ assert.strictEqual(p.place, 'Denver-Aurora-Centennial, CO');
++ assert.strictEqual(p.level, 'msa');
++ assert.strictEqual(p.year_change_pct, 10.0);
++ assert.strictEqual(p.five_year_change_pct, 32.0);
++ assert.strictEqual(p.latest, 'Q2 2026');
++ assert.deepStrictEqual(p.source, { name: 'FHFA House Price Index', url: 'https://www.fhfa.gov/data/hpi' });
++ });
++
++ await check('a small town falls back to its state', async () => {
++ const api = await t.get('/api/v1/housing?q=Telluride');
++ assert.strictEqual(api.status, 200);
++ const p = api.json().area_prices;
++ assert.strictEqual(p.place, 'Colorado');
++ assert.strictEqual(p.level, 'state');
++ assert.strictEqual(p.year_change_pct, 8.0);
++ assert.strictEqual(p.five_year_change_pct, 18.8);
++ assert.strictEqual(p.latest, 'Q2 2026');
++ });
++
++ await check('the page shows the home-price card above the results, with the repeat-sales note and source', async () => {
++ const r = await t.get('/housing?q=Denver');
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.ok(r.text.includes('Home prices around Denver-Aurora-Centennial, CO'));
++ assert.match(r.text, /\+10\.0 %<\/span><span class="hpi-label">over the past year/);
++ assert.match(r.text, /\+32\.0 %<\/span><span class="hpi-label">over five years/);
++ assert.ok(r.text.includes('FHFA all-transactions index, Q2 2026.'));
++ assert.ok(r.text.includes('This is an index of repeat sales, not a price.'));
++ assert.ok(r.text.includes('Source: FHFA House Price Index'));
++ assert.ok(r.text.includes('https://www.fhfa.gov/data/hpi'));
++ });
++
++ await check('the state fallback card names the state', async () => {
++ const r = await t.get('/housing?q=Telluride');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('Home prices around Colorado'));
++ assert.match(r.text, /\+8\.0 %<\/span><span class="hpi-label">over the past year/);
++ assert.match(r.text, /\+18\.8 %<\/span><span class="hpi-label">over five years/);
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });