openvibers/openvibe.rent Commits
Homes: each figure names its source in its own card; nobody pays us for the links (market outcomes) (#168)
Changes
@@@ -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); }@@@ -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>@@@ -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' },@@@ -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&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');
@@ -90,3 +90,9 @@
@@ -90,3 +90,9 @@
.rent-move strong.rent-move-down { color: var(--success, #22c55e); }
.rent-move strong.rent-move-down { color: var(--success, #22c55e); }
.listing-report-mini { margin-top: 6px; }
.listing-report-mini { margin-top: 6px; }
.listing-report-mini button { font-family: inherit; line-height: inherit; cursor: pointer; background: transparent; }
.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); }
@@ -28,6 +28,8 @@ const valueCell = (c) => {
@@ -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>`;
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>`;
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)} %`; };
const pct = (n) => { const x = Math.round(n * 10) / 10; return `${x > 0 ? '+' : ''}${x.toFixed(1)} %`; };
function mountHomesCityPages(r, { config, s, page }) {
function mountHomesCityPages(r, { config, s, page }) {
@@ -100,17 +102,16 @@ ${source()}
@@ -100,17 +102,16 @@ ${source()}
body: html`<h1>Buying a home in ${where}</h1>
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>
<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">
<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>
${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>` : ''}
<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>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>
</div>
-${source()}
<section class="sc-sec" aria-labelledby="h-sites"><h2 id="h-sites">Homes for sale in ${c.name}, on every site</h2>
<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>
<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>
<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">
<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="${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>
<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>
@@ -103,7 +103,7 @@ function createHomesRoutes(ctx) {
@@ -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.',
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' })}
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' }])}
${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([
${home.stats([
{ value: sites.SITES.length, label: 'for-sale, land and auction sites', href: '/search' },
{ value: sites.SITES.length, label: 'for-sale, land and auction sites', href: '/search' },
@@ -120,6 +120,12 @@ const housingFetchImpl = async (url) => (new URL(String(url)).pathname.endsWith(
@@ -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('<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&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('href="/mortgage?price=555300&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');
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.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">/);
assert.match(a.text, /<meta name="robots" content="index, follow">/);
const pa = await homes('/in/ca/palo-alto');
const pa = await homes('/in/ca/palo-alto');