Public: anyone can read this; the crew can change it.
A sold listing's seller report opens with the result in one line: sold price, percent of the original list price, and days on market #128
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/108-a-sold-listing-s-seller-report-opens-wit → main 2 commits fork 2 threads · 2 unresolved
7 files
changed +99
−4
README.md +9 −2
| @@ -160,8 +160,15 @@ to make a listing of your own. | ||
| 160 | 160 | 14. **Print the seller report.** Press **Seller report** beside the flyer and |
| 161 | 161 | the kit: one page with the address, price, status, photo, the property page |
| 162 | 162 | link and a row per asset, each with "Approved by … on …" and its words, or |
| 163 | "Draft — not approved yet" with no words. Print it at the end of the meeting. In the terminal, `R` on an open | |
| 164 | listing shows the same report, approved words only. | |
| 163 | "Draft — not approved yet" with no words. Print it at the end of the | |
| 164 | meeting. For a sold listing with a sold price, a line under the address | |
| 165 | gives the result in agents' numbers: "Sold for $752,000 · 101% of the | |
| 166 | original $745,000 list price · 9 days on market". The percent is the | |
| 167 | sale-to-list ratio against the first list price; if the price came down it | |
| 168 | also gives the ratio to the last list price. Days on market are the same | |
| 169 | count the listing page shows. Sold without a price shows no line. | |
| 170 | In the terminal, `R` on an open listing shows the same report, approved | |
| 171 | words only, with the same line. | |
| 165 | 172 | 15. **See the launch week.** Press **Launch week** on a listing: a day-by-day |
| 166 | 173 | plan of which asset goes out when — the Coming soon teaser, launch day (MLS, |
| 167 | 174 | social posts, email blast), price and status posts, and for each open house |
docs/design/areas/market.md +1
| @@ -3,5 +3,6 @@ | ||
| 3 | 3 | New component classes, built from existing tokens: |
| 4 | 4 | |
| 5 | 5 | - `market-days` — "N days on market", a muted line under the price on the listing page and the seller report. |
| 6 | - `report-result` — the sold result line (price, percent of list, days) in bold under the address on the seller report. | |
| 6 | 7 | - `market-days-pill` — the same count as a small `pill` beside an active listing on the home list. |
| 7 | 8 | - `price-history` — the short table of dates, prices and changes under it. |
src/market.test.ts +29 −1
| @@ -9,7 +9,7 @@ import { tmpdir } from "node:os"; | ||
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { getListing, launchListing, listListings, setStatus, updateFacts } from "./actions.ts"; |
| 11 | 11 | import { openDb } from "./db.ts"; |
| 12 | import { daysLine, daysOnMarket, historyLines, priceHistory } from "./market.ts"; | |
| 12 | import { daysLine, daysOnMarket, historyLines, priceHistory, saleResultLine } from "./market.ts"; | |
| 13 | 13 | import { draftOf, home } from "./pages/home.ts"; |
| 14 | 14 | import { listingPage } from "./pages/listing.ts"; |
| 15 | 15 | import { reportPage } from "./pages/report.ts"; |
| @@ -169,3 +169,31 @@ test("the samples open with believable numbers: Alder Lane has a price improveme | ||
| 169 | 169 | expect(daysOnMarket(s.value, dayOf(new Date()))).toBe(63); |
| 170 | 170 | expect(s.value.history.at(-1)).toMatchObject({ kind: "sold", price: 752000 }); |
| 171 | 171 | }); |
| 172 | ||
| 173 | const sale = (sold_price: number | null, prices: number[], days: [string, string], status = "sold") => ({ | |
| 174 | status, sold_price, listed_on: days[0], market_ended_on: days[1], | |
| 175 | history: [...prices.map((price, i) => ({ date: "2026-10-01", price, kind: i === 0 ? "list" : "price" }) as const), | |
| 176 | ...(sold_price === null ? [] : [{ date: days[1], price: sold_price, kind: "sold" } as const]), | |
| 177 | ], | |
| 178 | }); | |
| 179 | ||
| 180 | test("the sale line: above list, below list after a price improvement, no price, one day", () => { | |
| 181 | expect(saleResultLine(sale(752000, [745000], ["2026-10-01", "2026-10-10"]), "2026-11-08")) | |
| 182 | .toBe("Sold for $752,000 · 101% of the original $745,000 list price · 9 days on market"); | |
| 183 | expect(saleResultLine(sale(750000, [800000, 765000], ["2026-10-01", "2026-10-20"]), "2026-11-08")) | |
| 184 | .toBe("Sold for $750,000 · 94% of the original $800,000 list price · 98% of the last $765,000 · 19 days on market"); | |
| 185 | expect(saleResultLine(sale(null, [745000], ["2026-10-01", "2026-10-10"]), "2026-11-08")).toBeNull(); | |
| 186 | expect(saleResultLine(sale(700000, [745000], ["2026-10-01", "2026-10-10"], "active"), "2026-11-08")).toBeNull(); | |
| 187 | expect(saleResultLine(sale(745000, [745000], ["2026-10-01", "2026-10-02"]), "2026-11-08")).toContain("100% of the original $745,000 list price · 1 day on market"); | |
| 188 | }); | |
| 189 | ||
| 190 | test("the report page carries the sale line, escaped, only for a sold listing with a price", () => { | |
| 191 | const { db, id } = launch(); | |
| 192 | expect(reportPage({ who: me, listing: load(db, id) })).not.toContain("report-result"); | |
| 193 | setStatus(db, me, { id, status: "sold", sold_price: 701000 }); | |
| 194 | const html = reportPage({ who: me, listing: load(db, id) }); | |
| 195 | expect(html).toContain('<p class="report-result"><b>Sold for $701,000 · '); | |
| 196 | expect(html).toContain(`of the original ${"$" + SAMPLE.price.toLocaleString("en-US")} list price`); | |
| 197 | setStatus(db, me, { id, status: "sold" }); | |
| 198 | expect(reportPage({ who: me, listing: load(db, id) })).not.toContain("report-result"); | |
| 199 | }); |
src/market.ts +28
| @@ -73,3 +73,31 @@ export const marketLines = (l: MarketDates & { history: readonly PriceEntry[] }, | ||
| 73 | 73 | const days = daysLine(l, today); |
| 74 | 74 | return [...(days === null ? [] : [`${days}.`, ""]), "Price history:", ...historyLines(priceHistory(l.history))]; |
| 75 | 75 | }; |
| 76 | ||
| 77 | export interface SaleFacts extends MarketDates { | |
| 78 | readonly status: string; | |
| 79 | readonly sold_price: number | null; | |
| 80 | readonly history: readonly PriceEntry[]; | |
| 81 | } | |
| 82 | ||
| 83 | /** | |
| 84 | * The result a seller cares about, in one line: "Sold for $752,000 · 101% of | |
| 85 | * the original $745,000 list price · 9 days on market". When the price was | |
| 86 | * lowered it also names the last list price ("98% of the last $765,000"). | |
| 87 | * Null unless the listing is sold with a sold price. | |
| 88 | */ | |
| 89 | export function saleResultLine(l: SaleFacts, today: string): string | null { | |
| 90 | if (l.status !== "sold" || l.sold_price === null) return null; | |
| 91 | const listed = l.history.filter((h) => h.kind !== "sold"); | |
| 92 | const original = listed[0]?.price; | |
| 93 | const last = listed[listed.length - 1]?.price; | |
| 94 | const parts = [`Sold for ${money(l.sold_price)}`]; | |
| 95 | if (original !== undefined && original > 0) { | |
| 96 | const pct = (of: number): number => Math.round((l.sold_price! / of) * 100); | |
| 97 | parts.push(`${pct(original)}% of the original ${money(original)} list price`); | |
| 98 | if (last !== undefined && last !== original) parts.push(`${pct(last)}% of the last ${money(last)}`); | |
| 99 | } | |
| 100 | const days = daysLine(l, today); | |
| 101 | if (days !== null) parts.push(days); | |
| 102 | return parts.join(" · "); | |
| 103 | } |
src/pages/report.ts +7
| @@ -12,6 +12,7 @@ import { DRAFT_MARK, reportRows, type ReportRow } from "../report.ts"; | ||
| 12 | 12 | import type { Who } from "../who.ts"; |
| 13 | 13 | import { layout } from "./layout.ts"; |
| 14 | 14 | import { dayOf } from "../showings.ts"; |
| 15 | import { saleResultLine } from "../market.ts"; | |
| 15 | 16 | import { daysLineHtml, priceHistoryTable } from "./market.ts"; |
| 16 | 17 | import { photoUrl } from "./listing.ts"; |
| 17 | 18 | |
| @@ -26,6 +27,11 @@ const row = (r: ReportRow): string => | ||
| 26 | 27 | } |
| 27 | 28 | </li>`; |
| 28 | 29 | |
| 30 | const saleLineHtml = (l: Launched, today: string): string => { | |
| 31 | const line = saleResultLine(l, today); | |
| 32 | return line === null ? "" : `<p class="report-result"><b>${escape(line)}</b></p>`; | |
The terminal R report has no equivalent of this line; put it in reportText too so both doors agree. |
||
| 33 | }; | |
| 34 | ||
| 29 | 35 | export function reportPage(input: { who: Who; listing: Launched; origin?: string }): string { |
| 30 | 36 | const { who, listing: l } = input; |
| 31 | 37 | const hero = l.photos[0]; |
| @@ -42,6 +48,7 @@ export function reportPage(input: { who: Who; listing: Launched; origin?: string | ||
| 42 | 48 | <p class="muted">Launch report · ${escape(l.agent_name)}, ${escape(l.brokerage)}</p> |
| 43 | 49 | <h2>${escape(l.address)}</h2> |
| 44 | 50 | <p class="report-price brand">${money(l.price)} · ${escape(l.status === "active" ? "Active" : statusLine(l))}</p> |
| 51 | ${saleLineHtml(l, dayOf(new Date()))} | |
| 45 | 52 | ${daysLineHtml(l, dayOf(new Date()))} |
| 46 | 53 | ${priceHistoryTable(l.history)} |
| 47 | 54 | ${hero === undefined ? "" : `<img class="report-hero" src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="1200" height="800">`} |
src/report.test.ts +22
| @@ -4,6 +4,10 @@ import { mkdtempSync } from "node:fs"; | ||
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { serve } from "./server.ts"; |
| 7 | import { getListing, launchListing, setStatus } from "./actions.ts"; | |
| 8 | import { openDb } from "./db.ts"; | |
| 9 | import { reportText } from "./report.ts"; | |
| 10 | import { SAMPLE } from "./sample.ts"; | |
| 7 | 11 | import { sign } from "./who.ts"; |
| 8 | 12 | |
| 9 | 13 | const KEY = "a-key-only-the-forge-holds"; |
| @@ -52,3 +56,21 @@ test("a fresh listing's rows are marked Draft and carry no words", async () => { | ||
| 52 | 56 | expect(html).not.toContain("Approved by"); |
| 53 | 57 | expect(html).not.toContain("Sunny kitchen"); |
| 54 | 58 | }); |
| 59 | ||
| 60 | test("the terminal report opens with the sale line for a sold listing with a price, and only then", () => { | |
| 61 | const db = openDb(mkdtempSync(join(tmpdir(), "report-text-"))); | |
| 62 | const me = { role: "user", id: "u1", name: "me" } as const; | |
| 63 | const made = launchListing(db, me, { fields: { ...SAMPLE } }); | |
| 64 | if (!made.ok) throw new Error(made.message); | |
| 65 | const id = made.value.id; | |
| 66 | const text = () => { | |
| 67 | const found = getListing(db, me, { id }); | |
| 68 | if (!found.ok) throw new Error(found.message); | |
| 69 | return reportText(found.value); | |
| 70 | }; | |
| 71 | expect(text()).not.toContain("Sold for"); | |
| 72 | setStatus(db, me, { id, status: "sold", sold_price: 701000 }); | |
| 73 | expect(text()).toMatch(/\nSold for \$701,000 · \d+% of the original \$[\d,]+ list price · 0 days on market\n/); | |
| 74 | setStatus(db, me, { id, status: "sold" }); | |
| 75 | expect(text()).not.toContain("Sold for"); | |
| 76 | }); |
src/report.ts +3 −1
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import { money, statusLine } from "./generate.ts"; |
| 10 | import { marketLines } from "./market.ts"; | |
| 10 | import { marketLines, saleResultLine } from "./market.ts"; | |
| 11 | 11 | import { dayOf } from "./showings.ts"; |
| 12 | 12 | import { publicOrigin } from "./env.ts"; |
| 13 | 13 | import { propertyPath, type Approval, type Asset, type Launched } from "./listing.ts"; |
| @@ -46,10 +46,12 @@ export const reportRows = (l: Launched): ReportRow[] => | ||
| 46 | 46 | export function reportText(l: Launched, origin = publicOrigin): string { |
| 47 | 47 | const rows = reportRows(l); |
| 48 | 48 | const signed = rows.filter((r) => r.approved !== null).length; |
| 49 | const sale = saleResultLine(l, dayOf(new Date())); | |
| 49 | 50 | return `${[ |
| 50 | 51 | `SELLER REPORT — ${oneLine(l.address)}`, |
| 51 | 52 | `${money(l.price)} · ${oneLine(l.status === "active" ? "Active" : statusLine(l))}`, |
| 52 | 53 | `Agent: ${oneLine(l.agent_name)} · ${oneLine(l.brokerage)}`, |
| 54 | ...(sale === null ? [] : [sale]), | |
| 53 | 55 | ...marketLines(l, dayOf(new Date())), |
| 54 | 56 | "", |
| 55 | 57 | `Property page: ${propertyPath(l, origin)}`, |
Re-wrap this; the terminal sentence now claims the same report, which holds only once reportText carries the line.