Public: anyone can read this; the crew can change it.

The property page shows an estimated monthly payment for the home, with its assumptions in plain words #101

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/listing-launch:mason/95-the-property-page-shows-an-estimated-mon → main 2 commits fork 2 threads · 2 unresolved

  • Merged. mason/95-the-property-page-shows-an-estimated-mon was merged into main as 346411e.

Review

Sign in to review this change.
6 files changed +103 −1
README.md +8
@@ -314,6 +314,14 @@ list cannot drift from the app.
314314 asset again in another tone (your edits are replaced, so it asks with a
315315 tooltip first).
316316
317 **The estimated payment.** Under the price, the property page of an active or
318 coming-soon home says "Est. $2,528/mo" with one line of assumptions:
319 principal and interest only, 20% down, a 30-year fixed rate of 6.5% (a sample
320 rate held as `ANNUAL_RATE` in `src/payment.ts`; there is no rate feed). It is
321 an estimate, not a loan offer: no taxes, insurance or fees. Under contract,
322 sold, withdrawn and expired pages show none, and neither does a home with no
323 price.
324
317325 **Showings.** After each showing, write it down on the listing page: the day
318326 (today unless you change it; never a future one), the buyer's agent if you
319327 know them, how interested they were (Not interested, Maybe, Interested, Offer
docs/design/areas/property.md +1 −1
@@ -2,7 +2,7 @@
22
33 New component classes, all built from existing tokens:
44
5 - `home` (`home-hero`, `home-head`, `home-price`, `home-facts`, `home-open`, `home-words`, `home-agent`, `home-face`, `home-name`, `home-notice`) — the buyer's page at `/homes/:id`.
5 - `home` (`home-hero`, `home-head`, `home-price`, `home-payment`, `home-payment-note`, `home-facts`, `home-open`, `home-words`, `home-agent`, `home-face`, `home-name`, `home-notice`) — the buyer's page at `/homes/:id`.
66 - `property-link` — the link and Copy button on the listing page.
77 - `flyer-link` — the property page address printed on the flyer.
88 - `email-cta`, `email-button` — the "See the home" button in the email.
src/pages/property.ts +11
@@ -10,6 +10,7 @@ import { publicOrigin } from "../env.ts";
1010 import { escape } from "../http.ts";
1111 import { altOf, propertyPath, publicDescription, type Launched } from "../listing.ts";
1212 import type { Who } from "../who.ts";
13 import { monthlyPayment, PAYMENT_NOTE } from "../payment.ts";
1314 import { layout } from "./layout.ts";
1415 import { photoUrl } from "./listing.ts";
1516
@@ -50,6 +51,15 @@ const agentFooter = (l: Launched): string => `<footer class="home-agent card">
5051 </div>
5152 </footer>`;
5253
54 /** "Est. $X/mo" and its assumptions, for a home still for sale (active or coming soon) with a price. */
55 function paymentLines(l: Launched): string {
56 const mo = l.status === "active" || l.status === "coming_soon" ? monthlyPayment(l.price) : null;
57 return mo === null
58 ? ""
59 : `<p class="home-payment"><b>Est. ${money(mo)}/mo</b></p>
60 <p class="muted home-payment-note"><small>${escape(PAYMENT_NOTE)}</small></p>`;
61 }
62
5363 export function propertyPage(input: { who: Who; listing: Launched }): string {
5464 const { who, listing: l } = input;
5565 if (isOffMarket(l.status)) return offMarketPage(who, l);
@@ -63,6 +73,7 @@ export function propertyPage(input: { who: Who; listing: Launched }): string {
6373 <header class="home-head">
6474 <h2>${escape(l.address)}</h2>
6575 <p class="home-price brand">${money(l.price)}</p>
76 ${paymentLines(l)}
6677 <p><span class="pill status-pill status-${l.status}">${escape(status)}</span></p>
6778 </header>
6879 <ul class="home-facts brand-fill">
src/payment.test.ts added +30
@@ -0,0 +1,30 @@
1 import { expect, test } from "bun:test";
2 import { ANNUAL_RATE, monthlyPayment, PAYMENT_NOTE } from "./payment.ts";
3
4 test("the rate is the named constant the amounts below assume", () => {
5 expect(ANNUAL_RATE).toBe(0.065);
6 });
7
8 test("known prices give the payment to the dollar", () => {
9 expect(monthlyPayment(500_000)).toBe(2528); // 400,000 financed

The comment is wrong. $200,000 financed at 6.5% over 30 years is 1264.14, not 1263.89, and it rounds down to 1264, not up. Fix the comment, or pick a price that really does round up, so the test actually shows rounding.

10 expect(monthlyPayment(250_000)).toBe(1264); // 200,000 financed -> 1264.14
11 expect(monthlyPayment(1_000_000)).toBe(5057);
12 });
13
14 test("it rounds to the nearest dollar", () => {
15 // 100,000 -> 80,000 financed -> 505.65
16 expect(monthlyPayment(100_000)).toBe(506);
17 // 300,000 -> 240,000 financed -> 1516.96
18 expect(monthlyPayment(300_000)).toBe(1517);
19 });
20
21 test("a $0, negative, missing or non-finite price shows nothing", () => {
22 for (const p of [0, -5, null, undefined, NaN, Infinity]) expect(monthlyPayment(p)).toBeNull();
23 });
24
25 test("the note states the assumptions and that it is no offer", () => {
26 expect(PAYMENT_NOTE).toContain("20% down");
27 expect(PAYMENT_NOTE).toContain("30-year fixed");
28 expect(PAYMENT_NOTE).toContain("6.5%");
29 expect(PAYMENT_NOTE).toContain("not a loan offer");
30 });
src/payment.ts added +23
@@ -0,0 +1,23 @@
1 /**
2 * The estimated monthly payment on a home's price: principal and interest only,
3 * at fixed, stated assumptions. A sample figure, not a quote and not a loan offer.
4 */
5
6 /** The share of the price paid up front. */
7 export const DOWN_PAYMENT = 0.2;
8 /** The loan's length in years, fixed rate. */
9 export const TERM_YEARS = 30;
10 /** The one yearly interest rate every estimate uses (a sample, no rate feed). */
11 export const ANNUAL_RATE = 0.065;
12
13 /** Principal and interest per month, in whole dollars, for a whole-dollar price; null when there is no price to estimate. */
14 export function monthlyPayment(price: number | null | undefined): number | null {
15 if (typeof price !== "number" || !Number.isFinite(price) || price <= 0) return null;
16 const principal = price * (1 - DOWN_PAYMENT);
17 const r = ANNUAL_RATE / 12;
18 const n = TERM_YEARS * 12;
19 return Math.round((principal * r) / (1 - (1 + r) ** -n));
20 }
21
22 /** The one line that says what the estimate assumes and that it is not an offer. */
23 export const PAYMENT_NOTE = `Principal and interest at ${DOWN_PAYMENT * 100}% down, a ${TERM_YEARS}-year fixed rate of ${ANNUAL_RATE * 100}%. An estimate, not a loan offer.`;
src/property.test.ts +30
@@ -72,6 +72,8 @@ test("once approved, anyone reads the words, the facts and the agent; nothing to
7272 for (const s of ["$500,000", "Active", "Pat Agent", "+1 (503) 555-0100", "[email protected]", "palette-teal"])
7373 expect(html).toContain(s);
7474 expect(html).not.toContain("Not public yet");
75 expect(html).toContain("Est. $2,528/mo");
76 expect(html).toContain("not a loan offer");
7577 expect(html).not.toMatch(/<textarea|<form|<button|fh-pill|Draft —/);
7678 });
7779
@@ -101,6 +103,8 @@ test("a sold listing keeps its page, showing Sold", async () => {
101103 });
102104 const html = await (await call(`/homes/${id}`)).text();
103105 expect(html).toContain("Sold at $510,000");
106 expect(html).not.toContain("Est. $");

Only the sold case is covered. Add a coming_soon page that shows "Est. $" and an under_contract page that does not. Those two cases are what box 3 rests on, and the sold test alone does not exercise the active || coming_soon gate.

107 expect(html).not.toContain("loan offer");
104108 });
105109
106110 test("the listing page, the flyer and the email link to it by the configured public origin", async () => {
@@ -193,3 +197,29 @@ test("an off-market listing's preview says it is no longer available and carries
193197 expect(head).not.toContain("$");
194198 expect(head).not.toContain(" bd ");
195199 });
200
201 // --- the estimated payment's status gate -----------------------------------
202 const approvedPage = (status?: "coming_soon" | "under_contract", price?: number) => {
203 const { db, id, get } = preview();
204 for (const a of get().assets) approveAsset(db, me, { id, kind: a.kind, door: "page", seen: a.updated_at });
205 if (status) expect(setStatus(db, me, { id, status }).ok).toBe(true);
206 return propertyPage({ who: { role: "anon" }, listing: { ...get(), ...(price === undefined ? {} : { price }) } });
207 };
208
209 test("a coming-soon page shows the estimated payment and its note", () => {
210 const html = approvedPage("coming_soon");
211 expect(html).toContain("Est. $");
212 expect(html).toContain("not a loan offer");
213 });
214
215 test("an under-contract page shows no estimated payment", () => {
216 const html = approvedPage("under_contract");
217 expect(html).not.toContain("Est. $");
218 expect(html).not.toContain("loan offer");
219 });
220
221 test("a $0 price shows no estimated payment on the page", () => {
222 const html = approvedPage(undefined, 0);
223 expect(html).not.toContain("Est. $");
224 expect(html).not.toContain("loan offer");
225 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs