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

Merged opened this

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

Merged

mason/listing-launch:mason/108-a-sold-listing-s-seller-report-opens-wit → main 2 commits fork 2 threads · 2 unresolved

  • Merged. mason/108-a-sold-listing-s-seller-report-opens-wit was merged into main as 686db9a.

Review

Sign in to review this change.
7 files changed +99 −4
README.md +9 −2
@@ -160,8 +160,15 @@ to make a listing of your own.
160160 14. **Print the seller report.** Press **Seller report** beside the flyer and
161161 the kit: one page with the address, price, status, photo, the property page
162162 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
outdated

Re-wrap this; the terminal sentence now claims the same report, which holds only once reportText carries the line.

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.
165172 15. **See the launch week.** Press **Launch week** on a listing: a day-by-day
166173 plan of which asset goes out when — the Coming soon teaser, launch day (MLS,
167174 social posts, email blast), price and status posts, and for each open house
docs/design/areas/market.md +1
@@ -3,5 +3,6 @@
33 New component classes, built from existing tokens:
44
55 - `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.
67 - `market-days-pill` — the same count as a small `pill` beside an active listing on the home list.
78 - `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";
99 import { join } from "node:path";
1010 import { getListing, launchListing, listListings, setStatus, updateFacts } from "./actions.ts";
1111 import { openDb } from "./db.ts";
12 import { daysLine, daysOnMarket, historyLines, priceHistory } from "./market.ts";
12 import { daysLine, daysOnMarket, historyLines, priceHistory, saleResultLine } from "./market.ts";
1313 import { draftOf, home } from "./pages/home.ts";
1414 import { listingPage } from "./pages/listing.ts";
1515 import { reportPage } from "./pages/report.ts";
@@ -169,3 +169,31 @@ test("the samples open with believable numbers: Alder Lane has a price improveme
169169 expect(daysOnMarket(s.value, dayOf(new Date()))).toBe(63);
170170 expect(s.value.history.at(-1)).toMatchObject({ kind: "sold", price: 752000 });
171171 });
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[] },
7373 const days = daysLine(l, today);
7474 return [...(days === null ? [] : [`${days}.`, ""]), "Price history:", ...historyLines(priceHistory(l.history))];
7575 };
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";
1212 import type { Who } from "../who.ts";
1313 import { layout } from "./layout.ts";
1414 import { dayOf } from "../showings.ts";
15 import { saleResultLine } from "../market.ts";
1516 import { daysLineHtml, priceHistoryTable } from "./market.ts";
1617 import { photoUrl } from "./listing.ts";
1718
@@ -26,6 +27,11 @@ const row = (r: ReportRow): string =>
2627 }
2728 </li>`;
2829
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
2935 export function reportPage(input: { who: Who; listing: Launched; origin?: string }): string {
3036 const { who, listing: l } = input;
3137 const hero = l.photos[0];
@@ -42,6 +48,7 @@ export function reportPage(input: { who: Who; listing: Launched; origin?: string
4248 <p class="muted">Launch report · ${escape(l.agent_name)}, ${escape(l.brokerage)}</p>
4349 <h2>${escape(l.address)}</h2>
4450 <p class="report-price brand">${money(l.price)} · ${escape(l.status === "active" ? "Active" : statusLine(l))}</p>
51 ${saleLineHtml(l, dayOf(new Date()))}
4552 ${daysLineHtml(l, dayOf(new Date()))}
4653 ${priceHistoryTable(l.history)}
4754 ${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";
44 import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 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";
711 import { sign } from "./who.ts";
812
913 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 () => {
5256 expect(html).not.toContain("Approved by");
5357 expect(html).not.toContain("Sunny kitchen");
5458 });
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 @@
77 */
88
99 import { money, statusLine } from "./generate.ts";
10 import { marketLines } from "./market.ts";
10 import { marketLines, saleResultLine } from "./market.ts";
1111 import { dayOf } from "./showings.ts";
1212 import { publicOrigin } from "./env.ts";
1313 import { propertyPath, type Approval, type Asset, type Launched } from "./listing.ts";
@@ -46,10 +46,12 @@ export const reportRows = (l: Launched): ReportRow[] =>
4646 export function reportText(l: Launched, origin = publicOrigin): string {
4747 const rows = reportRows(l);
4848 const signed = rows.filter((r) => r.approved !== null).length;
49 const sale = saleResultLine(l, dayOf(new Date()));
4950 return `${[
5051 `SELLER REPORT — ${oneLine(l.address)}`,
5152 `${money(l.price)} · ${oneLine(l.status === "active" ? "Active" : statusLine(l))}`,
5253 `Agent: ${oneLine(l.agent_name)} · ${oneLine(l.brokerage)}`,
54 ...(sale === null ? [] : [sale]),
5355 ...marketLines(l, dayOf(new Date())),
5456 "",
5557 `Property page: ${propertyPath(l, origin)}`,

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs