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

Merged opened this

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

Merged

mason/listing-launch:mason/29-every-asset-reads-like-a-person-wrote-it → main 2 commits fork

  • Merged. mason/29-every-asset-reads-like-a-person-wrote-it was merged into main as 5e1ae54.

Review

Sign in to review this change.
6 files changed +121 −22
src/generate.test.ts +45 −1
@@ -6,15 +6,18 @@
66 import { expect, test } from "bun:test";
77 import { check } from "./fairhousing.ts";
88 import {
9 factItems,
10 factsLine,
911 flyerParts,
1012 generate,
1113 KINDS,
14 lotOf,
1215 street,
1316 TONES,
1417 type Facts,
1518 } from "./generate.ts";
1619 import { agentOf } from "./listing.ts";
17 import { SAMPLE } from "./sample.ts";
20 import { SAMPLE, SAMPLE_CONDO, SAMPLE_HOUSE } from "./sample.ts";
1821
1922 const agent = agentOf(SAMPLE);
2023 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
8184 });
8285 expect(check(words.mls).findings.map((f) => f.text)).toEqual(["Family-friendly"]);
8386 });
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 => {
6868 const bare = (line: string): string =>
6969 line.trim().replace(/\s+/g, " ").replace(/[.!?]+$/, "");
7070
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
71114 /** 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 [
74118 `${num(f.beds)} bd`,
75119 `${num(f.baths)} ba`,
76120 `${count(f.sqft)} sq ft`,
77 `${f.lot} lot`,
121 ...(lot === null ? [] : [`${lot} lot`]),
78122 `built ${f.year}`,
79123 ].join(" · ");
124 };
80125
81126 const signature = (a: Agent): string =>
82127 `${a.name} · ${a.brokerage} · Lic. ${a.license}`;
@@ -84,14 +129,15 @@ const signature = (a: Agent): string =>
84129 function mls(f: Facts): string {
85130 const where = f.address;
86131 const size = `${num(f.beds)}-bedroom, ${num(f.baths)}-bath`;
132 const sized = withArticle(size);
87133 const features = f.features.map(sentence).join(" ");
88134 switch (f.tone) {
89135 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.`;
91137 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)}.`;
93139 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.`;
95141 }
96142 }
97143
@@ -120,7 +166,7 @@ function socialMedium(f: Facts, a: Agent): string {
120166 warm: "I would love to show you around. Send me a message to set up a visit.",
121167 bold: "Showings are filling up. Message me today.",
122168 }[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`;
124170 }
125171
126172 function socialCarousel(f: Facts, a: Agent): string {
@@ -132,7 +178,7 @@ function socialCarousel(f: Facts, a: Agent): string {
132178 }[f.tone],
133179 `${money(f.price)} · ${num(f.beds)} bd · ${num(f.baths)} ba · ${count(f.sqft)} sq ft`,
134180 ...f.features.slice(0, 5).map(bare),
135 `Built ${f.year} on a ${f.lot} lot`,
181 `Built ${f.year}${lotPhrase(f, " on ")}`,
136182 `Showings with ${a.name}, ${a.brokerage} · ${a.phone} · Lic. ${a.license}`,
137183 ];
138184 return slides
@@ -143,7 +189,7 @@ function socialCarousel(f: Facts, a: Agent): string {
143189 /** The flyer's copy: a headline, a blank line, the paragraph. The page lays out the rest. */
144190 function flyer(f: Facts): string {
145191 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)}`,
147193 warm: `Welcome home to ${street(f.address)}`,
148194 bold: `${street(f.address)}. Just listed.`,
149195 }[f.tone];
@@ -153,7 +199,7 @@ function flyer(f: Facts): string {
153199 warm: "Come and see it for yourself.",
154200 bold: "See it before it is gone.",
155201 }[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}`;
157203 }
158204
159205 function email(f: Facts, a: Agent): string {
@@ -174,7 +220,7 @@ function email(f: Facts, a: Agent): string {
174220 warm: "If you or someone you know would like to see it, just reply to this email and I will set up a time.",
175221 bold: "Want in? Reply now and I will get you on the showing schedule.",
176222 }[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}`;
178224 }
179225
180226 /** All five assets (six texts: social is three posts), for one listing. */
src/pages/email.ts +2 −2
@@ -5,7 +5,7 @@
55 * computed styles when the agent copies it into their mail tool.
66 */
77
8 import { count, money } from "../generate.ts";
8 import { factItems, money } from "../generate.ts";
99 import { escape } from "../http.ts";
1010 import type { Launched } from "../listing.ts";
1111 import type { Who } from "../who.ts";
@@ -44,7 +44,7 @@ export function email(l: Launched): string {
4444 ${hero === undefined ? "" : `<img class="email-hero" src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="600" height="400">`}
4545 <h2 class="email-address brand">${escape(l.address)}</h2>
4646 <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>
4848 ${paragraphs.map((p) => `<p class="email-body">${escape(p).replace(/\n/g, "<br>")}</p>`).join("")}
4949 <div class="email-agent">
5050 ${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 @@
66
77 import type { Check, Finding } from "../fairhousing.ts";
88 import {
9 count,
9 factItems,
1010 factsLine,
1111 flyerParts,
1212 money,
@@ -223,11 +223,7 @@ export function flyer(l: Launched): string {
223223 <p class="flyer-price brand">${money(l.price)}</p>
224224 </header>
225225 <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 ")}
231227 </ul>
232228 <div class="flyer-body">
233229 <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
183183 expect(plain(guest.screen())).toContain("Only the listing's agent or an admin can edit it.");
184184 expect(mls()).toBe("A family-friendly street.");
185185 });
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 {
3434 import { ssh, title, where } from "./env.ts";
3535 import { approvalLabel } from "./listing.ts";
3636 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";
3838 import type { Who } from "./who.ts";
3939
4040 const esc = "\x1b[";
@@ -98,7 +98,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
9898 ...(approved ? [] : [amber("DRAFT — not approved"), ""]),
9999 bold(`Subject: ${subject}`), "",
100100 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(" · ")), "",
102102 ...paragraphs.flatMap((p) => [...p.split("\n"), ""]),
103103 bold(l.agent_name), `${l.brokerage} · License ${l.license}`, `${l.agent_phone} · ${l.agent_email}`, "",
104104 dim("Equal Housing Opportunity."),

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs