openvibers/openvibe.rent Commits

Homes: each figure names its source in its own card; nobody pays us for the links (market outcomes) (#168)

OP OpenVibers committed 12 days ago · a87d212695 · 4 files, +18 −5

A city page's median home value and typical rent now carry their source, years and what the
figure is (the ± is the Census 90% margin of error; rent is rent plus utilities) inside their own
card, not in a footnote under them (Homes outcome 5). The sites section and the home page say in
one line that nobody pays us for these links and we sell no leads (outcome 3): the outbound links
carry no affiliate or referral tags.

Changes

ddiff --git a/public/css/cities.css b/public/css/cities.css
iindex a087c1e..b0d1a00 100644
---- a/public/css/cities.css
++++ b/public/css/cities.css
@@@ -90,3 +90,9 @@
.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; }
++/* A figure's source in its own card (market outcome 5), and the one line on who pays us (outcome 3) */
++.fig-src { margin: 10px 0 0; padding-top: 8px; border-top: 1px dashed var(--border, #1f2d47); font-size: .78rem; line-height: 1.45; color: var(--text-muted, #7386a3); }
++.fig-src a { color: var(--text-secondary, #96a7c2); }
++.homes-nofee { margin: 14px 0 0; padding: 12px 14px; border-radius: 12px; border: 1px solid color-mix(in srgb, var(--success, #22c55e) 35%, var(--border, #1f2d47));
++ background: color-mix(in srgb, var(--success, #22c55e) 7%, transparent); font-size: .9rem; line-height: 1.5; color: var(--text-secondary, #96a7c2); }
++.homes-nofee b { color: var(--text-primary, #e8eef8); }
ddiff --git a/server/homes/city-pages.js b/server/homes/city-pages.js
iindex 6dfa2c8..90a1b47 100644
---- a/server/homes/city-pages.js
++++ b/server/homes/city-pages.js
@@@ -28,6 +28,8 @@ const valueCell = (c) => {
return v ? html`${v}${c.valueMoe && c.value < 2000001 ? html` <span class="moe">±$${c.valueMoe.toLocaleString('en-US')}</span>` : ''}` : html`<span class="muted">not published</span>`;
};
const source = () => html`<p class="small muted city-source">Median value of owner-occupied homes (the owners' own estimate of what their home would sell for), and median gross rent. <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>`;
++/** A figure's source in the figure's own card, never a footnote (market outcome 5): who, which table, which years. */
++const sourceLine = (what) => html`<p class="fig-src"><a href="${cities.SOURCE.url}" rel="noopener">${cities.SOURCE.name.replace('U.S. Census Bureau, ', 'Census ')}</a>, ${cities.SOURCE.years}${what ? html` · ${what}` : ''}</p>`;
const pct = (n) => { const x = Math.round(n * 10) / 10; return `${x > 0 ? '+' : ''}${x.toFixed(1)} %`; };
function mountHomesCityPages(r, { config, s, page }) {
@@@ -100,17 +102,16 @@ ${source()}
body: html`<h1>Buying a home in ${where}</h1>
<p class="sc-lede">Every for-sale site's search for ${c.name} in one place, what homes here are worth, and what renting costs instead.</p>
<div class="homes-cards city-homes-cards">
--<aside class="homes-card value-card"><h3>Median home value</h3><p class="value-num">${valueCell(c)}</p><p class="small muted">Half of the owner-occupied homes in ${c.name} are worth less, half more, by their owners' estimate.</p></aside>
++<aside class="homes-card value-card"><h3>Median home value</h3><p class="value-num">${valueCell(c)}</p><p class="small muted">Half of the owner-occupied homes in ${c.name} are worth less, half more, by their owners' own estimate.</p>${sourceLine(c.valueMoe ? 'the ± is its 90% margin of error' : 'table B25077')}</aside>
${prices ? html`<aside class="hpi-card"><h3 class="hpi-title">Home prices around ${prices.place}</h3><div class="hpi-stats">${prices.year_change_pct != null ? html`<div class="hpi-stat"><span class="hpi-num${prices.year_change_pct < 0 ? ' hpi-down' : ''}">${pct(prices.year_change_pct)}</span><span class="hpi-label">over the past year</span></div>` : ''}${prices.five_year_change_pct != null ? html`<div class="hpi-stat"><span class="hpi-num${prices.five_year_change_pct < 0 ? ' hpi-down' : ''}">${pct(prices.five_year_change_pct)}</span><span class="hpi-label">over five years</span></div>` : ''}</div>
<p class="hpi-note">FHFA all-transactions index, ${prices.latest}. An index of repeat sales, not a price. <a href="${prices.source.url}" rel="noopener">Source: ${prices.source.name}</a></p></aside>` : ''}
<aside class="homes-card"><h3>What would it cost a month?</h3><p><a href="${calc}">Work out the mortgage on ${value && c.value < 2000001 ? `a ${value} home` : 'a home'}</a> with 20% down: put in your own rate, taxes and insurance.</p></aside>
--<aside class="homes-card"><h3>Renting instead?</h3><p>${two ? html`A typical 2-bedroom in ${c.name} rents for ${two} a month. ` : ''}<a href="${rentBase}${c.path}">Rents and every rental site in ${c.name}</a> on OpenVibe.Rent.</p></aside>
++<aside class="homes-card"><h3>Renting instead?</h3><p>${two ? html`A typical 2-bedroom in ${c.name} rents for ${two} a month. ` : ''}<a href="${rentBase}${c.path}">Rents and every rental site in ${c.name}</a> on OpenVibe.Rent.</p>${two ? sourceLine('median gross rent, rent plus utilities') : ''}</aside>
</div>
--${source()}
<section class="sc-sec" aria-labelledby="h-sites"><h2 id="h-sites">Homes for sale in ${c.name}, on every site</h2>
<ul class="everywhere-sites">${links.map((l) => html`<li class="ew-site"><a class="ew-open" href="${l.url}" target="_blank" rel="noopener nofollow"><span class="ew-name">${l.name}</span><span class="ew-host">${hostOf(l.url)} ↗</span></a><p class="ew-best">${l.best}</p></li>`)}</ul>
<nav class="everywhere-kinds" aria-label="Other things to buy in ${c.name}">${sites.KINDS.filter((k) => k !== 'home').map((k) => html`<a class="ew-kind" href="/search?${new URLSearchParams({ q, kind: k })}">${sites.KIND_TEXT[k]}</a>`)}</nav>
--<p class="small muted">Each link opens that site's own search for ${c.name} in a new tab. Offers, inspections and payments happen with the seller or an agent, never through a stranger's link.</p></section>
++<p class="homes-nofee"><b>Nobody pays us for these links, and we sell no leads.</b> Each opens that site's own search for ${c.name} in a new tab; offers, inspections and payments happen with the seller or an agent there.</p></section>
<section class="sc-sec" aria-labelledby="h-records"><h2 id="h-records">Records and next steps</h2><ul class="everywhere-here">
<li><a href="${sites.recordsUrl(c.state)}" target="_blank" rel="noopener nofollow">The county assessor and recorder in ${c.stateName} ↗</a>: owners, past sales, assessed values, taxes and deeds</li>
<li><a href="${rentBase}/board">Your board</a> on OpenVibe.Rent: keep homes from every site in one list, with your notes</li></ul></section>
ddiff --git a/server/homes/pages.js b/server/homes/pages.js
iindex 59de68e..24daf6f 100644
---- a/server/homes/pages.js
++++ b/server/homes/pages.js
@@@ -103,7 +103,7 @@ function createHomesRoutes(ctx) {
lede: 'Type a place once and Zillow, Realtor.com, Redfin, Homes.com, Trulia and Craigslist search it, next to HUD and Fannie Mae homes, foreclosure auctions and the county\'s records.',
aside: { html: render(html`${home.search({ action: '/search', placeholder: 'City or ZIP', kinds: sites.KINDS, kindText: sites.KIND_TEXT, kind: 'home', go: 'Search every site' })}
${home.doors([{ href: '/in', label: 'Homes by city' }, { href: '/mortgage', label: 'Mortgage calculator' }])}
--<p class="home-note">Offers, inspections and payments happen with the seller or an agent on each site, never through a stranger's link.</p>`) },
++<p class="home-note">Nobody pays us to send you anywhere, and we sell no leads. Offers, inspections and payments happen with the seller or an agent on each site.</p>`) },
}))}
${home.stats([
{ value: sites.SITES.length, label: 'for-sale, land and auction sites', href: '/search' },
ddiff --git a/test/homes.test.js b/test/homes.test.js
iindex 099497f..3db286d 100644
---- a/test/homes.test.js
++++ b/test/homes.test.js
@@@ -120,6 +120,12 @@ const housingFetchImpl = async (url) => (new URL(String(url)).pathname.endsWith(
assert.ok(a.text.includes('<h1>Buying a home in Austin, TX</h1>') && a.text.includes('$555,300') && a.text.includes('±$8,017'));
assert.ok(a.text.includes('href="/mortgage?price=555300&amp;down=20"') || a.text.includes('href="/mortgage?price=555300&down=20"'), 'the calculator with the median price, the rate left to the person');
assert.ok(a.text.includes('rents for $1,845 a month') && a.text.includes('/in/tx/austin">Rents and every rental site in Austin'), 'its Rent twin');
++ // Each figure names its source in its own card (market outcome 5), and who pays us is said once (outcome 3).
++ const cardOf = (cls) => a.text.slice(a.text.indexOf(cls), a.text.indexOf('</aside>', a.text.indexOf(cls)));
++ assert.match(cardOf('value-card'), /<p class="fig-src"><a href="https:\/\/www\.census\.gov\/programs-surveys\/acs" rel="noopener">Census American Community Survey 5-year estimates<\/a>, 2020–2024 · the ± is its 90% margin of error<\/p>/);
++ assert.match(cardOf('<h3>Renting instead?'), /class="fig-src">.*2020–2024 · median gross rent, rent plus utilities/);
++ assert.ok(!a.text.includes('class="small muted city-source"'), 'no footnote: the sources are in the cards');
++ assert.match(a.text, /<p class="homes-nofee"><b>Nobody pays us for these links, and we sell no leads\.<\/b>/);
assert.ok(a.text.includes('https://www.zillow.com/austin-tx/') && a.text.includes('href="https://publicrecords.netronline.com/state/TX"'));
assert.match(a.text, /<meta name="robots" content="index, follow">/);
const pa = await homes('/in/ca/palo-alto');