openvibers/openvibe.rent Commits

Rent and Homes homes show real data: the largest cities' rents and home values, the cheapest and dearest big cities, every state, where home prices moved (FHFA, every metro), the sites searched by kind, HUD links and the products' questions (#81)

OP OpenVibers committed 22 days ago · 2afb8ece9c · 10 files, +315 −57

Changes

ddiff --git a/public/css/home.css b/public/css/home.css
nnew file mode 100644
iindex 0000000..fadbfa0
---- /dev/null
++++ b/public/css/home.css
@@@ -0,0 +1,54 @@
++/* OpenVibe.Rent's and OpenVibe.Homes' home pages (server/render/home-blocks.js): counts, kinds, city cards, ranked lists,
++ state chips and questions. Theme tokens only, so light and dark both read; every grid fits a 320px phone. */
++.home-stats { list-style: none; padding: 0; margin: 32px 0 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(min(130px, 100%), 1fr)); }
++.home-stats li > a, .home-stats li > b { display: block; }
++.home-stats li { border-radius: 14px; border: 1px solid var(--border, #1f2d47); border-top: 3px solid var(--accent, #3b82f6); background: var(--bg-secondary, #0f1729); }
++.home-stats a { padding: 14px 16px; color: inherit; text-decoration: none; border-radius: 14px; height: 100%; box-sizing: border-box; }
++.home-stats li:not(:has(a)) { padding: 14px 16px; }
++.home-stats a:hover, .home-stats a:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }
++.home-stats b { display: block; font-size: 1.75rem; line-height: 1.1; font-weight: 800; font-variant-numeric: tabular-nums; color: var(--text-primary, #e6edf7); }
++.home-stats span { display: block; margin-top: 4px; font-size: .88rem; color: var(--text-secondary, #96a7c2); }
++
++.home-kinds, .home-cities { list-style: none; padding: 0; margin: 0 0 8px; display: grid; gap: 10px; }
++.home-kinds { grid-template-columns: repeat(auto-fill, minmax(min(230px, 100%), 1fr)); }
++.home-cities { grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr)); }
++.home-kinds a, .home-cities a { display: flex; flex-direction: column; gap: 4px; height: 100%; box-sizing: border-box; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-decoration: none; transition: border-color .15s ease, transform .15s ease; }
++.home-kinds a:hover, .home-cities a:hover, .home-kinds a:focus-visible, .home-cities a:focus-visible { border-color: var(--accent, #3b82f6); transform: translateY(-1px); outline: 0; }
++.home-kinds b { font-size: 1rem; }
++.home-kinds span { font-size: .86rem; color: var(--text-secondary, #96a7c2); overflow-wrap: anywhere; }
++.hc-name { font-weight: 700; font-size: 1.02rem; overflow-wrap: anywhere; }
++.hc-state { margin-left: 6px; font-size: .78rem; font-weight: 600; color: var(--text-muted, #7386a3); }
++.hc-main { font-size: .88rem; color: var(--text-secondary, #96a7c2); }
++.hc-main span { white-space: nowrap; }
++.hc-main b { font-size: 1.3rem; color: var(--text-primary, #e6edf7); font-variant-numeric: tabular-nums; }
++.hc-sub { font-size: .8rem; color: var(--text-muted, #7386a3); font-variant-numeric: tabular-nums; }
++
++.home-ranks { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); }
++.home-rank { padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
++.home-rank h3 { margin: 0 0 8px; font-size: 1rem; }
++.home-rank ol { margin: 0; padding-left: 22px; display: grid; gap: 6px; }
++.home-rank li { padding-left: 2px; }
++.home-rank li::marker { color: var(--text-muted, #7386a3); font-variant-numeric: tabular-nums; }
++.home-rank li > a, .home-rank li > span:first-child { overflow-wrap: anywhere; }
++.hr-val { float: right; margin-left: 10px; font-weight: 700; font-variant-numeric: tabular-nums; }
++
++.home-chips { list-style: none; padding: 0; margin: 0 0 12px; display: flex; flex-wrap: wrap; gap: 8px; }
++.home-chips a { display: inline-flex; align-items: baseline; gap: 6px; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: .92rem; }
++.home-chips a:hover, .home-chips a:focus-visible { border-color: var(--accent, #3b82f6); }
++.home-chips span { font-size: .78rem; color: var(--text-muted, #7386a3); }
++
++.home-sites { list-style: none; padding: 0; margin: 0 0 10px; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(230px, 100%), 1fr)); }
++.home-sites li { padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
++.home-sites b { display: block; font-size: 1.02rem; }
++.home-sites span { display: block; font-size: .8rem; color: var(--text-muted, #7386a3); overflow-wrap: anywhere; }
++.home-sites p { margin: 6px 0 0; font-size: .88rem; color: var(--text-secondary, #96a7c2); }
++
++.home-faq { display: grid; gap: 8px; max-width: 820px; }
++.home-faq details { border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
++.home-faq summary { cursor: pointer; padding: 12px 16px; font-weight: 650; list-style-position: inside; }
++.home-faq summary:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; border-radius: 12px; }
++.home-faq details p { margin: 0; padding: 0 16px 14px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }
++@media (prefers-reduced-motion: reduce) { .home-kinds a, .home-cities a { transition: none; } .home-kinds a:hover, .home-cities a:hover { transform: none; } }
++/* The hero's search box: what to look for on its own row (its options are long), price and bedrooms side by side. */
++.sc-hero-aside .field-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
++.sc-hero-aside .field-row .field:first-child { grid-column: 1 / -1; }
ddiff --git a/server/homes/pages.js b/server/homes/pages.js
iindex 14252ae..286a09c 100644
---- a/server/homes/pages.js
++++ b/server/homes/pages.js
@@@ -30,6 +30,8 @@ const housingHpi = require('../housing/hpi');
const cities = require('../aggregate/cities');
const { US_STATE_NAMES } = require('../aggregate/everywhere');
const { mountHomesCityPages, cityPaths } = require('./city-pages');
++const home = require('../render/home-blocks');
++const { products } = require('openvibe-contracts');
const STATE_TITLE = Object.fromEntries(Object.entries(US_STATE_NAMES).map(([code, s]) => [code, s.split('-').map((w) => (w === 'of' ? w : w[0].toUpperCase() + w.slice(1))).join(' ')]));
const money = (n) => `$${Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
@@@ -61,33 +63,87 @@ function createHomesRoutes(ctx) {
}));
// ── Home ─────────────────────────────────────────────────
-- r.get('/', (req, res) => page(req, res, {
-- index: true, cache: PUBLIC_CACHE,
-- // What the site is, for search engines (WebSite + WebPage, as the other OpenVibe homes say it).
-- jsonLd: [
-- seo.jsonLd.website({ name: 'OpenVibe.Homes', url: base, description: HOMES_SUMMARY }),
-- seo.jsonLd.webPage({ name: 'OpenVibe.Homes', url: `${base}/`, description: HOMES_SUMMARY, siteUrl: base }),
-- ],
-- description: 'Buying a home: one search opens Zillow, Realtor.com, Redfin, Homes.com, Trulia and Craigslist, with HUD and Fannie Mae homes, foreclosure auctions, county property records and a mortgage calculator.',
-- styles: [showcase.STYLESHEET], localStyles: ['css/everywhere.css', 'css/homes.css'],
-- body: html`${raw(showcase.hero({
-- eyebrow: 'OpenVibe.Homes', title: 'Buying a home?', accent: 'Search every site at once.',
-- lede: 'Type a place once and every big for-sale site searches it for you: Zillow, Realtor.com, Redfin, Homes.com, Trulia and Craigslist, next to HUD and Fannie Mae homes, foreclosure auctions and the county\'s property records. Keep the homes you like on one board, whichever site they came from.',
-- actions: [{ label: 'Search every site', href: '#search', primary: true }, { label: 'Mortgage calculator', href: '/mortgage' }, { label: 'Property records', href: '/records' }],
-- }))}
--<section class="sc-sec" id="search" aria-labelledby="h-search"><h2 id="h-search">Search</h2>${searchForm({ kind: 'home' })}</section>
++ // Real rows only: the for-sale sites the search opens, Census home values for the same cities as Rent, and the
++ // FHFA index's past year for every state and metro area. A block with nothing to show is left out.
++ const FAQ = ((products.get('openvibe.homes') || {}).faq || []);
++ const pctText = (n) => `${n > 0 ? '+' : n < 0 ? '−' : ''}${Math.abs(Math.round(n * 10) / 10).toFixed(1)}%`;
++ /** "Denver-Aurora-Centennial, CO" → the first city's page when it is one of ours, so a metro opens its city. */
++ const metroCity = (m) => {
++ const [names, st] = String(m.place_name || '').split(',');
++ const first = String(names || '').split('-')[0].trim();
++ const state = String(st || '').trim().split('-')[0];
++ return first && state ? cities.match(`${first}, ${state}`) : null;
++ };
++ const metroRow = (m) => { const c = metroCity(m); return { name: m.place_name, href: c ? c.path : null, value: pctText(m.year_change_pct) }; };
++ async function homePage(req, res) {
++ let moves = null;
++ try { moves = await housingHpi.areaMoves(s); } catch { moves = null; }
++ const largest = home.bigCities(0).slice(0, 12);
++ const big = home.bigCities(250000).filter((c) => c.value != null && c.value < 2000001);
++ const byValue = [...big].sort((a, b) => a.value - b.value);
++ const valueRow = (c) => ({ name: `${c.name}, ${c.state}`, href: c.path, value: cities.valueText(c.value) });
++ const withValue = cities.cities.filter((c) => c.value != null).length;
++ const stateRow = (st) => ({ name: st.place_name, href: `/in/${String(st.place_id).toLowerCase()}`, value: pctText(st.year_change_pct) });
++ const kinds = sites.KINDS.map((k) => ({ k, list: sites.SITES.filter((x) => x.kinds.includes(k)) })).filter((x) => x.list.length);
++ return page(req, res, {
++ index: true, cache: PUBLIC_CACHE,
++ title: 'Homes for sale on every site, one search',
++ jsonLd: [
++ seo.jsonLd.website({ name: 'OpenVibe.Homes', url: base, description: HOMES_SUMMARY }),
++ seo.jsonLd.webPage({ name: 'OpenVibe.Homes', url: `${base}/`, description: HOMES_SUMMARY, siteUrl: base }),
++ seo.jsonLd.itemList('Median home value in the largest US cities', largest.filter((c) => c.value != null).map((c) => ({ name: `${c.name}, ${c.state}: ${cities.valueText(c.value)}`, url: `${base}${c.path}` })), { url: `${base}/in` }),
++ FAQ.length ? seo.jsonLd.faq(FAQ.map((f) => ({ q: f.question, a: f.answer }))) : null,
++ ],
++ description: 'Search Zillow, Realtor.com, Redfin, Homes.com, Trulia and Craigslist at once, with HUD and bank homes, home values for 885 cities and county records.',
++ styles: [showcase.STYLESHEET], localStyles: ['css/home.css'],
++ body: html`${raw(showcase.hero({
++ eyebrow: 'OpenVibe.Homes', title: 'Buying a home?', accent: 'Search every site at once.',
++ lede: 'Type a place once and every big for-sale site searches it for you: Zillow, Realtor.com, Redfin, Homes.com, Trulia and Craigslist, next to HUD and Fannie Mae homes, foreclosure auctions and the county\'s property records. See what homes are worth in your city and what the payment would be.',
++ actions: [{ label: 'Search every site', href: '/search', primary: true }, { label: 'Homes by city', href: '/in' }, { label: 'Mortgage calculator', href: '/mortgage' }],
++ aside: { html: String(searchForm({ kind: 'home' })) },
++ }))}
++${home.stats([
++ { value: sites.SITES.length, label: 'for-sale, land and auction sites', href: '/search' },
++ { value: withValue, label: 'US cities with home values', href: '/in' },
++ { value: moves && moves.metros.length >= 12 ? moves.metros.length : null, label: 'metro areas with a price trend', href: '#h-moves' },
++ { value: Object.keys(US_STATE_NAMES).length, label: 'states with county records', href: '/records' },
++ ], { label: 'OpenVibe.Homes in numbers' })}
++
++${home.section('kinds', 'What are you buying?', 'Every site that sells it opens at your place, with your price and bedrooms where the site takes them.',
++ html`<ul class="home-kinds">${kinds.map(({ k, list }) => html`<li><a href="/search?kind=${k}"><b>${sites.KIND_TEXT[k]}</b><span>${list.map((x) => x.name).join(', ')}</span></a></li>`)}</ul>`)}
++
++${home.section('cities', 'What homes are worth in the largest cities', html`The median value of owner-occupied homes, with the typical 2-bedroom rent beside it (Census Bureau, ${cities.SOURCE.years}). Each city page has the price trend, the monthly payment and every for-sale site there. <a href="/in">All ${cities.cities.length} cities</a>`,
++ home.cityCards(largest, (c) => ({ main: cities.valueText(c.value), mainLabel: 'median home', sub: c.rent.b2 != null ? `2-bedroom rent ${cities.rentText(c.rent.b2)}` : '' })))}
++
++${moves && moves.metros.length >= 12 ? home.section('moves', `Where home prices moved over the past year`, html`The FHFA House Price Index, ${moves.latest} against a year before: an index of repeat sales of the same homes, not a price. <a href="${moves.source.url}" rel="noopener">Source: ${moves.source.name}</a>`,
++ html`<div class="home-ranks">${home.ranked('Metro areas rising fastest', moves.metros.slice(0, 6).map(metroRow))}${home.ranked('Metro areas rising slowest or falling', moves.metros.slice(-6).reverse().map(metroRow))}${moves.states.length >= 10 ? home.ranked('States rising fastest', moves.states.slice(0, 6).map(stateRow)) : ''}</div>`) : ''}
++
++${big.length >= 10 ? home.section('range', 'Where homes cost least and most', 'Big cities (250,000 people or more) by median home value.',
++ html`<div class="home-ranks">${home.ranked('Lowest median value', byValue.slice(0, 6).map(valueRow))}${home.ranked('Highest median value', byValue.slice(-6).reverse().map(valueRow))}</div>`) : ''}
++
++${home.section('states', 'Homes by state', 'Home values for every city of 50,000 people or more, and each state\'s largest, with every for-sale site and the county records for each.', home.stateChips('/in'))}
++
++${home.section('sites', 'The sites it searches', 'Each one opens its own search at your place. Offers, inspections and payments happen with the seller or an agent on that site.',
++ html`<ul class="home-sites">${sites.SITES.map((x) => html`<li><b>${x.name}</b><span>${x.host}</span><p>${x.best}</p></li>`)}</ul>`)}
++
${raw(showcase.features({
title: 'What is here',
items: [
-- { icon: 'ov:search', title: 'Every for-sale site', text: 'Zillow, Realtor.com, Redfin, Homes.com, Trulia and Craigslist, each opened at your place and price.' },
-- { icon: 'ov:shield', title: 'Government and bank homes', text: 'HUD Homestore, Fannie Mae HomePath and foreclosure auctions, often below market and first offered to people who will live there.' },
-- { icon: 'ov:docs', title: 'Property records', text: 'The county assessor and recorder for any US state: owners, sales, taxes and deeds, from the official sites.' },
-- { icon: 'ov:map', title: 'Homes by city', text: 'What homes are worth in 885 US cities, beside what renting costs, from Census data.', href: '/in' },
-- { icon: 'ov:gauge', title: 'Mortgage calculator', text: 'The monthly cost of a loan with taxes, insurance and HOA, from the numbers you type.' },
++ { icon: 'ov:search', title: 'Every for-sale site', text: 'Zillow, Realtor.com, Redfin, Homes.com, Trulia and Craigslist, each opened at your place and price.', href: '/search' },
++ { icon: 'ov:shield', title: 'Government and bank homes', text: 'HUD Homestore, Fannie Mae HomePath and foreclosure auctions, often below market and first offered to people who will live there.', href: '/search?kind=foreclosure' },
++ { icon: 'ov:docs', title: 'Property records', text: 'The county assessor and recorder for any US state: owners, sales, taxes and deeds, from the official sites.', href: '/records' },
++ { icon: 'ov:map', title: 'Homes by city', text: `What homes are worth in ${cities.cities.length} US cities, beside what renting costs, from Census data.`, href: '/in' },
++ { icon: 'ov:gauge', title: 'Mortgage calculator', text: 'The monthly cost of a loan with taxes, insurance and HOA, from the numbers you type.', href: '/mortgage' },
{ icon: 'ov:rent', title: 'Keep them on your board', text: 'Your OpenVibe.Rent board keeps homes from every site in one list, with your notes.', href: `${config.baseUrl}/board` },
],
-- }))}`,
-- }));
++ }))}
++
++${home.section('faq', 'Questions people ask', '', home.faq(FAQ))}
++
++${raw(showcase.cta({ title: 'Renting instead?', text: 'OpenVibe.Rent opens every rental site at once, with typical rents for the same cities and affordable housing from HUD.', actions: [{ label: 'Open OpenVibe.Rent', href: `${config.baseUrl}/`, primary: true }, { label: 'Search every for-sale site', href: '/search' }] }))}`,
++ });
++ }
++ r.get('/', (req, res, next) => homePage(req, res).catch(next));
// ── One place, every site ────────────────────────────────
const budget = ctx.limits.budget('rent.housing.search');
ddiff --git a/server/housing/hpi.js b/server/housing/hpi.js
iindex 86b0824..4cad107 100644
---- a/server/housing/hpi.js
++++ b/server/housing/hpi.js
@@@ -243,6 +243,46 @@ async function areaPricesFor(s, location) {
return { place: pick.place_name, level: pick.level, ...c, source: { name: SOURCE.name, url: SOURCE.url } };
}
++/**
++ * The past year's change for every state and metro area, for the homes' "where prices moved" lists: the latest quarter
++ * against the same quarter a year before, from the stored rows only. → { latest: 'Q2 2026', states: [...], metros:
++ * [...] } with each row { level, place_id, place_name, states, year_change_pct }, highest change first, or null when
++ * nothing is stored. Held for an hour per store (the index is published monthly), so a home page costs no query.
++ */
++const MOVES_TTL_MS = 60 * 60 * 1000;
++const movesCache = new WeakMap();
++async function areaMoves(s, { now = Date.now() } = {}) {
++ const hit = movesCache.get(s);
++ if (hit && now - hit.at < MOVES_TTL_MS) return hit.value;
++ const rows = await s.db.many(
++ `SELECT level, place_id, place_name, states, yr, quarter, index_nsa FROM area_price_index
++ WHERE yr >= (SELECT max(yr) - 1 FROM area_price_index) ORDER BY level, place_id, yr, quarter`, []);
++ const groups = new Map();
++ let top = null;
++ for (const r of rows) {
++ const k = Number(r.yr) * 10 + Number(r.quarter);
++ if (top == null || k > top) top = k;
++ const key = `${r.level}:${r.place_id}`;
++ if (!groups.has(key)) groups.set(key, { level: r.level, place_id: r.place_id, place_name: r.place_name, states: r.states, series: [] });
++ groups.get(key).series.push(r);
++ }
++ const latest = top == null ? null : `Q${top % 10} ${Math.floor(top / 10)}`;
++ const out = { states: [], metros: [] };
++ for (const g of groups.values()) {
++ const c = changes(g.series);
++ if (!c || c.year_change_pct == null) continue;
++ (g.level === 'state' ? out.states : out.metros).push({ level: g.level, place_id: g.place_id, place_name: g.place_name, states: g.states, year_change_pct: c.year_change_pct, latest: c.latest });
++ }
++ const byChange = (a, b) => b.year_change_pct - a.year_change_pct || a.place_name.localeCompare(b.place_name);
++ out.states.sort(byChange);
++ out.metros.sort(byChange);
++ // Only places on the most recent quarter count, so a place whose series stopped early is not ranked as current.
++ const recent = (list) => list.filter((x) => x.latest === latest);
++ const value = latest ? { latest, states: recent(out.states), metros: recent(out.metros), source: { name: SOURCE.name, url: SOURCE.url } } : null;
++ if (value) movesCache.set(s, { at: now, value }); // an empty table (before the first ingest) is asked again
++ return value;
++}
++
/** 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,
@@@ -275,5 +315,5 @@ function startHpiTimer(config, s, {
module.exports = {
SOURCE, ATTRIBUTION, DEFAULT_URL, DEFAULT_TTL_MS, YEARS,
-- csvRecords, ingestFromStream, refresh, replaceAll, areaPricesFor, changes, startHpiTimer,
++ csvRecords, ingestFromStream, refresh, replaceAll, areaPricesFor, areaMoves, changes, startHpiTimer,
};
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 1c8538a..f8078e4 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -32,6 +32,8 @@ const everywhere = require('../aggregate/everywhere');
const { mountBoardPages } = require('./board-pages');
const { mountCityPages, listingFairLine } = require('./city-pages');
const cityList = require('../aggregate/cities');
++const home = require('../render/home-blocks');
++const { products } = require('openvibe-contracts');
const SITE_NAME = 'OpenVibe.Rent';
const TAGLINE = 'Every rental site, one search.';
@@@ -154,43 +156,78 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
&& (row.state === 'active' || (signedIn(req) && row.owner === requesterOf(req)) || staffViewer(req));
// ── Home ─────────────────────────────────────────────────
++ // What the home shows is real and current: every rental site this search opens, the Census rents of 885 cities,
++ // the FHFA index's metro count, listings posted here. A block with nothing to show is left out.
++ const FAQ = ((products.get('openvibe.rent') || {}).faq || []);
++ const sitesFor = (kind) => everywhere.SITES.filter((x) => x.kinds.includes(kind));
r.get('/', async (req, res) => {
const latest = await store.search(s, listings.normalizeFilters({}), { limit: HOME_LATEST });
++ const posted = latest.rows.length ? await store.countMatching(s, listings.normalizeFilters({})) : 0;
++ let moves = null;
++ try { moves = await housingHpi.areaMoves(s); } catch { moves = null; }
++ const homesBase = String(config.homes.url).replace(/\/+$/, '');
++ const largest = home.bigCities(0).slice(0, 12);
++ const big = home.bigCities(250000).filter((c) => c.rent.b2 != null && c.rent.b2 < 3501);
++ const byTwo = [...big].sort((a, b) => a.rent.b2 - b.rent.b2);
++ const rentRow = (c) => ({ name: `${c.name}, ${c.state}`, href: c.path, value: cityList.rentText(c.rent.b2) });
++ const sizes = (c) => [c.rent.studio != null ? `Studio ${cityList.rentText(c.rent.studio)}` : null, c.rent.b1 != null ? `1 bed ${cityList.rentText(c.rent.b1)}` : null, c.rent.b3 != null ? `3 beds ${cityList.rentText(c.rent.b3)}` : null].filter(Boolean).join(' · ');
const hero = showcase.hero({
eyebrow: `${SITE_NAME} · ${TAGLINE}`,
title: 'Every rental site.',
accent: 'One search.',
-- lede: `${SITE_NAME} searches Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper, Rent.com and a dozen more at once: apartments, houses, rooms, furnished stays, commercial space, parking, storage and cars. Next to them, affordable housing from HUD and listings people post here, all under one OpenVibe account.`,
++ lede: `Type a place once and ${SITE_NAME} opens Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper, Rent.com and ${everywhere.SITES.length - 6} more at it: apartments, houses, rooms, furnished stays, commercial space, parking, storage and cars. Check any rent against what the city usually pays, keep the places you like on one board, and find affordable housing from HUD.`,
actions: [
{ label: 'Search every site', href: '/everywhere', primary: true },
++ { label: 'Is this rent fair?', href: '/fair' },
{ label: 'Affordable housing', href: '/housing' },
-- signedIn(req) ? { label: 'Post a listing', href: '/post' } : { label: 'Sign in to post', href: '/auth/login?next=%2Fpost' },
],
note: 'Open source (AGPL-3.0). Listings, messages and payments stay on each site; OpenVibe never takes a deposit.',
aside: { html: String(everywhereForm({}, { big: true })) },
});
++ const jsonLd = [
++ ...homeJsonLd(config),
++ seo.jsonLd.itemList('Typical 2-bedroom rent in the largest US cities', largest.filter((c) => c.rent.b2 != null).map((c) => ({ name: `${c.name}, ${c.state}: ${cityList.rentText(c.rent.b2)} a month`, url: `${base()}${c.path}` })), { url: `${base()}/in` }),
++ FAQ.length ? seo.jsonLd.faq(FAQ.map((f) => ({ q: f.question, a: f.answer }))) : null,
++ ];
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
-- description: 'Search Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper and more at once: apartments, houses, rooms, stays, commercial space, parking, storage and cars, plus affordable housing from HUD.',
-- jsonLd: homeJsonLd(config),
-- styles: [showcase.STYLESHEET],
++ title: 'Every rental site in one search',
++ description: 'Search Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper and more at once, check any rent against Census data for 885 US cities, and find HUD housing.',
++ jsonLd,
++ styles: [showcase.STYLESHEET], localStyles: ['css/home.css'],
body: html`${raw(hero)}
--<section class="sc-sec" aria-labelledby="h-sites"><h2 id="h-sites">The sites it searches</h2>
--<p class="sc-lede">Type a place once. Each site opens its own search there, with your price and bedrooms where it takes them, so you see every site's newest listings without repeating yourself.</p>
--<ul class="ew-chips">${everywhere.SITES.map((site) => html`<li title="${site.best}">${site.name}</li>`)}</ul>
--<p class="small muted">The United States first; more countries next.</p></section>
--
--<section class="sc-sec" aria-labelledby="h-housing"><h2 id="h-housing">Affordable housing near you</h2>
--<p class="sc-lede">Public housing, tax-credit (LIHTC) and HUD-assisted properties, and the authorities and counselling agencies to apply through, from U.S. HUD open data.</p>
--<p><a class="sc-btn sc-primary" href="/housing">Find housing near you</a></p></section>
--
--${latest.rows.length ? html`<section class="sc-sec" aria-labelledby="h-latest"><h2 id="h-latest">Posted on ${SITE_NAME}</h2>
--<p class="sc-lede">Listings people post here themselves: apartments, rooms, houses, commercial space, parking and storage, and equipment. <a href="/listings">Browse them</a> or search them by place.</p>
--${listingList(latest.rows, { empty: '' })}
--<p><a class="sc-btn" href="/listings">Every listing posted here</a> <a class="sc-btn" href="/post">Post a listing</a></p></section>` : html`<section class="sc-sec" aria-labelledby="h-cities"><h2 id="h-cities">What rent costs in the largest cities</h2>
--<p class="sc-lede">The typical 2-bedroom rent (Census Bureau), and every rental site's search for each city. <a href="/in">All 885 cities</a> · <a href="/fair">Check a rent</a></p>
--<ul class="ew-chips">${cityList.cities.slice().sort((a, b) => b.population - a.population).slice(0, 8).map((c) => html`<li><a href="${c.path}">${c.name} ${cityList.rentText(c.rent.b2)}</a></li>`)}</ul>
--<p class="small muted">Renting something out? <a href="/post">Post a listing</a>.</p></section>`}
++${home.stats([
++ { value: everywhere.SITES.length, label: 'rental sites in one search', href: '/everywhere' },
++ { value: cityList.cities.length, label: 'US cities with typical rents', href: '/in' },
++ { value: cityList.states().length, label: 'states and territories', href: '/in' },
++ { value: moves ? moves.metros.length : null, label: 'metro areas with home-price trends', href: `${homesBase}/` },
++ { value: posted || null, label: posted === 1 ? 'listing posted here' : 'listings posted here', href: '/listings' },
++ ], { label: `${SITE_NAME} in numbers` })}
++
++${home.section('kinds', 'What are you looking for?', 'Pick a kind and a place: every site that lists it opens at once, with your price and bedrooms where it takes them.',
++ html`<ul class="home-kinds">${everywhere.KINDS.map((k) => { const list = sitesFor(k); return list.length ? html`<li><a href="/everywhere?kind=${k}"><b>${everywhere.KIND_TEXT[k]}</b><span>${list.length} ${list.length === 1 ? 'site' : 'sites'}: ${list.slice(0, 4).map((x) => x.name).join(', ')}${list.length > 4 ? ' and more' : ''}</span></a></li>` : ''; })}</ul>`)}
++
++${home.section('cities', 'What rent costs in the largest cities', html`The middle rent for a 2-bedroom, with studios, 1- and 3-bedrooms beside it (Census Bureau, ${cityList.SOURCE.years}). Each city page opens every rental site there. <a href="/in">All ${cityList.cities.length} cities</a> · <a href="/fair">Check a rent</a>`,
++ home.cityCards(largest, (c) => ({ main: cityList.rentText(c.rent.b2), mainLabel: 'for 2 bedrooms', sub: sizes(c) })))}
++
++${big.length >= 10 ? home.section('range', 'Where renting costs least and most', 'Big cities (250,000 people or more) by the middle 2-bedroom rent. A price far below a city\'s middle rent is a common sign of a scam.',
++ html`<div class="home-ranks">${home.ranked('Lowest 2-bedroom rent', byTwo.slice(0, 6).map(rentRow))}${home.ranked('Highest 2-bedroom rent', byTwo.slice(-6).reverse().map(rentRow))}</div>`) : ''}
++
++${home.section('states', 'Rent by state', 'Typical rents for every city of 50,000 people or more, and each state\'s largest, with every rental site\'s search for each.', home.stateChips('/in'))}
++
++${latest.rows.length ? home.section('latest', `Posted on ${SITE_NAME}`, html`Listings people post here themselves: apartments, rooms, houses, commercial space, parking, storage and equipment. <a href="/listings">Browse them all</a>.`,
++ html`${listingList(latest.rows, { empty: '' })}<p><a class="sc-btn" href="/listings">Every listing posted here</a> <a class="sc-btn" href="/post">Post a listing</a></p>`) : ''}
++
++${home.section('sites', 'The sites it searches', 'Each one opens its own search at your place, so you see its newest listings without typing the search again.',
++ html`<ul class="home-sites">${everywhere.SITES.map((x) => html`<li><b>${x.name}</b><span>${x.host}${x.us ? '' : ' · worldwide'}</span><p>${x.best}</p></li>`)}</ul>
++<p class="small muted">The United States first; more countries next. Listings, the landlord and the payment stay on each site.</p>`)}
++
++${home.section('housing', 'Affordable housing near you', 'Public housing, tax-credit (LIHTC) and HUD-assisted properties, and the housing authorities and counselling agencies to apply through, from U.S. HUD open data.',
++ html`<ul class="home-chips">${largest.slice(0, 8).map((c) => html`<li><a href="/housing?${new URLSearchParams({ q: `${c.name}, ${c.state}` })}">${c.name}</a></li>`)}</ul>
++<p><a class="sc-btn sc-primary" href="/housing">Find housing near you</a></p>`)}
++
++${home.section('buy', 'Buying instead?', html`OpenVibe.Homes is the owning side: every for-sale site in one search, home values for the same cities, county records and a mortgage calculator. <a href="${homesBase}/">Open OpenVibe.Homes</a>`,
++ home.cityCards(largest.slice(0, 4).filter((c) => c.value != null), (c) => ({ main: cityList.valueText(c.value), mainLabel: 'median home', sub: c.rent.b2 != null ? `2-bedroom rent ${cityList.rentText(c.rent.b2)}` : '' }), { href: (c) => `${homesBase}${c.path}` }))}
${raw(showcase.features({
title: 'How it works',
@@@ -198,14 +235,14 @@ ${raw(showcase.features({
items: [
{ icon: 'ov:search', title: 'One search, every site', text: 'Type a place once and every site above opens its own search there, with your price and bedrooms.', href: '/everywhere' },
{ icon: 'ov:check', title: 'Your board', text: 'Keep the listings you like from any site in one list, with your notes and how far you got.', href: '/board' },
-- { icon: 'ov:map', title: 'Is the rent fair?', text: 'What rent usually costs in 885 US cities by size, and a check for any price you are quoted, from Census data.', href: '/in' },
-- { icon: 'ov:bell', title: 'Alerts that keep looking', text: 'Save a search and get alerts through OpenVibe.Watch when something new matches.' },
++ { icon: 'ov:map', title: 'Is the rent fair?', text: `What rent usually costs in ${cityList.cities.length} US cities by size, and a check for any price you are quoted, from Census data.`, href: '/fair' },
++ { icon: 'ov:bell', title: 'Alerts that keep looking', text: 'Save a search and get alerts through OpenVibe.Watch when something new matches.', href: '/listings' },
{ icon: 'ov:gauge', title: 'Safety first', text: 'Never pay or send a deposit before you have seen the place and signed an agreement. Reported listings are hidden until staff look.', href: '/safety' },
++ { icon: 'ov:rent', title: 'Post your own', text: 'Rooms, apartments, parking or gear: a description, a price and where it is. Edit, renew or hide it any time.', href: '/post' },
],
}))}
--<section class="sc-sec" aria-labelledby="h-sources"><h2 id="h-sources">Where the listings come from</h2>
--<p class="sc-prose">From every major rental site, opened at your place and filters: the listings, the landlord and the payment stay on that site. From U.S. HUD open data, for affordable housing. And from people posting here. Partner feeds and licensed listing data join as they are signed, each listing labelled with its source. <a href="/safety">How to rent safely</a>.</p></section>
++${home.section('faq', 'Questions people ask', '', home.faq(FAQ))}
${raw(showcase.cta({ title: 'Have something to rent out?', text: 'Post it in a minute: a description, a price, a period and where it is. You can edit, renew or hide it at any time.', actions: [{ label: 'Post a listing', href: '/post', primary: true }, { label: 'Search every site', href: '/everywhere' }] }))}`,
});
ddiff --git a/server/render/home-blocks.js b/server/render/home-blocks.js
nnew file mode 100644
iindex 0000000..d2d34f9
---- /dev/null
++++ b/server/render/home-blocks.js
@@@ -0,0 +1,53 @@
++'use strict';
++
++/**
++ * The pieces OpenVibe.Rent's and OpenVibe.Homes' home pages share (public/css/home.css): a row of counts, city cards,
++ * a ranked list, state chips and the questions people ask. Each takes real rows only and renders nothing when it has
++ * none, so a section never stands empty.
++ */
++const { html, render } = require('./html');
++const cities = require('../aggregate/cities');
++
++/** Counts that are true right now: [{ value, label, href? }]; an item without a value is left out. */
++function stats(items, { label = 'In numbers' } = {}) {
++ const real = (items || []).filter((i) => i && i.value != null && i.value !== '' && i.value !== 0);
++ if (!real.length) return '';
++ return html`<ul class="home-stats" aria-label="${label}">${real.map((i) => html`<li>${i.href ? html`<a href="${i.href}"><b>${i.value}</b><span>${i.label}</span></a>` : html`<b>${i.value}</b><span>${i.label}</span>`}</li>`)}</ul>`;
++}
++
++/** The section wrapper every home block uses: an h2 with an id, a lede, the body. */
++const section = (id, title, lede, body) => (render(body).trim()
++ ? html`<section class="sc-sec" aria-labelledby="h-${id}"><h2 id="h-${id}">${title}</h2>${lede ? html`<p class="sc-lede">${lede}</p>` : ''}${body}</section>`
++ : '');
++
++/**
++ * City cards: the name, the headline number and a smaller line under it. `pick(c)` → { main, mainLabel, sub }.
++ * `href(c)` is where the card goes (the city page by default).
++ */
++function cityCards(list, pick, { href = (c) => c.path } = {}) {
++ if (!list.length) return '';
++ return html`<ul class="home-cities">${list.map((c) => {
++ const p = pick(c);
++ return html`<li><a href="${href(c)}"><span class="hc-name">${c.name}<span class="hc-state">${c.state}</span></span><span class="hc-main"><b>${p.main || 'not published'}</b> <span>${p.mainLabel}</span></span>${p.sub ? html`<span class="hc-sub">${p.sub}</span>` : ''}</a></li>`;
++ })}</ul>`;
++}
++
++/** A numbered list of [{ name, href?, value, note? }], for "lowest" and "highest" pairs side by side. */
++function ranked(title, rows) {
++ if (!rows.length) return '';
++ return html`<div class="home-rank"><h3>${title}</h3><ol>${rows.map((r) => html`<li>${r.href ? html`<a href="${r.href}">${r.name}</a>` : r.name}<span class="hr-val">${r.value}</span></li>`)}</ol></div>`;
++}
++
++/** Every state with cities, as chips to its page. */
++const stateChips = (prefix = '/in') => html`<ul class="home-chips">${cities.states().map((st) => html`<li><a href="${prefix}/${st.slug}">${st.name} <span>${st.count}</span></a></li>`)}</ul>`;
++
++/** The questions people ask (the product's own FAQ from openvibe-contracts), open on click, readable without JS. */
++function faq(items) {
++ if (!items || !items.length) return '';
++ return html`<div class="home-faq">${items.map((f) => html`<details><summary>${f.question}</summary><p>${f.answer}</p></details>`)}</div>`;
++}
++
++/** Cities with at least `min` people, largest first. */
++const bigCities = (min) => cities.cities.filter((c) => c.population >= min).sort((a, b) => b.population - a.population);
++
++module.exports = { stats, section, cityCards, ranked, stateChips, faq, bigCities };
ddiff --git a/test/homes.test.js b/test/homes.test.js
iindex dd359be..3083925 100644
---- a/test/homes.test.js
++++ b/test/homes.test.js
@@@ -79,6 +79,10 @@ const housingFetchImpl = async (url) => (new URL(String(url)).pathname.endsWith(
assert.ok(up.text.includes('What shipped on OpenVibe.Homes') && up.text.includes('data-ov-shipped'));
assert.ok(/<script type="application\/ld\+json">[^<]*"@type":"WebSite"[^<]*"name":"OpenVibe.Homes"/.test(r.text), 'the home says what the site is (JSON-LD)');
assert.ok(r.text.includes('<link rel="canonical" href="https://openvibe.homes/">'), 'its own canonical');
++ assert.ok(r.text.includes('What homes are worth in the largest cities') && r.text.includes('href="/in/ny/new-york"'), 'the largest cities\' home values, each opening its city page');
++ assert.ok(r.text.includes('Where homes cost least and most') && r.text.includes('Homes by state'), 'the cheapest and dearest big cities, and every state');
++ assert.ok(r.text.includes('Questions people ask') && /"@type":"FAQPage"/.test(r.text) && /"@type":"ItemList"/.test(r.text), 'the questions shown are the FAQPage; the cities are an ItemList');
++ assert.ok(!r.text.includes('Where home prices moved'), 'no price-trend block without FHFA rows');
const rent = await t.get('/');
assert.ok(!rent.text.includes('Buying a home?'), 'Rent still serves Rent');
assert.strictEqual((await homes('/listings')).status, 404, 'Rent\'s pages are not on the Homes host');
ddiff --git a/test/housing-hpi.test.js b/test/housing-hpi.test.js
iindex 40515b9..20b57f0 100644
---- a/test/housing-hpi.test.js
++++ b/test/housing-hpi.test.js
@@@ -180,6 +180,14 @@ const mockFetch = async (url) => {
assert.ok(r.text.includes('https://www.fhfa.gov/data/hpi'));
});
++ await check('where prices moved: every state and metro with a year-ago quarter, highest first, on the latest quarter only', async () => {
++ const m = await hpi.areaMoves(t.ctx.s);
++ assert.strictEqual(m.latest, 'Q2 2026');
++ assert.deepStrictEqual(m.states.map((x) => [x.place_id, x.year_change_pct]), [['CO', 8.0]], 'Texas has no year-ago quarter, so it is not ranked');
++ assert.deepStrictEqual(m.metros.map((x) => [x.place_name, x.year_change_pct]), [['Denver-Aurora-Centennial, CO', 10.0]]);
++ assert.strictEqual(m.source.url, '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);
ddiff --git a/test/layout.test.js b/test/layout.test.js
iindex 754c986..ddade45 100644
---- a/test/layout.test.js
++++ b/test/layout.test.js
@@@ -38,11 +38,11 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
assert.strictEqual(r.status, 200);
const head = r.text.slice(0, r.text.indexOf('</head>'));
assert.strictEqual((r.text.match(/<title>/g) || []).length, 1, 'exactly one <title>');
-- assert.ok(head.includes('<title>OpenVibe.Rent: Find a place, or rent yours out.</title>'), 'home title');
++ assert.ok(head.includes('<title>Every rental site in one search · OpenVibe.Rent</title>'), 'home title');
assert.ok(head.includes('<link rel="canonical" href="https://openvibe.rent/">'), 'canonical');
assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);
-- assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage'], 'the home JSON-LD');
++ assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage', 'ItemList', 'FAQPage'], 'the home JSON-LD (the largest cities\' rents and the questions shown on the page)');
for (const tag of ['<meta property="og:title"', 'data-ov-icon="rent"', '/css/app.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {
assert.ok(head.includes(tag), `head has ${tag}`);
}
@@@ -61,7 +61,7 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
assert.strictEqual(r.status, 200, r.text.slice(0, 300));
assert.match(r.headers.get('content-type'), /^text\/html/);
assert.ok(r.text.includes('OpenVibe.Rent'), 'the service name is on the page');
-- assert.ok(r.text.includes('Find a place, or rent yours out.'), 'the tagline is on the page');
++ assert.ok(r.text.includes('Every rental site, one search.'), 'the tagline is on the page');
assert.ok(r.text.includes('/shared/showcase.css'), 'the showcase stylesheet');
assert.ok(!r.text.includes('/home/') && !r.text.includes('/mnt/'), 'no local filesystem path on the page');
});
ddiff --git a/test/listings-pages.test.js b/test/listings-pages.test.js
iindex 0d8c53b..4eb3229 100644
---- a/test/listings-pages.test.js
++++ b/test/listings-pages.test.js
@@@ -26,7 +26,9 @@ const listings = require('../server/listings/listings');
assert.ok(r.text.includes('A flat on the home page'), 'the latest listings are on the home page');
assert.match(r.text, /How it works/);
assert.ok(r.text.includes('Never pay or send a deposit before you have seen the place'), 'the no-deposit rule');
-- assert.match(r.text, /Where the listings come from/, 'where the listings come from');
++ assert.match(r.text, /Where do the listings come from\?/, 'where the listings come from (the product\'s FAQ)');
++ assert.match(r.text, /What rent costs in the largest cities/, 'the largest cities\' rents');
++ assert.match(r.text, /href="\/in\/ny\/new-york"/, 'a city card opens its city page');
assert.match(r.text, /href="\/post"/);
assert.match(r.text, /href="\/safety"/);
});
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex ed91476..56071cf 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -17,15 +17,19 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
// Raised on 2026-10-08 for the product's home page: it is no longer the skeleton's, so the measurement moved from
// 24.9/6.2 KB to 28.4/6.5 KB (search box, latest listings, how it works, where the listings come from, cta). The
// budget keeps the same ~10% headroom over the new measurement; the other lines are unchanged.
++// Raised on 2026-10-22 for the curated home (the owner's "rich content on the home page"): counts, the kinds, the 12
++// largest cities' rents, the cheapest and dearest big cities, every state, every site searched, HUD links, Homes, the
++// product's questions, and ItemList + FAQPage JSON-LD. Measured 49.3/10.8 KB HTML and 25.5/5.9 KB CSS with one more
++// file (css/home.css); the budgets keep about 10% headroom over that.
const BUDGETS = {
-- htmlRawKB: 33.5, // measured 31.0 (real glyphs for page, db, deploy and gauge icons, shared 2.21.1); was 31.5
-- htmlBrotliKB: 8.5, // 8.1 on 2026-10-16 (Rent by city: the largest cities' rents where an empty "posted here" was); 7.4 before
++ htmlRawKB: 54, // 49.3 on 2026-10-22 (the curated home); 31.0 before
++ htmlBrotliKB: 12, // 10.8 on 2026-10-22; 8.1 on 2026-10-16 (Rent by city: the largest cities' rents where an empty "posted here" was); 7.4 before
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 264, // 239.8
jsBrotliKB: 62.5, // 56.5
-- cssFiles: 2, // 2 (app.css + the cached /shared/showcase.css)
-- cssRawKB: 21.5, // 19.4 with the listing cards and the search panel
-- cssBrotliKB: 5.1, // 4.6
++ cssFiles: 3, // 3 (app.css, the cached /shared/showcase.css, css/home.css)
++ cssRawKB: 28, // 25.5 with the home's cards, ranks, chips and questions; 19.4 before
++ cssBrotliKB: 6.5, // 5.9; 4.6 before
externalFiles: 0, // 0
};