openvibers/openvibe.rent Commits

Rent: the market outcomes beside the asking rent (#156)

OP OpenVibers committed 13 days ago · bd46ce4f62 · 10 files, +424 −61

* Rent: the market outcomes beside the asking price

HUD's Fair Market Rent now sits beside a listing's asking rent, with the
Census median as a second labelled line: metric, geography, the date each
figure carries, the source linked, and a plain line that they are never
averaged. HUD's feed carries no fiscal year, so the line is dated with the
year and day Sources read the figure; a listing has no ZIP here, so the
figure is its city's FMR area.

The city page labels HUD's column with the 40th percentile and the read
date, and the BLS rent trend now shows month on month and year on year as
two fields, each naming the months it compares (a missing comparison reads
not measured yet).

Every listing card carries the dated scam-check result, and a flagged one
names the signs in a reader's words with the quoted words behind them. A
listing card and its page can report in one tap to the staff queue, and the
page says we take no payment from renter or landlord. Days on market and
concessions are left out: we do not fetch them.

* Rent: dates in words, never a raw UTC timestamp

The shared time() helper now renders "31 Oct 2026" (keeping the ISO value in
<time datetime>) instead of "2026-10-31 04:45:43 UTC". The listing page's
posted and expires dates, the /listings cards, and the board, saved, staff
and housing-fetch dates all read in words. dayText lives beside time() and is
reused by the city and listing pages.

Tests assert the listing page and /listings cards show a written date and no
UTC stamp, and that the city page has none.

Changes

ddiff --git a/README.md b/README.md
iindex dcbe13f..ca30e64 100644
---- a/README.md
++++ b/README.md
@@@ -20,14 +20,17 @@ are in the sitemap and llms.txt, and each city page is an OpenVibe.Search docume
sent by the search sweep; contracts 0.197.0). The data is `server/data/cities.json`, built by `node tools/build-cities.js <dir>` from the
Census Bureau's keyless ACS 5-year table files and gazetteer (see the script for the URLs); nothing is fetched at run time.
Beside the Census medians, each city page shows **HUD's Fair Market Rents** for the city's FMR area (a studio to four
--bedrooms, rent plus essential utilities, what Housing Choice Vouchers are measured against), and the fair check names
--the one for that size. Rent reads them from OpenVibe.Sources (`hud-fair-market-rents`, category `housing`,
++bedrooms, rent plus essential utilities, what Housing Choice Vouchers are measured against), labelled with the
++percentile and the date Sources read the figure, and the fair check names the one for that size. A listing posted here
++in a listed US city shows the asking rent, the Census median and HUD's FMR as separate labelled lines, never averaged.
++Rent reads them from OpenVibe.Sources (`hud-fair-market-rents`, category `housing`,
`sources.item.read`), never from HUD ([server/housing/fair-market-rents.js](server/housing/fair-market-rents.js)); which
area a city lies in is `server/data/city-fmr-areas.json`, built once by `node tools/build-fmr-areas.js` from each city's
Census point on HUD's own FMR layer.
--Each city page also draws a **BLS CPI rent trend line** for the last 13 months, with the 12-month change ("Rents
--in <metro> rose X% in the last year (BLS CPI, <month>)"), and the fair check repeats the figure for the city.
++Each city page also draws a **BLS CPI rent trend line** for the last 13 months, with **month on month** and **year on
++year** as two separate fields (each naming the months it compares; a missing comparison reads "not measured yet"), and
++the fair check repeats the 12-month figure for the city.
The national line is always shown; a city BLS publishes a metro rent series for gets its metro line too. Rent
reads it from OpenVibe.Sources (`bls-cpi-rent`, category `housing`; the national series and the 19 largest metro
series), never from BLS ([server/housing/rent-cpi.js](server/housing/rent-cpi.js), `rent_cpi_points`), with BLS's
@@@ -193,9 +196,9 @@ Index master CSV — paced and cached in [server/housing/upstream.js](server/hou
| Expiry | [server/listings/expiry.js](server/listings/expiry.js), [store.js](server/listings/store.js) | A listing lasts 30 days. A timer (started by [server/index.js](server/index.js), never by a request) marks the ones that are up expired; an expired listing leaves search and its owner can renew it for 30 more days from now. |
| Search | [server/listings/store.js](server/listings/store.js), [server/http/pages.js](server/http/pages.js) | `/listings?q=&kind=&city=&country=&min=&max=&period=`, newest first, 30 a page with a cursor, and facets for kind and country (each counted without its own filter). A filter we do not know is ignored, never an error. |
| One listing | `/listings/:id` | The listing, its safety note, and a contact link **only for a signed-in viewer** — everyone else is asked to sign in. JSON-LD is published as an `Offer` (price, currency, `priceValidUntil`) only when the kind has a clear schema.org type; `other` gets none. |
--| 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): the listing page has a one-tap **Report this listing** to the staff queue, with the detailed reason form folded under it. **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. The page says plainly that OpenVibe takes no payment from renter or landlord. |
| 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. |
--| AI screen | [server/listings/screen.js](server/listings/screen.js), `/staff?state=flagged` | Each listing a person posts or edits is read by OpenVibe.AI's `rent.screen_listing` for the signs of a rental scam (payment before a viewing, wire/gift-card/crypto payment, an owner who cannot show the place, pressure, identity or bank details up front, only a private channel), never with the poster or the contact link. `review` puts it in the staff page's **flagged** queue with the signs and the quoted words; nothing is hidden or refused, and staff decide (**Looks fine**, hide, remove). The same words are never screened twice; a failed run is retried by a sweep every 30 minutes. Off without `OV_AI_INTERNAL_URL` (and the client secret). |
++| AI screen | [server/listings/screen.js](server/listings/screen.js), `/staff?state=flagged` | Each listing a person posts or edits is read by OpenVibe.AI's `rent.screen_listing` for the signs of a rental scam (payment before a viewing, wire/gift-card/crypto payment, an owner who cannot show the place, pressure, identity or bank details up front, only a private channel), never with the poster or the contact link. `review` puts it in the staff page's **flagged** queue with the signs and the quoted words, and the listing's own page shows the check result, its date and the quoted words in a reader's terms; nothing is hidden or refused, and staff decide (**Looks fine**, hide, remove). The same words are never screened twice; a failed run is retried by a sweep every 30 minutes. Off without `OV_AI_INTERNAL_URL` (and the client secret). |
| 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. |
| The board | [server/board/board.js](server/board/board.js), [server/http/board-pages.js](server/http/board-pages.js), `/board` | Keep a listing from any site with its link, title, price, place and notes, and move it along as you go (interested, contacted, viewing booked, applied, rented, not for me). The same link twice is one item; at most 500. A bookmarklet (shown as text to copy) opens `/board?url=&title=` from the listing you are on, with the site's name taken off the page title. Rent stores what you send and never fetches the listing: it, the messages and any payment stay on its site. |
| Alerts | `/listings.atom`, Get alerts | Every search is an Atom feed with the same filters (the newest 25, cached 5 minutes), linked from the listings page's head. **Get alerts** on the listings page and on each saved search opens OpenVibe.Watch's feed form with that feed filled in, so Watch tells you, or runs your agent, when a listing that fits appears. |
ddiff --git a/public/css/cities.css b/public/css/cities.css
iindex 5637f49..a087c1e 100644
---- a/public/css/cities.css
++++ b/public/css/cities.css
@@@ -50,3 +50,43 @@
.city-homes-cards { margin: 18px 0 6px; }
.value-card .value-num { margin: 4px 0 6px; font-size: 1.6rem; font-weight: 750; font-variant-numeric: tabular-nums; }
.value-card .value-num .moe { font-size: .85rem; font-weight: 500; }
++
++/* The listing page's asking rent, the two official benchmarks beside it, and the dated scam check. */
++.listing-asking { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; margin: 2px 0 6px; }
++.listing-asking-label { font-size: .8rem; font-weight: 650; letter-spacing: .04em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); }
++.listing-asking .price { font-size: 1.35rem; }
++.rent-check { margin: 14px 0; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
++.rent-check h2 { margin: 0 0 10px; font-size: 1.1rem; }
++.rent-lines { margin: 0; display: grid; gap: 8px; }
++.rent-line { min-width: 0; padding: 10px 12px; border-radius: 10px; background: var(--bg-secondary, #0f1729); border: 1px solid var(--border, #1f2d47); }
++.rent-line dt { font-size: .78rem; font-weight: 650; text-transform: uppercase; letter-spacing: .04em; color: var(--text-secondary, #96a7c2); }
++.rent-line dd { margin: 2px 0 0; font-size: 1.25rem; font-weight: 750; font-variant-numeric: tabular-nums; color: var(--text-primary, #e6edf7); overflow-wrap: anywhere; }
++.rent-line dd .moe { font-size: .8rem; font-weight: 500; color: var(--text-muted, #7386a3); }
++.rent-line-note { display: block; margin-top: 3px; font-size: .82rem; font-weight: 400; line-height: 1.4; color: var(--text-secondary, #96a7c2); }
++.rent-line-year { font-weight: 500; text-transform: none; letter-spacing: 0; color: var(--text-muted, #7386a3); }
++.rent-never { margin: 10px 0 0; }
++.listing-check { margin: 12px 0; padding: 10px 12px; border-radius: 10px; border-left: 3px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); }
++.listing-check p { margin: 0; }
++.listing-check.check-ok { border-left-color: var(--success, #22c55e); }
++.listing-check.check-warn { border-left-color: var(--warning, #f59e0b); }
++.check-quotes { margin: 6px 0; padding-left: 18px; }
++.check-quotes li { margin: 3px 0; }
++.listing-nopay { margin-top: 8px; }
++.listing-report { margin: 18px 0; }
++.listing-report h2 { font-size: 1.1rem; }
++.listing-report form { margin: 8px 0; }
++.listing-report-more { margin-top: 10px; }
++.listing-report-more summary { cursor: pointer; font-size: .9rem; color: var(--text-secondary, #96a7c2); }
++.listing-report-more .report-form { margin-top: 10px; }
++/* The two changes beside the BLS trend: month on month and year on year, never one number. */
++.rent-cpi-head { margin: 0 0 6px; }
++.rent-moves { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 150px), 1fr)); gap: 8px; margin: 8px 0; }
++.rent-move { padding: 8px 10px; border-radius: 10px; background: var(--bg-secondary, #0f1729); border: 1px solid var(--border, #1f2d47); }
++.rent-move-label { display: block; font-size: .78rem; font-weight: 650; color: var(--text-secondary, #96a7c2); }
++.rent-move strong { display: block; font-size: 1.3rem; font-variant-numeric: tabular-nums; color: var(--text-primary, #e6edf7); }
++.rent-move .rent-move-none { font-size: .95rem; color: var(--text-secondary, #96a7c2); }
++.rent-move-note { display: block; font-size: .76rem; color: var(--text-muted, #7386a3); }
++.rent-move strong.rent-move-up { color: var(--warning, #f59e0b); }
++.rent-move strong.rent-move-down { color: var(--success, #22c55e); }
++.listing-report-mini { margin-top: 6px; }
++.listing-report-mini button { font-family: inherit; line-height: inherit; cursor: pointer; background: transparent; }
ddiff --git a/server/housing/rent-cpi.js b/server/housing/rent-cpi.js
iindex 4d5935a..6a471bf 100644
---- a/server/housing/rent-cpi.js
++++ b/server/housing/rent-cpi.js
@@@ -94,18 +94,27 @@ function pointOf(row) {
return { year: Number(row.year), period: row.period, value: Number(row.value), month: monthKey(row.year, row.period), label: `${MONTHS[Number(row.period.slice(1)) - 1]} ${row.year}` };
}
--/** The latest point and the 12-month change, with the points a trend line draws (up to the last 13 months). */
++/**
++ * The latest point with the two changes a reader compares: month on month (the month before) and year on year (the
++ * same month a year earlier), each only when that point is really in the series — never a guess from a shorter run.
++ */
function trendOf(rows) {
const points = rows.map(pointOf).sort((a, b) => a.month - b.month);
if (!points.length) return null;
const latest = points[points.length - 1];
++ const previous = points.find((p) => p.month === latest.month - 1) || null;
const earlier = points.find((p) => p.month === latest.month - 12) || null;
-- const change_pct = earlier && earlier.value ? Math.round((latest.value / earlier.value - 1) * 1000) / 10 : null;
++ const pct = (from) => (from && from.value ? Math.round((latest.value / from.value - 1) * 1000) / 10 : null);
++ const mom_pct = pct(previous);
++ const change_pct = pct(earlier);
return {
points: points.slice(-13),
latest,
++ previous,
earlier,
++ mom_pct,
change_pct,
++ yoy_pct: change_pct,
series: rows[0].series_id,
};
}
@@@ -210,7 +219,11 @@ function createRentCpi({ config, s, fetchImpl = globalThis.fetch, log = console,
url: picked.url,
points: picked.points,
latest: picked.latest,
++ previous: picked.previous,
++ earlier: picked.earlier,
++ mom_pct: picked.mom_pct,
change_pct: picked.change_pct,
++ yoy_pct: picked.yoy_pct,
national,
metro: local,
};
ddiff --git a/server/http/city-pages.js b/server/http/city-pages.js
iindex 39f798f..800faf5 100644
---- a/server/http/city-pages.js
++++ b/server/http/city-pages.js
@@@ -18,7 +18,7 @@
const seo = require('openvibe-shared/seo');
const showcase = require('openvibe-shared/showcase');
const cache = require('openvibe-shared/cache-policy');
--const { html, raw } = require('../render/html');
++const { html, raw, dayText } = require('../render/html');
const cities = require('../aggregate/cities');
const everywhere = require('../aggregate/everywhere');
const listings = require('../listings/listings');
@@@ -27,7 +27,7 @@ const PUBLIC = cache.htmlHeaders({ maxAge: 3600 });
const STYLES = ['css/everywhere.css', 'css/cities.css'];
const HOMES_URL = 'https://openvibe.homes';
--const source = () => html`<p class="small muted city-source">Median gross rent (rent plus the utilities the renter pays): half of rentals cost less, half more. <a href="${cities.SOURCE.url}" rel="noopener">${cities.SOURCE.name}</a>, ${cities.SOURCE.years} (public domain). The ± is the Census' 90% margin of error.</p>`;
++const source = () => html`<p class="small muted city-source">Median gross rent (rent plus the utilities the renter pays): half of rentals cost less, half more. <a href="${cities.SOURCE.url}" rel="noopener">${cities.SOURCE.name}</a>, ${cities.SOURCE.years} (public domain). The ± is the Census' 90% margin of error. The Census median and HUD's Fair Market Rent are separate measures, shown side by side and never averaged.</p>`;
const cell = (c, key) => {
const v = cities.rentText(c.rent[key]);
return v ? html`${v}${c.moe[key] && c.rent[key] < 3501 ? html` <span class="moe">±$${c.moe[key].toLocaleString('en-US')}</span>` : ''}` : html`<span class="muted">not published</span>`;
@@@ -36,8 +36,13 @@ const placeLabel = (c) => `${c.name}, ${c.state}`;
const { HUD_URL } = require('../housing/fair-market-rents');
const { NOTE: CPI_NOTE, NATIONAL: CPI_NATIONAL } = require('../housing/rent-cpi');
const fmrText = (n) => (n == null ? null : `$${n.toLocaleString('en-US')}`);
++/** A change as a reader reads it: +5.0%, -0.1%, or null when the comparison point is not in the series. */
++const pctText = (n) => (n == null ? null : `${n > 0 ? '+' : ''}${n.toFixed(1)}%`);
/** What a Fair Market Rent is, under the city table (HUD's own definition and use, in short). */
--const fmrSource = (area) => html`<p class="small muted city-source">HUD Fair Market Rent for the ${area.name}: what a modest rental there costs, rent plus essential utilities, by HUD's estimate. HUD sets them each October; Housing Choice Vouchers pay up to 90% to 110% of them. <a href="${HUD_URL}" rel="noopener">HUD</a> (public domain), through OpenVibe.Sources.</p>`;
++const fmrSource = (area) => {
++ const read = dayText(area.retrieved_at);
++ return html`<p class="small muted city-source">HUD Fair Market Rent for the ${area.name}: what a modest rental there costs, rent plus essential utilities, by HUD's estimate; the 40th percentile of gross rent for most areas. HUD sets them each October; Housing Choice Vouchers pay up to 90% to 110% of them. <a href="${HUD_URL}" rel="noopener">HUD</a> (public domain), through OpenVibe.Sources${read ? html`, read ${read}` : ''}.</p>`;
++};
/** A small line for a BLS CPI trend: one point per month, the last 13 at most. */
function trendSvg(trend, label) {
@@@ -53,14 +58,21 @@ function trendSvg(trend, label) {
return html`<svg class="rent-trend" viewBox="0 0 320 64" width="320" height="64" role="img" aria-label="${label}"><polyline fill="none" stroke="currentColor" stroke-width="2" points="${poly}"/></svg>`;
}
++/** One field of a change: its label, the number (or "not measured yet") and the two months it compares. */
++function moveField(label, pct, from, to, missing) {
++ return html`<div class="rent-move"><span class="rent-move-label">${label}</span><strong class="${pct == null ? 'rent-move-none' : pct < 0 ? 'rent-move-down' : 'rent-move-up'}">${pct == null ? 'not measured yet' : pctText(pct)}</strong><span class="rent-move-note">${pct == null ? missing : `${to} vs ${from}`}</span></div>`;
++}
++
/** The BLS trend lines a page shows: the national line and, where BLS publishes one, a city's metro line. */
function cpiBlock(cpi) {
if (!cpi) return '';
const lines = [cpi.national, cpi.metro].filter(Boolean).filter((t, i, all) => all.findIndex((x) => x.series === t.series) === i);
if (!lines.length) return '';
return html`<section class="sc-sec rent-cpi" aria-labelledby="h-cpi"><h2 id="h-cpi">Are rents rising?</h2>
--${lines.map((t) => html`<div class="rent-cpi-line"><p>${t.text} <a href="${t.url}" rel="noopener">BLS series ${t.series}</a></p>${trendSvg(t, `Rent of primary residence CPI, ${t.area}, last 13 months`)}</div>`)}
--<p class="small muted city-source">Rent of primary residence CPI from the <a href="${lines[0].url}" rel="noopener">U.S. Bureau of Labor Statistics</a> (public domain), through OpenVibe.Sources. ${CPI_NOTE}</p></section>`;
++${lines.map((t) => html`<div class="rent-cpi-line"><p class="rent-cpi-head">Rent of primary residence CPI, ${t.area} · ${t.latest.label} <a href="${t.url}" rel="noopener">BLS series ${t.series}</a></p>
++<div class="rent-moves" role="group" aria-label="Change in ${t.area}, ${t.latest.label}">${moveField('Month on month', t.mom_pct, t.previous && t.previous.label, t.latest.label, 'the month before is not in BLS’ series yet')}${moveField('Year on year', t.yoy_pct, t.earlier && t.earlier.label, t.latest.label, 'the same month a year earlier is not in BLS’ series yet')}</div>
++${trendSvg(t, `Rent of primary residence CPI, ${t.area}, last 13 months`)}</div>`)}
++<p class="small muted city-source">${CPI_NOTE} Through OpenVibe.Sources.</p></section>`;
}
function mountCityPages(r, { config, page, fmr = null, rentCpi = null }) {
@@@ -142,8 +154,8 @@ ${source()}
<p class="sc-lede">Every rental site's search for ${c.name} in one place, what rent usually costs here, and a quick check for a price you were quoted.</p>
<div class="city-top">
<section class="rent-card" aria-labelledby="h-typical"><h2 id="h-typical">What rent costs in ${c.name}</h2>
--${area ? html`<table class="rent-sizes rent-sizes-fmr"><caption class="sr-only">Median gross rent (Census) and HUD Fair Market Rent by size in ${where}</caption>
--<thead><tr><td></td><th scope="col">Middle rent</th><th scope="col">HUD fair market</th></tr></thead><tbody>
++${area ? html`<table class="rent-sizes rent-sizes-fmr"><caption class="sr-only">Census median gross rent and HUD Fair Market Rent by size in ${where}, separate measures never averaged</caption>
++<thead><tr><td></td><th scope="col">Census median</th><th scope="col">HUD FMR</th></tr></thead><tbody>
${cities.SIZES.map((s) => html`<tr><th scope="row">${s.text}</th><td>${cell(c, s.key)}</td><td>${fmrText(area.rents[s.key]) || html`<span class="muted">—</span>`}</td></tr>`)}
<tr class="rent-all"><th scope="row">All rentals</th><td>${cell(c, 'all')}</td><td></td></tr></tbody></table>
${source()}${fmrSource(area)}` : html`<table class="rent-sizes"><caption class="sr-only">Median gross rent by size in ${where}</caption><tbody>
@@@ -222,15 +234,62 @@ ${cpi && cpi.text ? html`<p class="small">${cpi.text} <a href="${cpi.url}" rel="
}
/**
-- * The fair-rent line for a listing posted here, when it can be judged: a US monthly rent in dollars for an apartment or
-- * house with a bedroom count, in a city on the list (its region naming the state). → html or ''.
++ * Where a listing's rent sits against the Census median, in one short line without repeating the median itself.
++ * Same conditions as listingRentCheck; → html or ''.
*/
--function listingFairLine(row) {
-- if (!row || String(row.country || '').toUpperCase() !== 'US' || row.currency !== 'USD' || row.period !== 'month' || !['apartment', 'house'].includes(row.kind) || row.bedrooms == null) return '';
++function listingFairLine(row, fair = null) {
++ const f = fair || fairOf(row);
++ if (!f) return '';
++ const { city, size } = f;
++ const verdict = f.verdict === 'typical' ? `about typical for ${size.long}`
++ : f.verdict === 'high-end' ? 'in the range the Census reports for the top of the market'
++ : `${Math.abs(f.pct)}% ${f.diff < 0 ? 'below' : 'above'} the Census middle rent for ${size.long}`;
++ return html`<p class="fair-line verdict-${f.verdict}">This rent is ${verdict} in ${city.name}, ${city.state}. <a href="${city.path}">Rents in ${city.name}</a></p>`;
++}
++
++/** The Census city and size a listing can be judged in: a US monthly apartment or house with 0–5 bedrooms. */
++const listingCity = (row) => {
++ if (!row || String(row.country || '').toUpperCase() !== 'US' || row.currency !== 'USD' || row.period !== 'month'
++ || !['apartment', 'house'].includes(row.kind) || row.bedrooms == null) return null;
const st = cities.stateCode(row.region);
const city = st ? cities.match(`${row.city}, ${st}`) : null;
-- const f = city ? cities.fair({ city, beds: row.bedrooms, rent: row.price }) : null;
-- return f ? html`<p class="fair-line verdict-${f.verdict}">${f.text} <a href="${city.path}">Rents in ${city.name}</a></p>` : '';
++ if (!city) return null;
++ const size = cities.SIZES.find((s) => s.beds === Math.min(5, Math.max(0, Math.floor(Number(row.bedrooms)))));
++ return size ? { city, size } : null;
++};
++
++function fairOf(row) {
++ const found = listingCity(row);
++ if (!found) return null;
++ const f = cities.fair({ city: found.city, beds: row.bedrooms, rent: row.price });
++ return f ? { ...f, ...found } : null;
++}
++
++/**
++ * The rent check a listing card shows beside its asking rent (outcome 2): the Census median gross rent and HUD's Fair
++ * Market Rent as separate labelled lines, with the metric, the geography and the date each figure carries, and the
++ * source linked. HUD's figure is the area's (a listing has no ZIP here), labelled with the year Sources read it since
++ * the FMR feed carries no fiscal year; the percentile is HUD's 40th of gross rent. Never an average of the two.
++ * → html or '' when neither figure is known, so a card never stands empty.
++ */
++function listingRentCheck(row, { fmr = null } = {}) {
++ const found = listingCity(row);
++ if (!found) return '';
++ const { city, size } = found;
++ const median = city.rent[size.key];
++ const moe = city.moe[size.key];
++ const area = fmr && size.beds <= 4 ? fmr.forCity(city) : null;
++ const amount = area ? area.rents[size.key] : null;
++ if (median == null && amount == null) return '';
++ const read = area ? dayText(area.retrieved_at) : null;
++ const readYear = read ? read.slice(-4) : null;
++ return html`<section class="rent-check" aria-labelledby="h-rent-check"><h2 id="h-rent-check">What rent costs here</h2>
++<dl class="rent-lines">
++${median != null ? html`<div class="rent-line"><dt>Census median gross rent</dt><dd><b>${cities.rentText(median)}</b>${moe && median < 3501 ? html` <span class="moe">±$${moe.toLocaleString('en-US')}</span>` : ''}<span class="rent-line-note">${size.text} in ${city.name}, ${city.state} · <a href="${cities.SOURCE.url}" rel="noopener">ACS ${cities.SOURCE.years}</a></span></dd></div>` : ''}
++${amount != null ? html`<div class="rent-line"><dt>HUD Fair Market Rent${readYear ? html` <span class="rent-line-year">read ${readYear}</span>` : ''}</dt><dd><b>${fmrText(amount)}</b><span class="rent-line-note">${size.text} in the ${area.name} · 40th percentile, gross rent · <a href="${HUD_URL}" rel="noopener">HUD</a>${read ? html` · read ${read}` : ''}</span></dd></div>` : ''}
++</dl>
++${listingFairLine(row, fairOf(row))}
++<p class="small muted rent-never">Asking rent, the Census median and HUD's FMR are separate measures, shown side by side: never averaged.</p></section>`;
}
--module.exports = { mountCityPages, listingFairLine };
++module.exports = { mountCityPages, listingFairLine, listingRentCheck, listingCity };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex af9180f..d6b73f8 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -21,7 +21,7 @@ const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { sameOrigin } = require('./principal');
--const { html, raw, render, table, notice, time, badge } = require('../render/html');
++const { html, raw, render, table, notice, time, dayText, badge } = require('../render/html');
const S = require('../render/short');
const { send } = require('../render/layout');
const listings = require('../listings/listings');
@@@ -32,7 +32,7 @@ const housingPlaces = require('../housing/places');
const housingHpi = require('../housing/hpi');
const everywhere = require('../aggregate/everywhere');
const { mountBoardPages } = require('./board-pages');
--const { mountCityPages, listingFairLine } = require('./city-pages');
++const { mountCityPages, listingRentCheck, listingCity } = require('./city-pages');
const cityList = require('../aggregate/cities');
const home = require('../render/home-blocks');
const { products } = require('openvibe-contracts');
@@@ -43,6 +43,16 @@ const HOME_LATEST = 12;
const STATE_KIND = { active: 'ok', hidden: 'warn', expired: '', removed: 'bad' };
const STATE_TEXT = { active: 'active', hidden: 'hidden — waiting for staff', expired: 'expired', removed: 'removed' };
++// The AI screen's signs, in words a reader knows (server/listings/screen.js names them; never a raw `full_time`-style value).
++const SIGN_TEXT = {
++ payment_before_viewing: 'payment before a viewing',
++ unusual_payment: 'wire, gift card, crypto or a payment app',
++ owner_unavailable: 'an owner who cannot show the place',
++ pressure: 'pressure to decide or pay',
++ personal_data_request: 'identity or bank details up front',
++ off_platform_only: 'only a private channel, no calls or visits',
++};
++const money = (n) => `$${Number(n).toLocaleString('en-US')}`;
// Only from a clear schema.org type do we publish JSON-LD for a listing; "other" is left without one on purpose.
const SCHEMA_TYPE = { apartment: 'Apartment', room: 'Room', house: 'House', commercial: 'Place', parking: 'ParkingFacility', equipment: 'Product' };
@@@ -68,21 +78,39 @@ function createPageRoutes(ctx) {
const whereOf = (row) => [row.neighbourhood, row.city, row.country].filter(Boolean).join(', ');
const signInPrompt = (what, next) => html`<div class="notice">${what} <a href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a>.</div>`;
++ /** HUD's Fair Market Rent for a listing's city and size (the card's short line), or null. */
++ const fmrForRow = (row) => {
++ if (!ctx.fmr) return null;
++ const found = listingCity(row);
++ if (!found || found.size.beds > 4) return null;
++ const area = ctx.fmr.forCity(found.city);
++ const amount = area && area.rents[found.size.key];
++ if (amount == null) return null;
++ return { amount, area, size: found.size, read: dayText(area.retrieved_at) };
++ };
++
++ /** The date of a listing's latest check, and whether it found signs (only what the check really says). */
++ const checkOf = (check) => (check ? { flagged: check.action === 'review', date: dayText(check.checked_at) } : null);
++
/** One listing in a list, as a card: what kind it is at a glance, the title, where, the price on the right. */
const KIND_MARK = { apartment: 'Apt', room: 'Room', house: 'House', commercial: 'Work', parking: 'Park', equipment: 'Gear', other: 'More' };
-- const listingItem = (row) => html`<li class="listing kind-${listings.KINDS.includes(row.kind) ? row.kind : 'other'}">
++ const listingItem = (row, check = null, canReport = false) => {
++ const f = fmrForRow(row);
++ const c = checkOf(check);
++ return html`<li class="listing kind-${listings.KINDS.includes(row.kind) ? row.kind : 'other'}">
<span class="listing-mark" aria-hidden="true">${KIND_MARK[row.kind] || 'More'}</span>
<span class="listing-main">
<a class="listing-title" href="/listings/${row.id}">${row.title}</a>
<span class="listing-where">${whereOf(row)}</span>
--<span class="listing-chips"><span class="chip">${listings.KIND_TEXT[row.kind] || row.kind}</span>${row.bedrooms == null ? '' : html`<span class="chip">${row.bedrooms} bedroom${Number(row.bedrooms) === 1 ? '' : 's'}</span>`}${row.available_from ? html`<span class="chip">from ${row.available_from}</span>` : ''}<span class="listing-age">posted ${time(row.created_at)}</span></span>
++<span class="listing-chips"><span class="chip">${listings.KIND_TEXT[row.kind] || row.kind}</span>${row.bedrooms == null ? '' : html`<span class="chip">${row.bedrooms} bedroom${Number(row.bedrooms) === 1 ? '' : 's'}</span>`}${row.available_from ? html`<span class="chip">from ${row.available_from}</span>` : ''}${f ? html`<span class="chip">HUD FMR ${money(f.amount)}</span>` : ''}${c && c.date ? html`<span class="chip">${c.flagged ? 'Flagged' : 'Checked'} ${c.date}</span>` : ''}<span class="listing-age">posted ${time(row.created_at)}</span></span>
</span>
--<span class="listing-price">${priceOf(row)}</span>
++<div class="listing-price">${priceOf(row)}${canReport ? html`<form class="listing-report-mini" method="post" action="/listings/${row.id}/reports"><input type="hidden" name="reason" value="scam"><button class="chip" type="submit" aria-label="Report this listing" title="Report this listing to OpenVibe staff">Report</button></form>` : ''}</div>
</li>`;
++ };
/** `empty` may be a plain string (escaped) or an `html` value (a link, say). */
-- const listingList = (rows, { empty = 'Nothing here yet.' } = {}) => (rows.length
-- ? html`<ul class="listing-list">${rows.map(listingItem)}</ul>`
++ const listingList = (rows, { empty = 'Nothing here yet.', checks = new Map(), canReport = null } = {}) => (rows.length
++ ? html`<ul class="listing-list">${rows.map((row) => listingItem(row, checks.get(row.id), Boolean(canReport && canReport(row))))}</ul>`
: html`<p class="muted">${empty}</p>`);
/** The search box: one GET form, the same fields /listings and a saved search use. */
@@@ -170,12 +198,16 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
const amount = Number.isFinite(n) ? n.toLocaleString('en-US', { maximumFractionDigits: Number.isInteger(n) ? 0 : 2 }) : String(row.price);
return `${amount} ${row.currency}${PERIOD_SHORT[row.period] == null ? '' : PERIOD_SHORT[row.period]}`;
};
-- /** One listing in a rail: the price in its corner, the city and kind on a line under the title (kit.card sub), the
-- * full place on hover. */
-- const listingCard = (row) => kit.card({
-- href: `/listings/${row.id}`, title: row.title, type: 'listing', ratio: '4/3', share: false,
-- sub: [row.city, listings.KIND_TEXT[row.kind] || row.kind].filter(Boolean).join(' · '), corner: priceShort(row), peek: whereOf(row), when: row.created_at,
-- });
++ /** One listing in a rail: the price in its corner, HUD's benchmark as the badge above it, the city and kind under
++ * the title, the full place on hover. */
++ const listingCard = (row) => {
++ const f = fmrForRow(row);
++ return kit.card({
++ href: `/listings/${row.id}`, title: row.title, type: 'listing', ratio: '4/3', share: false,
++ sub: [row.city, listings.KIND_TEXT[row.kind] || row.kind].filter(Boolean).join(' · '),
++ badge: f ? `HUD FMR ${money(f.amount)}` : '', corner: priceShort(row), peek: whereOf(row), when: row.created_at,
++ });
++ };
/**
* The home on a phone (the owner's short-pages rule, 2026-10-30): the parts a reader rarely needs — the questions
* and the other OpenVibe places — are folds, closed on a phone and open where there is room. Both stay in the page.
@@@ -518,6 +550,7 @@ ${entries.join('\n')}
const before = listings.isListingId(req.query.before) ? String(req.query.before) : null;
const out = await store.search(s, filters, { before });
const facets = await store.facets(s, filters);
++ const checks = await store.checksFor(s, out.rows.map((row) => row.id));
const qs = listings.filtersToQuery(filters);
const more = (extra) => `/listings?${new URLSearchParams({ ...Object.fromEntries(new URLSearchParams(qs)), ...extra }).toString()}`;
const facetLink = (dim, value) => `/listings?${new URLSearchParams({ ...Object.fromEntries(new URLSearchParams(qs)), [dim]: value }).toString()}`;
@@@ -527,6 +560,7 @@ ${entries.join('\n')}
title: 'Listings',
description: 'Search apartments, rooms, houses, commercial space, parking and storage, and equipment for rent — posted by people on OpenVibe.Rent.',
crumbs: [{ label: 'Home', href: '/' }, { label: 'Listings' }],
++ styles: [showcase.STYLESHEET],
body: html`<h1>Listings</h1>
<p class="sc-lede">Active listings, newest first. ${out.rows.length} on this page.</p>
${searchForm(filters)}
@@@ -538,7 +572,7 @@ ${qs ? html`<div class="small"><a href="/listings">Clear all filters</a>${signed
<input type="hidden" name="q" value="${filters.q || ''}"><input type="hidden" name="kind" value="${filters.kind || ''}"><input type="hidden" name="city" value="${filters.city || ''}"><input type="hidden" name="country" value="${filters.country || ''}"><input type="hidden" name="period" value="${filters.period || ''}"><input type="hidden" name="min" value="${filters.min == null ? '' : filters.min}"><input type="hidden" name="max" value="${filters.max == null ? '' : filters.max}">
<button class="sc-btn" type="submit">Save this search</button></form>` : ''}</div>` : ''}
<p class="listing-alert"><a class="sc-btn" href="${alertHref(filters)}">Get alerts for this search</a> <span class="small muted">OpenVibe.Watch follows <a href="${feedPath(filters)}">its feed</a> and tells you when a new listing appears.</span></p>
--${listingList(out.rows, { empty: html`No listing matches those filters. <a href="/listings">Clear them</a> and try again.` })}
++${listingList(out.rows, { empty: html`No listing matches those filters. <a href="/listings">Clear them</a> and try again.`, checks, canReport: (row) => signedIn(req) && row.owner !== requesterOf(req) && row.state === 'active' })}
${out.next ? html`<p><a href="${more({ before: out.next })}">Older listings</a>${before ? html` · <a href="/listings${qs ? `?${qs}` : ''}">Newest first</a>` : ''}</p>` : ''}
</section>`,
});
@@@ -571,16 +605,39 @@ ${values.slice(0, 24).map((v) => html`<a href="${link(dim, v.value)}" class="bad
};
}
++ /** The detailed report form, folded under the one-tap button: a reason and a note for anything the button misses. */
const reportForm = (row) => html`<form class="card report-form" method="post" action="/listings/${row.id}/reports">
--<fieldset><legend>Report this listing</legend>
++<fieldset><legend>Tell us what is wrong</legend>
<div class="field"><label for="reason">Why?</label><select id="reason" name="reason">
${listings.REASONS.map((x) => html`<option value="${x}">${x === 'scam' ? 'It looks like a scam' : x === 'wrong' ? 'The details are wrong' : x === 'offensive' ? 'The content is offensive' : 'Something else'}</option>`)}
</select></div>
<div class="field"><label for="note">Anything else? (optional)</label><textarea id="note" name="note" rows="3" maxlength="${listings.LIMITS.note}"></textarea></div>
--<button class="sc-btn" type="submit">Report it</button></fieldset>
--<p class="small muted">Three different people reporting a listing hide it from search until OpenVibe staff look at it.</p>
++<button class="sc-btn" type="submit">Send the report</button></fieldset>
</form>`;
++ /** The latest AI screen of a listing, in the words a renter knows, with the date and the quoted words behind a flag. */
++ const checkLine = (check) => {
++ if (!check) return html`<p class="listing-check"><b>Our listing check</b> has not read this listing yet.</p>`;
++ const when = dayText(check.checked_at);
++ const date = when || 'an unrecorded date';
++ if (check.action !== 'review') {
++ return html`<p class="listing-check check-ok"><b>Our listing check</b> read this on ${date}: no scam signs found.</p>`;
++ }
++ const spans = listOf(check.spans).filter((x) => x && x.quote).slice(0, 4);
++ const signals = [...new Set(listOf(check.signals).map((x) => SIGN_TEXT[x]).filter(Boolean))];
++ const reviewed = dayText(check.reviewed_at);
++ const found = signals.length ? `${signals.length === 1 ? 'this sign' : 'these signs'} of a scam: ${signals.join('; ')}` : 'signs of a scam';
++ return html`<div class="listing-check check-warn"><p><b>Our listing check</b> read this on ${date} and found ${found}.</p>
++${spans.length ? html`<ul class="check-quotes">${spans.map((x) => html`<li>“${truncate(x.quote, 200)}”${x.signal && SIGN_TEXT[x.signal] ? html` <span class="muted">— ${SIGN_TEXT[x.signal]}</span>` : ''}</li>`)}</ul>` : ''}
++<p class="small muted">A flag asks <a href="/staff">OpenVibe staff</a> to look; it is not a verdict, and the listing stays up until they decide.${reviewed ? html` Staff looked at it on ${reviewed} and left it up.` : ''}</p></div>`;
++ };
++
++ /** Outcome 5: one tap to the staff report queue, the named path, and the plain line about money. */
++ const oneTapReport = (row) => html`<section class="listing-report" aria-labelledby="h-report"><h2 id="h-report">Something wrong?</h2>
++<form method="post" action="/listings/${row.id}/reports"><input type="hidden" name="reason" value="scam"><button class="sc-btn sc-primary" type="submit">Report this listing</button></form>
++<p class="small muted">One tap sends it to <a href="/staff">OpenVibe staff's report queue</a>: ${listings.HIDE_AT_REPORTS} different reports hide the listing until they look.</p>
++<details class="listing-report-more"><summary>Something else is wrong</summary>${reportForm(row)}</details></section>`;
++
r.get('/listings/:id', async (req, res) => {
const row = listings.isListingId(req.params.id) ? await store.get(s, req.params.id) : null;
if (!canSee(req, row)) {
@@@ -592,21 +649,25 @@ ${listings.REASONS.map((x) => html`<option value="${x}">${x === 'scam' ? 'It loo
}
const isMine = row.owner === requesterOf(req);
const contact = row.contact_url || null;
++ const check = await store.getCheck(s, row.id);
return page(req, res, {
index: row.state === 'active', cache: (row.state === 'active' && !signedIn(req)) ? PUBLIC_CACHE : null,
title: truncate(row.title, 70),
description: truncate(`${listings.KIND_TEXT[row.kind] || row.kind} in ${whereOf(row)} — ${row.description}`, 160),
crumbs: [{ label: 'Home', href: '/' }, { label: 'Listings', href: '/listings' }, { label: truncate(row.title, 60) }],
jsonLd: [listingJsonLd(row), seo.jsonLd.breadcrumbs([{ name: SITE_NAME, url: `${base()}/` }, { name: 'Listings', url: `${base()}/listings` }, { name: row.title, url: `${base()}/listings/${row.id}` }])],
-- localStyles: ['css/cities.css'],
++ styles: [showcase.STYLESHEET], localStyles: ['css/cities.css'],
body: html`<article class="listing-page">
<h1>${row.title}</h1>
--<p class="listing-facts">${stateBadge(row.state)} <span class="badge">${listings.KIND_TEXT[row.kind] || row.kind}</span> ${priceOf(row)}</p>
++<p class="listing-facts">${stateBadge(row.state)} <span class="badge">${listings.KIND_TEXT[row.kind] || row.kind}</span></p>
++<p class="listing-asking"><span class="listing-asking-label">Asking rent</span> ${priceOf(row)}</p>
<p class="where"><b>${whereOf(row)}</b>${row.region ? html` <span class="muted small">(${row.region})</span>` : ''}${row.neighbourhood ? '' : html` <span class="muted small">— the neighbourhood is not given</span>`}</p>
<p class="facts small muted">${row.bedrooms == null ? '' : html`${row.bedrooms} bedroom${Number(row.bedrooms) === 1 ? '' : 's'} · `}${row.available_from ? html`available from ${row.available_from} · ` : ''}posted ${time(row.created_at)} · ${row.state === 'expired' ? 'expired' : 'expires'} ${time(row.expires_at)}</p>
--${listingFairLine(row)}
++${listingRentCheck(row, { fmr: ctx.fmr })}
++${checkLine(check)}
${notice(listings.SAFETY, 'warn')}
++<p class="small muted listing-nopay">We take no payment from renter or landlord: posting and searching are free, and rent and deposits stay between you two.</p>
<section aria-labelledby="h-about"><h2 id="h-about">About this listing</h2>${textBlock(row.description)}</section>
@@@ -630,8 +691,8 @@ ${isMine ? html`<section class="sc-sec" aria-labelledby="h-own"><h2 id="h-own">Y
${staffViewer(req) ? html`<section class="sc-sec" aria-labelledby="h-staff"><h2 id="h-staff">Staff</h2>
<p class="small muted">${row.report_count} report${Number(row.report_count) === 1 ? '' : 's'}. <a href="/staff">The report queue</a>.</p></section>` : ''}
--${signedIn(req) && !isMine && row.state === 'active' ? reportForm(row) : ''}
--${!signedIn(req) ? html`<div class="notice">Anyone signed in can <a href="/auth/login?next=${encodeURIComponent(`/listings/${row.id}`)}">report this listing</a>. Three different reports hide it until OpenVibe staff look at it.</div>` : ''}
++${signedIn(req) && !isMine && row.state === 'active' ? oneTapReport(row) : ''}
++${!signedIn(req) ? html`<div class="notice">Anyone signed in can <a href="/auth/login?next=${encodeURIComponent(`/listings/${row.id}`)}">report this listing</a>. ${listings.HIDE_AT_REPORTS} different reports hide it until OpenVibe staff look at it.</div>` : ''}
</article>`,
});
});
@@@ -926,15 +987,6 @@ ${it.newCount ? html` <span class="badge info">${it.newCount} new</span>` : html
});
// ── The report queue (staff) ─────────────────────────────
-- // The AI screen's signs, in staff's words (server/listings/screen.js names them).
-- const SIGN_TEXT = {
-- payment_before_viewing: 'payment before a viewing',
-- unusual_payment: 'wire, gift card, crypto or a payment app',
-- owner_unavailable: 'an owner who cannot show the place',
-- pressure: 'pressure to decide or pay',
-- personal_data_request: 'identity or bank details up front',
-- off_platform_only: 'only a private channel, no calls or visits',
-- };
const listOf = (v) => (Array.isArray(v) ? v : (() => { try { return JSON.parse(v || '[]'); } catch { return []; } })());
r.get('/staff', async (req, res) => {
ddiff --git a/server/listings/store.js b/server/listings/store.js
iindex 13b952c..d583063 100644
---- a/server/listings/store.js
++++ b/server/listings/store.js
@@@ -193,6 +193,14 @@ const staffCounts = async (s) => ({
const getCheck = (s, listingId) => s.db.maybe('SELECT * FROM rent_listing_checks WHERE listing_id = $1', [listingId]);
++/** The latest check of each of several listings, by id (a list page reads them in one query). */
++async function checksFor(s, listingIds) {
++ const ids = [...new Set((listingIds || []).filter(Boolean).map(String))];
++ if (!ids.length) return new Map();
++ const rows = await s.db.many('SELECT * FROM rent_listing_checks WHERE listing_id = ANY($1)', [ids]);
++ return new Map(rows.map((row) => [row.listing_id, row]));
++}
++
/** Record a listing's latest screen. A verdict on new words is unreviewed again, whatever staff said before. */
async function putCheck(s, { listingId, contentHash, action, signals = [], spans = [], rationale = '', runId = null }) {
await s.db.query(
@@@ -244,7 +252,7 @@ module.exports = {
insert, get, search, countMatching, facets, update, expireListings, renew, setState, remove,
countActiveByOwner, countCreatedToday, listByOwner,
reportsFor, addReport, staffQueue, staffCounts,
-- getCheck, putCheck, recheckUnchanged, unscreenedActive, flaggedQueue, countFlagged, markCheckReviewed,
++ getCheck, putCheck, recheckUnchanged, unscreenedActive, flaggedQueue, countFlagged, markCheckReviewed, checksFor,
saveSearch, listSaved, getSaved, deleteSaved, markSavedSeen,
DAY_MS,
};
ddiff --git a/server/render/html.js b/server/render/html.js
iindex c0f69d9..1a02d93 100644
---- a/server/render/html.js
++++ b/server/render/html.js
@@@ -22,7 +22,20 @@ function render(v) {
}
const code = (s) => html`<code>${s}</code>`;
--const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');
++const MONTHS_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
++/** An ISO timestamp as "31 Oct 2026" (UTC), or null when it is not a real date (1970 is "not given"). */
++function dayText(iso) {
++ const t = Date.parse(String(iso == null ? '' : iso));
++ if (!Number.isFinite(t) || t <= 0 || String(iso).startsWith('1970-01-01')) return null;
++ const d = new Date(t);
++ return `${d.getUTCDate()} ${MONTHS_SHORT[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
++}
++/** A date a reader reads ("31 Oct 2026"), never the raw ISO string; `datetime` keeps the machine value. */
++const time = (iso) => {
++ if (!iso) return '—';
++ const day = dayText(iso);
++ return html`<time datetime="${iso}">${day || String(iso)}</time>`;
++};
/** A <table> from headers and rows of cells (cells are already html or plain values). */
function table(headers, rows, { cls = '', empty = 'Nothing here yet.' } = {}) {
@@@ -42,4 +55,4 @@ const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="s
const csrfField = (token) => html`<input type="hidden" name="csrf" value="${token}">`;
const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;
--module.exports = { esc, html, raw, Raw, render, code, time, table, problemBox, notice, csrfField, badge };
++module.exports = { esc, html, raw, Raw, render, code, time, dayText, table, problemBox, notice, csrfField, badge };
ddiff --git a/test/fair-market-rents.test.js b/test/fair-market-rents.test.js
iindex 46862d2..b85a5d5 100644
---- a/test/fair-market-rents.test.js
++++ b/test/fair-market-rents.test.js
@@@ -74,12 +74,15 @@ function feed(items) {
await check('a city page shows HUD\'s rents beside the medians and says what they are', async () => {
const page = await t.get('/in/ny/new-york');
-- assert.ok(page.text.includes('HUD fair market'));
++ assert.ok(page.text.includes('HUD FMR'));
assert.ok(page.text.includes('$2,910'));
assert.ok(page.text.includes('New York, NY HUD Metro FMR Area'));
assert.ok(page.text.includes('Housing Choice Vouchers'));
++ assert.ok(page.text.includes('40th percentile'), 'the percentile HUD states');
++ assert.ok(page.text.includes('read 22 Oct 2026'), 'the year and date the figure carries');
++ assert.ok(page.text.includes('never averaged'), 'the two measures are shown side by side');
const la = await t.get('/in/ca/los-angeles');
-- assert.ok(!la.text.includes('HUD fair market'), 'an area Sources has not given keeps the plain table');
++ assert.ok(!la.text.includes('HUD FMR'), 'an area Sources has not given keeps the plain table');
});
await check('/fair names HUD\'s Fair Market Rent for that size', async () => {
ddiff --git a/test/market-outcomes.test.js b/test/market-outcomes.test.js
nnew file mode 100644
iindex 0000000..e67665e
---- /dev/null
++++ b/test/market-outcomes.test.js
@@@ -0,0 +1,169 @@
++'use strict';
++/**
++ * The market rebuild's outcomes (research-2026-10-30/market/outcomes/rent.md):
++ * 1. HUD's Fair Market Rent beside the asking rent, labelled with its year, percentile and source;
++ * 2. asking rent, the Census median and HUD's FMR as separate labelled lines, never averaged;
++ * 3. month on month and year on year as two fields on the city page's BLS trend;
++ * 4. the scam check with its date, and the words behind a flag in a reader's words;
++ * 5. a one-tap report to the staff queue, and the line that we take no payment from either side.
++ * Outcome 6 (days-on-market and concessions) needs a fetch we do not have: nothing here claims it.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { SAME, post } = require('./helpers/rent');
++const store = require('../server/listings/store');
++const cities = require('../server/aggregate/cities');
++const AREAS = require('../server/data/city-fmr-areas.json');
++
++const SCAM = 'I am abroad for work so I cannot show it. Send the deposit by wire transfer today and I will mail you the keys.';
++const sd = cities.match('San Diego, CA');
++
++const fmrItem = (seq, code, name, rents) => ({
++ id: `itm_${seq}`, source_key: 'hud-fair-market-rents', category: 'housing', kind: 'rent_area', identity: code,
++ canonical_url: null, title: name, summary: null,
++ fields: { fmr_0br: rents[0], fmr_1br: rents[1], fmr_2br: rents[2], fmr_3br: rents[3], fmr_4br: rents[4] },
++ revision: 1, provenance: { retrieved_at: '2026-10-22T10:00:00.000Z' }, removed: null, change_seq: seq,
++});
++const cpiItem = (seq, year, period, value) => ({
++ id: `cpi_${seq}`, source_key: 'bls-cpi-rent', category: 'housing', kind: 'rent_cpi', identity: `CUUR0000SEHA-${year}${period}`,
++ canonical_url: null, title: 'CUUR0000SEHA', summary: null,
++ fields: { series_id: 'CUUR0000SEHA', year: String(year), period, value: String(value) },
++ revision: 1, provenance: { retrieved_at: '2026-10-30T06:00:00.000Z' }, removed: null, change_seq: seq,
++});
++function feed(items) {
++ return {
++ items,
++ async listItems({ after = 0, limit = 100 }) {
++ const rest = items.filter((x) => x.change_seq > after).sort((a, b) => a.change_seq - b.change_seq);
++ const page = rest.slice(0, limit);
++ return { items: page, next_after: page.length ? page[page.length - 1].change_seq : after, more: rest.length > limit, sources: {} };
++ },
++ };
++}
++
++(async () => {
++ const ai = {
++ run: async (key, input) => ({
++ runId: 'run_market',
++ output: /wire transfer/.test(input.listing.description)
++ ? {
++ action: 'review', rationale: 'Payment by wire before a viewing.',
++ signals: ['payment_before_viewing', 'unusual_payment', 'owner_unavailable', 'made_up'],
++ spans: [
++ { quote: 'Send the deposit by wire transfer today', signal: 'payment_before_viewing' },
++ { quote: 'by wire transfer', signal: 'unusual_payment' },
++ { quote: 'I am abroad for work so I cannot show it.', signal: 'owner_unavailable' },
++ { quote: 'Pay in bitcoin within the hour', signal: 'payment_before_viewing' },
++ ],
++ }
++ : { action: 'allow', signals: [], spans: [], rationale: '' },
++ }),
++ };
++ const t = await boot({
++ ai,
++ fmrSource: feed([fmrItem(1, AREAS.areas[sd.id], 'San Diego-Chula Vista-Carlsbad, CA MSA', [2288, 2459, 3001, 3998, 4845])]),
++ rentCpiSource: feed([cpiItem(1, 2025, 'M09', 400), cpiItem(2, 2026, 'M08', 419), cpiItem(3, 2026, 'M09', 420)]),
++ });
++ const kim = t.network.addUser('kim');
++ const lee = t.network.addUser('lee');
++ let clean;
++ let scam;
++
++ try {
++ await t.ctx.fmr.pull();
++ await t.ctx.rentCpi.pull();
++
++ await check('a US listing shows the asking rent, the Census median and HUD\'s FMR as separate labelled lines', async () => {
++ clean = (await post(t, kim, { title: 'Two-bed near Balboa Park', city: 'San Diego', region: 'California', country: 'US', currency: 'USD', price: 2400, bedrooms: 2, neighbourhood: 'North Park' })).json();
++ const page = await t.get(`/listings/${clean.id}`, { as: lee });
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('<span class="listing-asking-label">Asking rent</span>'), 'the asking rent is labelled');
++ assert.strictEqual((page.text.match(/class="rent-line"/g) || []).length, 2, 'the two official lines, each its own row');
++ assert.ok(page.text.includes('Census median gross rent') && page.text.includes('$2,442') && page.text.includes('ACS 2020–2024'), 'the Census median with its metric and years');
++ assert.ok(page.text.includes('HUD Fair Market Rent') && page.text.includes('$3,001'), 'HUD\'s figure for the 2-bedroom');
++ assert.ok(page.text.includes('40th percentile') && page.text.includes('gross rent'), 'the percentile and what it measures');
++ assert.ok(page.text.includes('read 2026') && page.text.includes('read 22 Oct 2026'), 'the year and the date the figure carries');
++ assert.ok(page.text.includes('https://www.huduser.gov/portal/datasets/fmr.html'), 'the HUD source link');
++ assert.ok(page.text.includes('never averaged'), 'the page says the measures are not mixed');
++ assert.ok(!page.text.includes('2,721'), 'nothing is the average of the median and the FMR');
++ const shown = page.text.replace(/<[^>]*>/g, '');
++ assert.match(shown, /posted \d{1,2} [A-Z][a-z]{2} \d{4} · expires \d{1,2} [A-Z][a-z]{2} \d{4}/, 'dates in words');
++ assert.ok(!page.text.includes('UTC'), 'never a raw UTC timestamp');
++ });
++
++ await check('listing cards carry HUD\'s benchmark and the dated check; the home rail too', async () => {
++ await t.ctx.screen.settle();
++ const checkDate = (await store.getCheck(t.ctx.s, clean.id)).checked_at;
++ const r = await t.get('/listings?city=San%20Diego', { as: lee });
++ assert.ok(r.text.includes('HUD FMR $3,001'), 'the card carries the benchmark');
++ assert.match(r.text, new RegExp(`Checked ${new Date(checkDate).getUTCDate()} `), 'the card carries the check date');
++ assert.match(r.text.replace(/<[^>]*>/g, ''), /posted \d{1,2} [A-Z][a-z]{2} \d{4}/, 'the card posts a date in words');
++ assert.ok(!r.text.includes('UTC'), 'no raw UTC timestamp on the listings page');
++ assert.ok(r.text.includes(`<form class="listing-report-mini" method="post" action="/listings/${clean.id}/reports">`), 'every card can be reported in one tap');
++ const home = await t.get('/');
++ const rail = (/<section class="ovk-rail[^"]*" id="latest"[\s\S]*?<\/section>/.exec(home.text) || [''])[0];
++ assert.ok(rail.includes('HUD FMR $3,001'), 'the home card carries the benchmark');
++ });
++
++ await check('the city page labels HUD\'s figure with percentile, year and source, beside the Census median', async () => {
++ const page = await t.get(sd.path);
++ assert.ok(page.text.includes('Census median') && page.text.includes('HUD FMR'), 'the two columns are named');
++ assert.ok(page.text.includes('40th percentile') && page.text.includes('read 22 Oct 2026'), 'percentile and the year/date');
++ assert.ok(page.text.includes('https://www.huduser.gov/portal/datasets/fmr.html'), 'the source link');
++ assert.ok(page.text.includes('never averaged'), 'the page says they are separate measures');
++ assert.ok(!page.text.includes('UTC'), 'no raw UTC timestamp on the city page');
++ });
++
++ await check('month on month and year on year are two fields, each with the months it compares', async () => {
++ const page = await t.get(sd.path);
++ assert.strictEqual((page.text.match(/class="rent-move"/g) || []).length, 2, 'two fields, not one combined number');
++ assert.ok(page.text.includes('Month on month') && page.text.includes('Year on year'));
++ assert.ok(page.text.includes('Month on month</span><strong class="rent-move-up">+0.2%'), 'the month-on-month change');
++ assert.ok(page.text.includes('Year on year</span><strong class="rent-move-up">+5.0%'), 'the year-on-year change');
++ assert.ok(page.text.includes('September 2026 vs August 2026') && page.text.includes('September 2026 vs September 2025'), 'the months each compares');
++ assert.ok(!page.text.includes('days on market') && !page.text.includes('concession'), 'outcome 6 is not claimed: we do not fetch it');
++ });
++
++ await check('a flagged listing says what the check saw, in quoted words and a reader\'s words, with its date', async () => {
++ scam = (await post(t, kim, { title: 'Room in the centre', description: SCAM, city: 'San Diego', region: 'California', country: 'US', currency: 'USD', price: 1500, bedrooms: 2 })).json();
++ await t.ctx.screen.settle();
++ await t.ctx.screen.settle();
++ const c = await store.getCheck(t.ctx.s, scam.id);
++ assert.strictEqual(c.action, 'review');
++ const page = await t.get(`/listings/${scam.id}`, { as: lee });
++ const date = new Date(c.checked_at);
++ const stamp = `${date.getUTCDate()} ${['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][date.getUTCMonth()]} ${date.getUTCFullYear()}`;
++ assert.ok(page.text.includes('Our listing check') && page.text.includes(stamp), 'the check and its date');
++ assert.ok(page.text.includes('payment before a viewing'), 'the sign in a reader\'s words');
++ assert.ok(page.text.includes('Send the deposit by wire transfer today'), 'the quoted words behind the sign');
++ assert.ok(!page.text.includes('payment_before_viewing') && !page.text.includes('made_up'), 'never a raw signal name');
++ assert.ok(page.text.includes('not a verdict'), 'a flag is not a verdict');
++ const cleanPage = await t.get(`/listings/${clean.id}`, { as: lee });
++ assert.ok(cleanPage.text.includes('no scam signs found'), 'a clean check says so');
++ });
++
++ await check('one tap reports to the staff queue, and the page says we take no payment from either side', async () => {
++ const page = await t.get(`/listings/${scam.id}`, { as: lee });
++ assert.ok(page.text.includes(`<form method="post" action="/listings/${scam.id}/reports"><input type="hidden" name="reason" value="scam"><button class="sc-btn sc-primary" type="submit">Report this listing</button></form>`), 'one button, one hidden reason');
++ assert.ok(page.text.includes('OpenVibe staff&#39;s report queue') || page.text.includes('OpenVibe staff\'s report queue'), 'the named escalation path');
++ assert.ok(page.text.includes('We take no payment from renter or landlord'), 'the no-payment line');
++ assert.ok(page.text.includes('3 different reports hide the listing'), 'the escalation count comes from the listing rules');
++ const out = await t.get(`/listings/${scam.id}/reports`, { as: lee, method: 'POST', form: { reason: 'scam' }, headers: SAME });
++ assert.strictEqual(out.status, 303);
++ assert.strictEqual(Number((await store.get(t.ctx.s, scam.id)).report_count), 1);
++ const signedOut = await t.get(`/listings/${scam.id}`);
++ assert.ok(signedOut.text.includes('We take no payment from renter or landlord'), 'the line is on a signed-out page too');
++ });
++
++ await check('a listing outside the US gets no HUD block, and no figure is made up for it', async () => {
++ const lisbon = (await post(t, kim, { title: 'A room in Lisbon' })).json();
++ const page = await t.get(`/listings/${lisbon.id}`, { as: lee });
++ assert.ok(!page.text.includes('HUD Fair Market Rent'), 'no HUD figure for a non-US listing');
++ assert.ok(!page.text.includes('Census median gross rent'), 'no Census figure either');
++ assert.ok(page.text.includes('Asking rent'), 'the asking rent still shows');
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/rent-cpi.test.js b/test/rent-cpi.test.js
iindex ba62b37..43d9b68 100644
---- a/test/rent-cpi.test.js
++++ b/test/rent-cpi.test.js
@@@ -82,13 +82,16 @@ function feed(items) {
const page = await t.get(ny.path);
assert.strictEqual(page.status, 200);
assert.ok(page.text.includes('Are rents rising?'));
-- assert.ok(page.text.includes('Rents in New York-Newark-Jersey City, NY-NJ-PA rose 4.0% in the last year (BLS CPI, September 2026).'));
-- assert.ok(page.text.includes('Rents in US city average rose 5.0% in the last year (BLS CPI, September 2026).'));
++ assert.ok(page.text.includes('Month on month') && page.text.includes('Year on year'), 'two separate fields');
++ assert.ok(page.text.includes('Year on year</span><strong class="rent-move-up">+5.0%'), 'the national year on year');
++ assert.ok(page.text.includes('Month on month</span><strong class="rent-move-up">+0.2%'), 'the national month on month');
++ assert.ok(page.text.includes('September 2026 vs September 2025'), 'the year-on-year months');
++ assert.ok(page.text.includes('September 2026 vs August 2026'), 'the month-on-month months');
assert.ok(page.text.includes('BLS series CUURS12ASEHA'));
assert.ok(page.text.includes('<svg class="rent-trend"'), 'a trend line');
assert.ok(page.text.includes('BLS.gov cannot vouch'));
const boisePage = await t.get(boise.path);
-- assert.ok(boisePage.text.includes('Rents in US city average rose 5.0%'));
++ assert.ok(boisePage.text.includes('Rent of primary residence CPI, US city average') && boisePage.text.includes('+5.0%'));
assert.ok(!boisePage.text.includes('CUURS12ASEHA'), 'no metro line where BLS has none');
const all = await t.get('/in');
assert.ok(all.text.includes('Are rents rising?') && all.text.includes('BLS series CUUR0000SEHA'), 'the national line is on /in too');