openvibers/openvibe.rent Commits
Rent: HUD homes for sale near you (HUD's SF_REO layer, checked against HUD Homestore) (#29)
Changes
@@@ -85,7 +85,7 @@ namespace is `rent.*`.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 five housing layers) — paced and cached in++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).## Capabilities@@@ -108,7 +108,7 @@ Nominatim (a place name → coordinates) and HUD's ArcGIS FeatureServer (the fiv| Reports | [server/listings/store.js](server/listings/store.js), `/staff` | Anyone signed in reports a listing once (reason + note ≤500). **3 distinct reports hide it** from search until staff look; staff restore, hide or remove it, and a listing staff restored is not hidden again by the same reports. || 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 five HUD ArcGIS layers — public housing, LIHTC, HUD-assisted multifamily, public housing authorities and housing counselling agencies — normalised, distance-sorted and grouped, with the HUD attribution on the page. |++| 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. || 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. |@@@ -215,7 +215,7 @@ through PostgreSQL and PgBouncer (see [.github/workflows/ci.yml](.github/workflo- [test/listings-pages.test.js](test/listings-pages.test.js) — the server-rendered pages and plain form POSTs, thesafety note word for word, saved-search new counts, and a refused form re-rendering what was typed, escaped.- [test/housing.test.js](test/housing.test.js) — "Affordable housing near you", with the upstream fetch injected:-- geocoding and bounding-box clipping, the ArcGIS query URL, normalisation of all five HUD layers (null for a missing++ geocoding and bounding-box clipping, the ArcGIS query URL, normalisation of all six HUD layers (null for a missingvalue), distance sort, the cache (a second request does not fetch), the upstream-failure and non-US states, the pagesigned out with attribution, and the API.- [test/account-data.test.js](test/account-data.test.js) — ADR-033 export and deletion through `/internal/events`@@@ -27,13 +27,14 @@ const SOURCE = {fetched_note: 'fetched by OpenVibe.Rent and cached for 7 days',};--const KINDS = ['public_housing', 'lihtc', 'multifamily', 'housing_authority', 'counseling'];++const KINDS = ['public_housing', 'lihtc', 'multifamily', 'housing_authority', 'counseling', 'hud_home'];const KIND_TEXT = {public_housing: 'Public housing',lihtc: 'Tax-credit (LIHTC) properties',multifamily: 'HUD-assisted multifamily',housing_authority: 'Public housing authorities',counseling: 'Housing counselling agencies',++ hud_home: 'HUD homes for sale',};const KIND_DESCRIPTIONS = {public_housing: 'Public housing: homes owned by a local housing authority, rent based on income.',@@@ -41,6 +42,7 @@ const KIND_DESCRIPTIONS = {multifamily: 'HUD-assisted multifamily: privately owned apartments where HUD helps keep the rent affordable.',housing_authority: 'Public housing authorities: the local agency to apply to for public housing and housing vouchers.',counseling: 'Housing counselling agencies: HUD-approved local help with renting, buying and keeping a home.',++ hud_home: 'HUD homes for sale: houses HUD took back after an FHA loan, sold through HUD Homestore. From HUD\'s published snapshot (March 2026), so a home may have sold since: confirm it on HUD Homestore before you plan around it.',};// Counts, rents and coordinates-free numbers: HUD marks a missing or suppressed value with a negative sentinel (-1, -4,@@@ -192,6 +194,33 @@ const LAYERS = [extra: { website: safeUrl(a.AGC_WEB_SITE), services: str(a.COUNSEL_SRV_DESC), languages: str(a.LANGUAGE_DESC) },};},++ }, {++ kind: 'hud_home',++ service: 'SF_REO',++ layer: 0,++ fields: ['OBJECTID', 'CASE_NUM', 'STREET_NUM', 'DIRECTION_PREFIX', 'STREET_NAME', 'ADDRESS', 'CITY', 'STATE_CODE', 'DISPLAY_ZIP_CODE', 'DATE_ACQUIRED', 'DATE_CLOSED', 'MAP_LATITUDE', 'MAP_LONGITUDE'],++ normalize(a) {++ // The street fields are padded; ADDRESS already joins number, prefix and street when it is set.++ const street = str(a.ADDRESS) || [str(a.STREET_NUM), str(a.DIRECTION_PREFIX), str(a.STREET_NAME)].filter(Boolean).join(' ') || null;++ const city = str(a.CITY);++ const state = str(a.STATE_CODE);++ return {++ id: idOf('hud-reo', a.CASE_NUM, a.OBJECTID),++ name: street,++ address: street,++ city,++ state,++ zip: zip5(a.DISPLAY_ZIP_CODE),++ phone: null,++ units: { total: null, low_income: null },++ extra: {++ case_number: str(a.CASE_NUM),++ acquired: epochIso(a.DATE_ACQUIRED),++ // HUD Homestore has no per-case link HUD publishes; its city search finds the home by its case number.++ homestore_url: city && state ? `https://www.hudhomestore.gov/searchresult?citystate=${encodeURIComponent(`${city}, ${state}`)}` : 'https://www.hudhomestore.gov/',++ },++ };++ },},];@@@ -236,11 +236,14 @@ ${housingPlaces.KINDS.map((k) => html`<label class="housing-kind"><input type="cif (x.expiration_date) out.push(html`Assistance contract runs to ${x.expiration_date}`);if (x.services) out.push(html`${x.services}`);if (x.languages) out.push(html`Languages: ${x.languages}`);++ if (x.case_number) out.push(html`HUD case ${x.case_number}`);++ if (x.acquired) out.push(html`Taken back by HUD in ${new Date(x.acquired).toLocaleDateString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' })}`);return out;};function housingRow(r) {-- const where = [titleCase(r.address), titleCase(r.city), r.state, r.zip].filter(Boolean).join(', ');++ // A HUD home is named by its street, so its address line starts at the city.++ const where = [r.kind === 'hud_home' ? null : titleCase(r.address), titleCase(r.city), r.state, r.zip].filter(Boolean).join(', ');const tel = r.phone ? String(r.phone).replace(/[^\d+]/g, '') : null;const details = housingDetails(r);const units = housingUnits(r);@@@ -250,7 +253,7 @@ ${housingPlaces.KINDS.map((k) => html`<label class="housing-kind"><input type="c${where ? html`<p class="housing-addr">${where}</p>` : ''}${units ? html`<p class="housing-units">${units}</p>` : ''}${details.length ? html`<ul class="housing-facts">${details.map((d) => html`<li>${d}</li>`)}</ul>` : ''}--<p class="housing-actions">${r.phone ? html`<a href="tel:${tel}">${phoneText(r.phone)}</a>` : ''}${r.extra && r.extra.email ? html`<a href="mailto:${r.extra.email}">Email</a>` : ''}${r.extra && r.extra.website ? html`<a href="${r.extra.website}" rel="nofollow noopener external">Website</a>` : ''}${map ? html`<a href="${map}" rel="noopener">Map</a>` : ''}</p>++<p class="housing-actions">${r.phone ? html`<a href="tel:${tel}">${phoneText(r.phone)}</a>` : ''}${r.extra && r.extra.email ? html`<a href="mailto:${r.extra.email}">Email</a>` : ''}${r.extra && r.extra.website ? html`<a href="${r.extra.website}" rel="nofollow noopener external">Website</a>` : ''}${r.extra && r.extra.homestore_url ? html`<a href="${r.extra.homestore_url}" rel="noopener external">Check it on HUD Homestore</a>` : ''}${map ? html`<a href="${map}" rel="noopener">Map</a>` : ''}</p></li>`;}@@@ -1,6 +1,6 @@'use strict';/**-- * "Affordable housing near you" — the two upstream services (Nominatim + HUD ArcGIS), the five layers, and the++ * "Affordable housing near you" — the two upstream services (Nominatim + HUD ArcGIS), the six layers, and the* page and API. The upstream fetch is injected through opts.housingFetchImpl (the sandbox has no network or loopback* to reach Nominatim/HUD), and the DB cache is the real migrated housing_geo_cache table.*/@@@ -147,6 +147,20 @@ const mockFetch = async (url) => {assert.strictEqual(r.extra.website, 'http://www.ifhmb.com/');});++ await check('normalises HUD homes for sale (SF_REO): padded street fields, ZIP, case number, HUD Homestore link', () => {++ const r = places.normalizeFeature({ attributes: { OBJECTID: 9, CASE_NUM: '011-662347', STREET_NUM: '2220 ', DIRECTION_PREFIX: null, STREET_NAME: 'E TUSCALOOSA A ', ADDRESS: '2220 E TUSCALOOSA A ', CITY: 'GADSDEN', STATE_CODE: 'AL', DISPLAY_ZIP_CODE: 5904, DATE_ACQUIRED: 1713225600000, DATE_CLOSED: null }, geometry: { x: -86.03811, y: 34.028436 } }, def('hud_home'));++ assert.strictEqual(r.kind, 'hud_home');++ assert.strictEqual(r.id, 'hud-reo-011-662347');++ assert.strictEqual(r.name, '2220 E TUSCALOOSA A');++ assert.strictEqual(r.zip, '05904', 'a numeric ZIP keeps its leading zero');++ assert.strictEqual(r.extra.case_number, '011-662347');++ assert.strictEqual(r.extra.acquired, '2024-04-16T00:00:00.000Z');++ assert.strictEqual(r.extra.homestore_url, 'https://www.hudhomestore.gov/searchresult?citystate=GADSDEN%2C%20AL');++ assert.deepStrictEqual([r.lat, r.lon], [34.028436, -86.03811]);++ assert.ok(places.KINDS.includes('hud_home'));++ assert.match(places.KIND_DESCRIPTIONS.hud_home, /snapshot \(March 2026\)/);++ });++await check('a missing value stays null, never invented', () => {const r = places.normalizeFeature({ attributes: { OBJECTID: 1, PROJECT_NAME: 'X', TOTAL_UNITS: 12 }, geometry: { x: -77, y: 38 } }, def('public_housing'));assert.strictEqual(r.address, null);
@@ -85,7 +85,7 @@ namespace is `rent.*`.
@@ -85,7 +85,7 @@ namespace is `rent.*`.
For listings it fetches from no source and no third party: the only outbound calls are the Network and Events above.
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 —
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 five housing layers) — paced and cached in
+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).
[server/housing/upstream.js](server/housing/upstream.js).
## Capabilities
## Capabilities
@@ -108,7 +108,7 @@ Nominatim (a place name → coordinates) and HUD's ArcGIS FeatureServer (the fiv
@@ -108,7 +108,7 @@ Nominatim (a place name → coordinates) and HUD's ArcGIS FeatureServer (the fiv
| Reports | [server/listings/store.js](server/listings/store.js), `/staff` | Anyone signed in reports a listing once (reason + note ≤500). **3 distinct reports hide it** from search until staff look; staff restore, hide or remove it, and a listing staff restored is not hidden again by the same reports. |
| Reports | [server/listings/store.js](server/listings/store.js), `/staff` | Anyone signed in reports a listing once (reason + note ≤500). **3 distinct reports hide it** from search until staff look; staff restore, hide or remove it, and a listing staff restored is not hidden again by the same reports. |
| 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. |
| 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. |
| 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 five HUD ArcGIS layers — public housing, LIHTC, HUD-assisted multifamily, public housing authorities and housing counselling agencies — normalised, distance-sorted and grouped, with the HUD attribution on the page. |
+| 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. |
| 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. |
| 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`. |
| 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. |
| 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. |
@@ -215,7 +215,7 @@ through PostgreSQL and PgBouncer (see [.github/workflows/ci.yml](.github/workflo
@@ -215,7 +215,7 @@ through PostgreSQL and PgBouncer (see [.github/workflows/ci.yml](.github/workflo
- [test/listings-pages.test.js](test/listings-pages.test.js) — the server-rendered pages and plain form POSTs, the
- [test/listings-pages.test.js](test/listings-pages.test.js) — the server-rendered pages and plain form POSTs, the
safety note word for word, saved-search new counts, and a refused form re-rendering what was typed, escaped.
safety note word for word, saved-search new counts, and a refused form re-rendering what was typed, escaped.
- [test/housing.test.js](test/housing.test.js) — "Affordable housing near you", with the upstream fetch injected:
- [test/housing.test.js](test/housing.test.js) — "Affordable housing near you", with the upstream fetch injected:
- geocoding and bounding-box clipping, the ArcGIS query URL, normalisation of all five HUD layers (null for a missing
+ 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
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.
signed out with attribution, and the API.
- [test/account-data.test.js](test/account-data.test.js) — ADR-033 export and deletion through `/internal/events`
- [test/account-data.test.js](test/account-data.test.js) — ADR-033 export and deletion through `/internal/events`
@@ -27,13 +27,14 @@ const SOURCE = {
@@ -27,13 +27,14 @@ const SOURCE = {
fetched_note: 'fetched by OpenVibe.Rent and cached for 7 days',
fetched_note: 'fetched by OpenVibe.Rent and cached for 7 days',
};
};
-const KINDS = ['public_housing', 'lihtc', 'multifamily', 'housing_authority', 'counseling'];
+const KINDS = ['public_housing', 'lihtc', 'multifamily', 'housing_authority', 'counseling', 'hud_home'];
const KIND_TEXT = {
const KIND_TEXT = {
public_housing: 'Public housing',
public_housing: 'Public housing',
lihtc: 'Tax-credit (LIHTC) properties',
lihtc: 'Tax-credit (LIHTC) properties',
multifamily: 'HUD-assisted multifamily',
multifamily: 'HUD-assisted multifamily',
housing_authority: 'Public housing authorities',
housing_authority: 'Public housing authorities',
counseling: 'Housing counselling agencies',
counseling: 'Housing counselling agencies',
+ hud_home: 'HUD homes for sale',
};
};
const KIND_DESCRIPTIONS = {
const KIND_DESCRIPTIONS = {
public_housing: 'Public housing: homes owned by a local housing authority, rent based on income.',
public_housing: 'Public housing: homes owned by a local housing authority, rent based on income.',
@@ -41,6 +42,7 @@ const KIND_DESCRIPTIONS = {
@@ -41,6 +42,7 @@ const KIND_DESCRIPTIONS = {
multifamily: 'HUD-assisted multifamily: privately owned apartments where HUD helps keep the rent affordable.',
multifamily: 'HUD-assisted multifamily: privately owned apartments where HUD helps keep the rent affordable.',
housing_authority: 'Public housing authorities: the local agency to apply to for public housing and housing vouchers.',
housing_authority: 'Public housing authorities: the local agency to apply to for public housing and housing vouchers.',
counseling: 'Housing counselling agencies: HUD-approved local help with renting, buying and keeping a home.',
counseling: 'Housing counselling agencies: HUD-approved local help with renting, buying and keeping a home.',
+ hud_home: 'HUD homes for sale: houses HUD took back after an FHA loan, sold through HUD Homestore. From HUD\'s published snapshot (March 2026), so a home may have sold since: confirm it on HUD Homestore before you plan around it.',
};
};
// Counts, rents and coordinates-free numbers: HUD marks a missing or suppressed value with a negative sentinel (-1, -4,
// Counts, rents and coordinates-free numbers: HUD marks a missing or suppressed value with a negative sentinel (-1, -4,
@@ -192,6 +194,33 @@ const LAYERS = [
@@ -192,6 +194,33 @@ const LAYERS = [
extra: { website: safeUrl(a.AGC_WEB_SITE), services: str(a.COUNSEL_SRV_DESC), languages: str(a.LANGUAGE_DESC) },
extra: { website: safeUrl(a.AGC_WEB_SITE), services: str(a.COUNSEL_SRV_DESC), languages: str(a.LANGUAGE_DESC) },
};
};
},
},
+ }, {
+ kind: 'hud_home',
+ service: 'SF_REO',
+ layer: 0,
+ fields: ['OBJECTID', 'CASE_NUM', 'STREET_NUM', 'DIRECTION_PREFIX', 'STREET_NAME', 'ADDRESS', 'CITY', 'STATE_CODE', 'DISPLAY_ZIP_CODE', 'DATE_ACQUIRED', 'DATE_CLOSED', 'MAP_LATITUDE', 'MAP_LONGITUDE'],
+ normalize(a) {
+ // The street fields are padded; ADDRESS already joins number, prefix and street when it is set.
+ const street = str(a.ADDRESS) || [str(a.STREET_NUM), str(a.DIRECTION_PREFIX), str(a.STREET_NAME)].filter(Boolean).join(' ') || null;
+ const city = str(a.CITY);
+ const state = str(a.STATE_CODE);
+ return {
+ id: idOf('hud-reo', a.CASE_NUM, a.OBJECTID),
+ name: street,
+ address: street,
+ city,
+ state,
+ zip: zip5(a.DISPLAY_ZIP_CODE),
+ phone: null,
+ units: { total: null, low_income: null },
+ extra: {
+ case_number: str(a.CASE_NUM),
+ acquired: epochIso(a.DATE_ACQUIRED),
+ // HUD Homestore has no per-case link HUD publishes; its city search finds the home by its case number.
+ homestore_url: city && state ? `https://www.hudhomestore.gov/searchresult?citystate=${encodeURIComponent(`${city}, ${state}`)}` : 'https://www.hudhomestore.gov/',
+ },
+ };
+ },
},
},
];
];
@@ -236,11 +236,14 @@ ${housingPlaces.KINDS.map((k) => html`<label class="housing-kind"><input type="c
@@ -236,11 +236,14 @@ ${housingPlaces.KINDS.map((k) => html`<label class="housing-kind"><input type="c
if (x.expiration_date) out.push(html`Assistance contract runs to ${x.expiration_date}`);
if (x.expiration_date) out.push(html`Assistance contract runs to ${x.expiration_date}`);
if (x.services) out.push(html`${x.services}`);
if (x.services) out.push(html`${x.services}`);
if (x.languages) out.push(html`Languages: ${x.languages}`);
if (x.languages) out.push(html`Languages: ${x.languages}`);
+ if (x.case_number) out.push(html`HUD case ${x.case_number}`);
+ if (x.acquired) out.push(html`Taken back by HUD in ${new Date(x.acquired).toLocaleDateString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' })}`);
return out;
return out;
};
};
function housingRow(r) {
function housingRow(r) {
- const where = [titleCase(r.address), titleCase(r.city), r.state, r.zip].filter(Boolean).join(', ');
+ // A HUD home is named by its street, so its address line starts at the city.
+ const where = [r.kind === 'hud_home' ? null : titleCase(r.address), titleCase(r.city), r.state, r.zip].filter(Boolean).join(', ');
const tel = r.phone ? String(r.phone).replace(/[^\d+]/g, '') : null;
const tel = r.phone ? String(r.phone).replace(/[^\d+]/g, '') : null;
const details = housingDetails(r);
const details = housingDetails(r);
const units = housingUnits(r);
const units = housingUnits(r);
@@ -250,7 +253,7 @@ ${housingPlaces.KINDS.map((k) => html`<label class="housing-kind"><input type="c
@@ -250,7 +253,7 @@ ${housingPlaces.KINDS.map((k) => html`<label class="housing-kind"><input type="c
${where ? html`<p class="housing-addr">${where}</p>` : ''}
${where ? html`<p class="housing-addr">${where}</p>` : ''}
${units ? html`<p class="housing-units">${units}</p>` : ''}
${units ? html`<p class="housing-units">${units}</p>` : ''}
${details.length ? html`<ul class="housing-facts">${details.map((d) => html`<li>${d}</li>`)}</ul>` : ''}
${details.length ? html`<ul class="housing-facts">${details.map((d) => html`<li>${d}</li>`)}</ul>` : ''}
-<p class="housing-actions">${r.phone ? html`<a href="tel:${tel}">${phoneText(r.phone)}</a>` : ''}${r.extra && r.extra.email ? html`<a href="mailto:${r.extra.email}">Email</a>` : ''}${r.extra && r.extra.website ? html`<a href="${r.extra.website}" rel="nofollow noopener external">Website</a>` : ''}${map ? html`<a href="${map}" rel="noopener">Map</a>` : ''}</p>
+<p class="housing-actions">${r.phone ? html`<a href="tel:${tel}">${phoneText(r.phone)}</a>` : ''}${r.extra && r.extra.email ? html`<a href="mailto:${r.extra.email}">Email</a>` : ''}${r.extra && r.extra.website ? html`<a href="${r.extra.website}" rel="nofollow noopener external">Website</a>` : ''}${r.extra && r.extra.homestore_url ? html`<a href="${r.extra.homestore_url}" rel="noopener external">Check it on HUD Homestore</a>` : ''}${map ? html`<a href="${map}" rel="noopener">Map</a>` : ''}</p>
</li>`;
</li>`;
}
}
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
/**
/**
- * "Affordable housing near you" — the two upstream services (Nominatim + HUD ArcGIS), the five layers, and the
+ * "Affordable housing near you" — the two upstream services (Nominatim + HUD ArcGIS), the six layers, and the
* page and API. The upstream fetch is injected through opts.housingFetchImpl (the sandbox has no network or loopback
* page and API. The upstream fetch is injected through opts.housingFetchImpl (the sandbox has no network or loopback
* to reach Nominatim/HUD), and the DB cache is the real migrated housing_geo_cache table.
* to reach Nominatim/HUD), and the DB cache is the real migrated housing_geo_cache table.
*/
*/
@@ -147,6 +147,20 @@ const mockFetch = async (url) => {
@@ -147,6 +147,20 @@ const mockFetch = async (url) => {
assert.strictEqual(r.extra.website, 'http://www.ifhmb.com/');
assert.strictEqual(r.extra.website, 'http://www.ifhmb.com/');
});
});
+ await check('normalises HUD homes for sale (SF_REO): padded street fields, ZIP, case number, HUD Homestore link', () => {
+ const r = places.normalizeFeature({ attributes: { OBJECTID: 9, CASE_NUM: '011-662347', STREET_NUM: '2220 ', DIRECTION_PREFIX: null, STREET_NAME: 'E TUSCALOOSA A ', ADDRESS: '2220 E TUSCALOOSA A ', CITY: 'GADSDEN', STATE_CODE: 'AL', DISPLAY_ZIP_CODE: 5904, DATE_ACQUIRED: 1713225600000, DATE_CLOSED: null }, geometry: { x: -86.03811, y: 34.028436 } }, def('hud_home'));
+ assert.strictEqual(r.kind, 'hud_home');
+ assert.strictEqual(r.id, 'hud-reo-011-662347');
+ assert.strictEqual(r.name, '2220 E TUSCALOOSA A');
+ assert.strictEqual(r.zip, '05904', 'a numeric ZIP keeps its leading zero');
+ assert.strictEqual(r.extra.case_number, '011-662347');
+ assert.strictEqual(r.extra.acquired, '2024-04-16T00:00:00.000Z');
+ assert.strictEqual(r.extra.homestore_url, 'https://www.hudhomestore.gov/searchresult?citystate=GADSDEN%2C%20AL');
+ assert.deepStrictEqual([r.lat, r.lon], [34.028436, -86.03811]);
+ assert.ok(places.KINDS.includes('hud_home'));
+ assert.match(places.KIND_DESCRIPTIONS.hud_home, /snapshot \(March 2026\)/);
+ });
+
await check('a missing value stays null, never invented', () => {
await check('a missing value stays null, never invented', () => {
const r = places.normalizeFeature({ attributes: { OBJECTID: 1, PROJECT_NAME: 'X', TOTAL_UNITS: 12 }, geometry: { x: -77, y: 38 } }, def('public_housing'));
const r = places.normalizeFeature({ attributes: { OBJECTID: 1, PROJECT_NAME: 'X', TOTAL_UNITS: 12 }, geometry: { x: -77, y: 38 } }, def('public_housing'));
assert.strictEqual(r.address, null);
assert.strictEqual(r.address, null);