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
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/95-the-property-page-shows-an-estimated-mon → main 2 commits fork 2 threads · 2 unresolved
6 files
changed +103
−1
README.md +8
| @@ -314,6 +314,14 @@ list cannot drift from the app. | ||
| 314 | 314 | asset again in another tone (your edits are replaced, so it asks with a |
| 315 | 315 | tooltip first). |
| 316 | 316 | |
| 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 | ||
| 317 | 325 | **Showings.** After each showing, write it down on the listing page: the day |
| 318 | 326 | (today unless you change it; never a future one), the buyer's agent if you |
| 319 | 327 | know them, how interested they were (Not interested, Maybe, Interested, Offer |
docs/design/areas/property.md +1 −1
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | New component classes, all built from existing tokens: |
| 4 | 4 | |
| 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`. | |
| 6 | 6 | - `property-link` — the link and Copy button on the listing page. |
| 7 | 7 | - `flyer-link` — the property page address printed on the flyer. |
| 8 | 8 | - `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"; | ||
| 10 | 10 | import { escape } from "../http.ts"; |
| 11 | 11 | import { altOf, propertyPath, publicDescription, type Launched } from "../listing.ts"; |
| 12 | 12 | import type { Who } from "../who.ts"; |
| 13 | import { monthlyPayment, PAYMENT_NOTE } from "../payment.ts"; | |
| 13 | 14 | import { layout } from "./layout.ts"; |
| 14 | 15 | import { photoUrl } from "./listing.ts"; |
| 15 | 16 | |
| @@ -50,6 +51,15 @@ const agentFooter = (l: Launched): string => `<footer class="home-agent card"> | ||
| 50 | 51 | </div> |
| 51 | 52 | </footer>`; |
| 52 | 53 | |
| 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 | ||
| 53 | 63 | export function propertyPage(input: { who: Who; listing: Launched }): string { |
| 54 | 64 | const { who, listing: l } = input; |
| 55 | 65 | if (isOffMarket(l.status)) return offMarketPage(who, l); |
| @@ -63,6 +73,7 @@ export function propertyPage(input: { who: Who; listing: Launched }): string { | ||
| 63 | 73 | <header class="home-head"> |
| 64 | 74 | <h2>${escape(l.address)}</h2> |
| 65 | 75 | <p class="home-price brand">${money(l.price)}</p> |
| 76 | ${paymentLines(l)} | |
| 66 | 77 | <p><span class="pill status-pill status-${l.status}">${escape(status)}</span></p> |
| 67 | 78 | </header> |
| 68 | 79 | <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 | |
| 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 | ||
| 72 | 72 | for (const s of ["$500,000", "Active", "Pat Agent", "+1 (503) 555-0100", "[email protected]", "palette-teal"]) |
| 73 | 73 | expect(html).toContain(s); |
| 74 | 74 | expect(html).not.toContain("Not public yet"); |
| 75 | expect(html).toContain("Est. $2,528/mo"); | |
| 76 | expect(html).toContain("not a loan offer"); | |
| 75 | 77 | expect(html).not.toMatch(/<textarea|<form|<button|fh-pill|Draft —/); |
| 76 | 78 | }); |
| 77 | 79 | |
| @@ -101,6 +103,8 @@ test("a sold listing keeps its page, showing Sold", async () => { | ||
| 101 | 103 | }); |
| 102 | 104 | const html = await (await call(`/homes/${id}`)).text(); |
| 103 | 105 | 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 |
||
| 107 | expect(html).not.toContain("loan offer"); | |
| 104 | 108 | }); |
| 105 | 109 | |
| 106 | 110 | 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 | ||
| 193 | 197 | expect(head).not.toContain("$"); |
| 194 | 198 | expect(head).not.toContain(" bd "); |
| 195 | 199 | }); |
| 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 | }); |
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.