Public: anyone can read this; the crew can change it.
Every asset reads like a person wrote it: '1 bedroom', no 'Condo, no lot lot', 'an 1890s home', in every tone #30
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/29-every-asset-reads-like-a-person-wrote-it → main 2 commits fork
6 files
changed +121
−22
src/generate.test.ts +45 −1
| @@ -6,15 +6,18 @@ | ||
| 6 | 6 | import { expect, test } from "bun:test"; |
| 7 | 7 | import { check } from "./fairhousing.ts"; |
| 8 | 8 | import { |
| 9 | factItems, | |
| 10 | factsLine, | |
| 9 | 11 | flyerParts, |
| 10 | 12 | generate, |
| 11 | 13 | KINDS, |
| 14 | lotOf, | |
| 12 | 15 | street, |
| 13 | 16 | TONES, |
| 14 | 17 | type Facts, |
| 15 | 18 | } from "./generate.ts"; |
| 16 | 19 | import { agentOf } from "./listing.ts"; |
| 17 | import { SAMPLE } from "./sample.ts"; | |
| 20 | import { SAMPLE, SAMPLE_CONDO, SAMPLE_HOUSE } from "./sample.ts"; | |
| 18 | 21 | |
| 19 | 22 | const agent = agentOf(SAMPLE); |
| 20 | 23 | const facts = (tone: Facts["tone"]): Facts => ({ ...SAMPLE, tone }); |
| @@ -81,3 +84,44 @@ test("what the agent typed is checked as it is: a risky feature is flagged in th | ||
| 81 | 84 | }); |
| 82 | 85 | expect(check(words.mls).findings.map((f) => f.text)).toEqual(["Family-friendly"]); |
| 83 | 86 | }); |
| 87 | ||
| 88 | test("every asset reads like a person wrote it, in every tone", () => { | |
| 89 | const base = { ...SAMPLE }; | |
| 90 | const cases: [string, Omit<Facts, "tone">][] = [ | |
| 91 | ["one-bed condo", { ...base, beds: 1, baths: 1, lot: "Condo, no lot", year: 2016 }], | |
| 92 | ["1890 house", { ...base, beds: 4, baths: 2.5, year: 1890, lot: "0.31 acre" }], | |
| 93 | ["8,000 sq ft", { ...base, beds: 8, sqft: 8000, year: 1890 }], | |
| 94 | ["sample", base], | |
| 95 | ["sample condo", { ...SAMPLE_CONDO }], | |
| 96 | ["sample house", { ...SAMPLE_HOUSE }], | |
| 97 | ]; | |
| 98 | const bad = [/\b1 (bedrooms|baths|beds)\b/, /lot lot/, /\ba n\/a\b/, /\ba (8|18|11)\d/, /undefined/, /NaN/, / {2}/]; | |
| 99 | for (const [name, c] of cases) | |
| 100 | for (const tone of TONES) { | |
| 101 | const words = generate({ facts: { ...c, tone }, agent }); | |
| 102 | for (const kind of KINDS) | |
| 103 | for (const re of bad) expect([name, tone, kind, re.test(words[kind])]).toEqual([name, tone, kind, false]); | |
| 104 | } | |
| 105 | }); | |
| 106 | ||
| 107 | test("singular for one, the article fits, a lot that is not a measurement is left out", () => { | |
| 108 | const condo = { ...SAMPLE, beds: 1, baths: 1, lot: "Condo, no lot", year: 2016, tone: "bold" as const }; | |
| 109 | const w = generate({ facts: condo, agent }); | |
| 110 | expect(w.mls).toContain("1 bed. 1 bath."); | |
| 111 | expect(w.mls).toContain("Built in 2016. Book"); | |
| 112 | expect(generate({ facts: { ...condo, tone: "classic" }, agent }).mls).toContain("a 1-bedroom, 1-bath residence"); | |
| 113 | expect(generate({ facts: { ...condo, tone: "classic" }, agent }).flyer).toContain("1 bedroom and 1 bath"); | |
| 114 | expect(generate({ facts: { ...SAMPLE, baths: 1.5 }, agent }).flyer).toContain("1.5 baths"); | |
| 115 | expect(generate({ facts: { ...SAMPLE, year: 1890, tone: "classic" }, agent }).flyer).toStartWith("An 1890 home"); | |
| 116 | expect(generate({ facts: { ...SAMPLE, lot: "8,000 sq ft", tone: "bold" }, agent }).mls).toContain("on an 8,000 sq ft lot"); | |
| 117 | expect(generate({ facts: { ...SAMPLE, lot: "0.18 acre", tone: "bold" }, agent }).mls).toContain("on a 0.18 acre lot"); | |
| 118 | expect(generate({ facts: { ...SAMPLE, lot: "1,800 sq ft", tone: "bold" }, agent }).mls).toContain("on a 1,800 sq ft lot"); | |
| 119 | expect(generate({ facts: { ...SAMPLE, lot: "1,150 sq ft", tone: "bold" }, agent }).mls).toContain("on a 1,150 sq ft lot"); | |
| 120 | expect(generate({ facts: { ...SAMPLE, lot: "18,000 sq ft", tone: "bold" }, agent }).mls).toContain("on an 18,000 sq ft lot"); | |
| 121 | for (const lot of ["n/a", "none", "—", "Condo, no lot"]) { | |
| 122 | expect(lotOf(lot)).toBeNull(); | |
| 123 | expect(factItems({ ...condo, lot }).map((i) => i.label)).toEqual(["bed", "bath", "sq ft", "built"]); | |
| 124 | expect(factsLine({ ...condo, lot })).not.toContain("lot"); | |
| 125 | } | |
| 126 | expect(factItems({ ...condo, lot: "0.18 acre" }).map((i) => i.label)).toContain("lot"); | |
| 127 | }); |
src/generate.ts +57 −11
| @@ -68,15 +68,60 @@ const sentence = (line: string): string => { | ||
| 68 | 68 | const bare = (line: string): string => |
| 69 | 69 | line.trim().replace(/\s+/g, " ").replace(/[.!?]+$/, ""); |
| 70 | 70 | |
| 71 | /** "1 bedroom", "2 bedrooms", "1.5 baths": the singular for exactly one. */ | |
| 72 | const plural = (n: number, one: string, many = `${one}s`): string => | |
| 73 | `${num(n)} ${n === 1 ? one : many}`; | |
| 74 | ||
| 75 | /** The lot as said in a sentence, or null when it is not a measurement ("Condo, no lot", "n/a", "—"). */ | |
| 76 | export const lotOf = (lot: string): string | null => { | |
| 77 | const t = lot.trim().replace(/\s+/g, " "); | |
| 78 | return /\d/.test(t) ? t : null; | |
| 79 | }; | |
| 80 | ||
| 81 | /** "a" or "an" for the word that follows: "an 1890", "an 8,000", "a 2004", "a 180". */ | |
| 82 | export const article = (next: string): "a" | "an" => { | |
| 83 | const lead = /^\d[\d,]*/.exec(next.trim())?.[0] ?? ""; | |
| 84 | if (lead === "") return /^[aeiou]/i.test(next.trim()) ? "an" : "a"; | |
| 85 | if (lead.startsWith("8")) return "an"; | |
| 86 | // Eleven, eighteen, eighteen thousand, and a bare year ("eighteen ninety"): | |
| 87 | // but "1,800" is "one thousand eight hundred" and "180" is "one eighty". | |
| 88 | const digits = lead.replace(/,/g, "").length; | |
| 89 | if (!/^1[18]/.test(lead)) return "a"; | |
| 90 | return digits === 2 || digits === 5 || (digits === 4 && !lead.includes(",")) ? "an" : "a"; | |
| 91 | }; | |
| 92 | const withArticle = (next: string): string => `${article(next)} ${next}`; | |
| 93 | ||
| 94 | /** ", on a 0.18 acre lot" pieces: the lot phrase, or "" when the lot is not a measurement. */ | |
| 95 | const lotPhrase = (f: Facts, lead: string): string => { | |
| 96 | const lot = lotOf(f.lot); | |
| 97 | return lot === null ? "" : `${lead}${withArticle(`${lot} lot`)}`; | |
| 98 | }; | |
| 99 | ||
| 100 | /** The fact strips the pages draw: a value and its label, with no lot when the lot is not a measurement. */ | |
| 101 | export const factItems = ( | |
| 102 | f: Pick<Facts, "beds" | "baths" | "sqft" | "lot" | "year">, | |
| 103 | ): { value: string; label: string }[] => { | |
| 104 | const lot = lotOf(f.lot); | |
| 105 | return [ | |
| 106 | { value: num(f.beds), label: f.beds === 1 ? "bed" : "beds" }, | |
| 107 | { value: num(f.baths), label: f.baths === 1 ? "bath" : "baths" }, | |
| 108 | { value: count(f.sqft), label: "sq ft" }, | |
| 109 | ...(lot === null ? [] : [{ value: lot, label: "lot" }]), | |
| 110 | { value: num(f.year), label: "built" }, | |
| 111 | ]; | |
| 112 | }; | |
| 113 | ||
| 71 | 114 | /** The facts in one line: "3 bd · 2.5 ba · 2,140 sq ft · 0.18 acre lot · built 1928". */ |
| 72 | export const factsLine = (f: Facts): string => | |
| 73 | [ | |
| 115 | export const factsLine = (f: Facts): string => { | |
| 116 | const lot = lotOf(f.lot); | |
| 117 | return [ | |
| 74 | 118 | `${num(f.beds)} bd`, |
| 75 | 119 | `${num(f.baths)} ba`, |
| 76 | 120 | `${count(f.sqft)} sq ft`, |
| 77 | `${f.lot} lot`, | |
| 121 | ...(lot === null ? [] : [`${lot} lot`]), | |
| 78 | 122 | `built ${f.year}`, |
| 79 | 123 | ].join(" · "); |
| 124 | }; | |
| 80 | 125 | |
| 81 | 126 | const signature = (a: Agent): string => |
| 82 | 127 | `${a.name} · ${a.brokerage} · Lic. ${a.license}`; |
| @@ -84,14 +129,15 @@ const signature = (a: Agent): string => | ||
| 84 | 129 | function mls(f: Facts): string { |
| 85 | 130 | const where = f.address; |
| 86 | 131 | const size = `${num(f.beds)}-bedroom, ${num(f.baths)}-bath`; |
| 132 | const sized = withArticle(size); | |
| 87 | 133 | const features = f.features.map(sentence).join(" "); |
| 88 | 134 | switch (f.tone) { |
| 89 | 135 | case "classic": |
| 90 | return `Offered at ${money(f.price)}, ${where} is a ${size} residence of ${count(f.sqft)} square feet on a ${f.lot} lot, built in ${f.year}. ${features} Showings by appointment.`; | |
| 136 | return `Offered at ${money(f.price)}, ${where} is ${sized} residence of ${count(f.sqft)} square feet${lotPhrase(f, " on ")}, built in ${f.year}. ${features} Showings by appointment.`; | |
| 91 | 137 | case "warm": |
| 92 | return `There is a lot to love at ${where}. This ${size} home has ${count(f.sqft)} square feet to settle into, on a ${f.lot} lot, and it has been looked after since ${f.year}. ${features} Come see it in person; it is even better than the photos. Offered at ${money(f.price)}.`; | |
| 138 | return `There is a lot to love at ${where}. This ${size} home has ${count(f.sqft)} square feet to settle into${lotPhrase(f, ", on ")}, and it has been looked after since ${f.year}. ${features} Come see it in person; it is even better than the photos. Offered at ${money(f.price)}.`; | |
| 93 | 139 | case "bold": |
| 94 | return `${where}. ${num(f.beds)} beds. ${num(f.baths)} baths. ${count(f.sqft)} square feet. ${money(f.price)}. ${features} Built in ${f.year} on a ${f.lot} lot. Book your showing now.`; | |
| 140 | return `${where}. ${plural(f.beds, "bed")}. ${plural(f.baths, "bath")}. ${count(f.sqft)} square feet. ${money(f.price)}. ${features} Built in ${f.year}${lotPhrase(f, " on ")}. Book your showing now.`; | |
| 95 | 141 | } |
| 96 | 142 | } |
| 97 | 143 | |
| @@ -120,7 +166,7 @@ function socialMedium(f: Facts, a: Agent): string { | ||
| 120 | 166 | warm: "I would love to show you around. Send me a message to set up a visit.", |
| 121 | 167 | bold: "Showings are filling up. Message me today.", |
| 122 | 168 | }[f.tone]; |
| 123 | return `${open}\n\n${num(f.beds)} bedrooms · ${num(f.baths)} baths · ${count(f.sqft)} sq ft · built ${f.year}\n\n${top}\n\n${close}\n${signature(a)} · ${a.phone}\n#JustListed #HomeForSale`; | |
| 169 | return `${open}\n\n${plural(f.beds, "bedroom")} · ${plural(f.baths, "bath")} · ${count(f.sqft)} sq ft · built ${f.year}\n\n${top}\n\n${close}\n${signature(a)} · ${a.phone}\n#JustListed #HomeForSale`; | |
| 124 | 170 | } |
| 125 | 171 | |
| 126 | 172 | function socialCarousel(f: Facts, a: Agent): string { |
| @@ -132,7 +178,7 @@ function socialCarousel(f: Facts, a: Agent): string { | ||
| 132 | 178 | }[f.tone], |
| 133 | 179 | `${money(f.price)} · ${num(f.beds)} bd · ${num(f.baths)} ba · ${count(f.sqft)} sq ft`, |
| 134 | 180 | ...f.features.slice(0, 5).map(bare), |
| 135 | `Built ${f.year} on a ${f.lot} lot`, | |
| 181 | `Built ${f.year}${lotPhrase(f, " on ")}`, | |
| 136 | 182 | `Showings with ${a.name}, ${a.brokerage} · ${a.phone} · Lic. ${a.license}`, |
| 137 | 183 | ]; |
| 138 | 184 | return slides |
| @@ -143,7 +189,7 @@ function socialCarousel(f: Facts, a: Agent): string { | ||
| 143 | 189 | /** The flyer's copy: a headline, a blank line, the paragraph. The page lays out the rest. */ |
| 144 | 190 | function flyer(f: Facts): string { |
| 145 | 191 | const headline = { |
| 146 | classic: `A ${f.year} home at ${street(f.address)}`, | |
| 192 | classic: `${withArticle(`${f.year} home`).replace(/^a/, "A")} at ${street(f.address)}`, | |
| 147 | 193 | warm: `Welcome home to ${street(f.address)}`, |
| 148 | 194 | bold: `${street(f.address)}. Just listed.`, |
| 149 | 195 | }[f.tone]; |
| @@ -153,7 +199,7 @@ function flyer(f: Facts): string { | ||
| 153 | 199 | warm: "Come and see it for yourself.", |
| 154 | 200 | bold: "See it before it is gone.", |
| 155 | 201 | }[f.tone]; |
| 156 | return `${headline}\n\n${num(f.beds)} bedrooms and ${num(f.baths)} baths across ${count(f.sqft)} square feet. ${body} ${close}`; | |
| 202 | return `${headline}\n\n${plural(f.beds, "bedroom")} and ${plural(f.baths, "bath")} across ${count(f.sqft)} square feet. ${body} ${close}`; | |
| 157 | 203 | } |
| 158 | 204 | |
| 159 | 205 | function email(f: Facts, a: Agent): string { |
| @@ -174,7 +220,7 @@ function email(f: Facts, a: Agent): string { | ||
| 174 | 220 | warm: "If you or someone you know would like to see it, just reply to this email and I will set up a time.", |
| 175 | 221 | bold: "Want in? Reply now and I will get you on the showing schedule.", |
| 176 | 222 | }[f.tone]; |
| 177 | return `Subject: ${subject}\n\n${open}\n\nThe facts: ${num(f.beds)} bedrooms, ${num(f.baths)} baths, ${count(f.sqft)} square feet on a ${f.lot} lot, built in ${f.year}.\n\nHighlights:\n${list}\n\n${close}\n\n${a.name}\n${a.brokerage} · License ${a.license}\n${a.phone} · ${a.email}`; | |
| 223 | return `Subject: ${subject}\n\n${open}\n\nThe facts: ${plural(f.beds, "bedroom")}, ${plural(f.baths, "bath")}, ${count(f.sqft)} square feet${lotPhrase(f, " on ")}, built in ${f.year}.\n\nHighlights:\n${list}\n\n${close}\n\n${a.name}\n${a.brokerage} · License ${a.license}\n${a.phone} · ${a.email}`; | |
| 178 | 224 | } |
| 179 | 225 | |
| 180 | 226 | /** All five assets (six texts: social is three posts), for one listing. */ |
src/pages/email.ts +2 −2
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | * computed styles when the agent copies it into their mail tool. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { count, money } from "../generate.ts"; | |
| 8 | import { factItems, money } from "../generate.ts"; | |
| 9 | 9 | import { escape } from "../http.ts"; |
| 10 | 10 | import type { Launched } from "../listing.ts"; |
| 11 | 11 | import type { Who } from "../who.ts"; |
| @@ -44,7 +44,7 @@ export function email(l: Launched): string { | ||
| 44 | 44 | ${hero === undefined ? "" : `<img class="email-hero" src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="600" height="400">`} |
| 45 | 45 | <h2 class="email-address brand">${escape(l.address)}</h2> |
| 46 | 46 | <p class="email-price brand">${money(l.price)}</p> |
| 47 | <p class="email-facts brand-fill">${l.beds} beds · ${l.baths} baths · ${count(l.sqft)} sq ft · ${escape(l.lot)} lot · ${l.year} built</p> | |
| 47 | <p class="email-facts brand-fill">${factItems(l).map((i) => `${escape(i.value)} ${i.label}`).join(" · ")}</p> | |
| 48 | 48 | ${paragraphs.map((p) => `<p class="email-body">${escape(p).replace(/\n/g, "<br>")}</p>`).join("")} |
| 49 | 49 | <div class="email-agent"> |
| 50 | 50 | ${l.headshot === null ? "" : `<img class="email-face" src="${escape(photoUrl(l, l.headshot))}" alt="${escape(l.agent_name)}" width="72" height="72">`} |
src/pages/listing.ts +2 −6
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { Check, Finding } from "../fairhousing.ts"; |
| 8 | 8 | import { |
| 9 | count, | |
| 9 | factItems, | |
| 10 | 10 | factsLine, |
| 11 | 11 | flyerParts, |
| 12 | 12 | money, |
| @@ -223,11 +223,7 @@ export function flyer(l: Launched): string { | ||
| 223 | 223 | <p class="flyer-price brand">${money(l.price)}</p> |
| 224 | 224 | </header> |
| 225 | 225 | <ul class="flyer-facts brand-fill"> |
| 226 | <li><b>${l.beds}</b> beds</li> | |
| 227 | <li><b>${l.baths}</b> baths</li> | |
| 228 | <li><b>${count(l.sqft)}</b> sq ft</li> | |
| 229 | <li><b>${escape(l.lot)}</b> lot</li> | |
| 230 | <li><b>${l.year}</b> built</li> | |
| 226 | ${factItems(l).map((i) => `<li><b>${escape(i.value)}</b> ${i.label}</li>`).join("\n ")} | |
| 231 | 227 | </ul> |
| 232 | 228 | <div class="flyer-body"> |
| 233 | 229 | <p>${escape(copy.body)}</p> |
src/tty.test.ts +13
| @@ -183,3 +183,16 @@ test("u takes the suggested wording on the asset under the cursor, and says when | ||
| 183 | 183 | expect(plain(guest.screen())).toContain("Only the listing's agent or an admin can edit it."); |
| 184 | 184 | expect(mls()).toBe("A family-friendly street."); |
| 185 | 185 | }); |
| 186 | ||
| 187 | test("the email preview reads like the page: singular, and no lot that is not a measurement", () => { | |
| 188 | const db = openDb(mkdtempSync(join(tmpdir(), "app-tty-"))); | |
| 189 | insertListing(db, me, { ...SAMPLE, beds: 1, baths: 1, lot: "Condo, no lot" }, [], null); | |
| 190 | const tty = session(db, me); | |
| 191 | tty.press("\r"); | |
| 192 | tty.press("p"); | |
| 193 | const shown = plain(tty.screen()); | |
| 194 | expect(shown).toContain("1 bed · 1 bath · 2,140 sq ft · 1928 built"); | |
| 195 | expect(shown).not.toContain("lot lot"); | |
| 196 | expect(shown).not.toContain("Condo, no lot"); | |
| 197 | expect(shown).not.toContain("1 beds"); | |
| 198 | }); |
src/tty.ts +2 −2
| @@ -34,7 +34,7 @@ import { | ||
| 34 | 34 | import { ssh, title, where } from "./env.ts"; |
| 35 | 35 | import { approvalLabel } from "./listing.ts"; |
| 36 | 36 | import { emailParts } from "./pages/email.ts"; |
| 37 | import { money, TONES, type Kind } from "./generate.ts"; | |
| 37 | import { factItems, money, TONES, type Kind } from "./generate.ts"; | |
| 38 | 38 | import type { Who } from "./who.ts"; |
| 39 | 39 | |
| 40 | 40 | const esc = "\x1b["; |
| @@ -98,7 +98,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 98 | 98 | ...(approved ? [] : [amber("DRAFT — not approved"), ""]), |
| 99 | 99 | bold(`Subject: ${subject}`), "", |
| 100 | 100 | bold(`${l.address} · ${money(l.price)}`), |
| 101 | dim(`${l.beds} beds · ${l.baths} baths · ${l.sqft.toLocaleString("en-US")} sq ft · ${l.lot} lot · ${l.year} built`), "", | |
| 101 | dim(factItems(l).map((i) => `${i.value} ${i.label}`).join(" · ")), "", | |
| 102 | 102 | ...paragraphs.flatMap((p) => [...p.split("\n"), ""]), |
| 103 | 103 | bold(l.agent_name), `${l.brokerage} · License ${l.license}`, `${l.agent_phone} · ${l.agent_email}`, "", |
| 104 | 104 | dim("Equal Housing Opportunity."), |
new above
offline