Public: anyone can read this; the crew can change it.

Mark a listing Coming soon and get a teaser post and email to approve before it goes live #57

Merged opened this

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

Merged

mason/listing-launch:mason/55-mark-a-listing-coming-soon-and-get-a-tea → main 2 commits fork 3 threads · 3 unresolved

  • Merged. mason/55-mark-a-listing-coming-soon-and-get-a-tea was merged into main as 2f24ef4.

Review

Sign in to review this change.
18 files changed +314 −39
README.md +8 −6
@@ -50,7 +50,9 @@ to make a listing of your own.
5050 flyer wears a "Sold" band, and the list on the first screen shows the
5151 status beside the approval count. *88 Meridian Street* is already sold, so
5252 you can see it. **Under contract** does the same with its own words, and
53 **Active** takes the two away again.
53 **Active** takes the two away again. **Coming soon** teases a home before it
54 is listed: a post and an email ("Coming soon — 412 Alder Lane", the price,
55 the facts, "Message me to be first to see it"), never a date you did not give.
5456 **Lower the price.** In **Edit facts**, a lower price adds a price
5557 improvement post (Social) and email ("New price — 412 Alder Lane, now
5658 $849,000, was $899,000") in the listing's tone, each Fair Housing checked
@@ -58,8 +60,8 @@ to make a listing of your own.
5860 the price back to the original, or above it, takes them away. The same
5961 goes in the terminal (press `r` on the listing) and for other tools.
6062 **Find one.** Above the list, **All · Active · Under contract · Sold ·
61 Needs approval** are links with their counts, and the search box matches
62 the address, any case, together with the filter.
63 Coming soon · Needs approval** are links with their counts, and the
64 search box matches the address, any case, together with the filter.
6365 9. **Print the flyer.** Press **Print flyer**: a one-page flyer opens with
6466 the photo, price, facts, description and your name. Print it or save it
6567 as a PDF.
@@ -303,7 +305,7 @@ curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -
303305 curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"facts":{"price":649000}}'
304306 # an open house (up to two; local date and 24-hour times as typed; [] clears)
305307 curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"facts":{"open_houses":[{"date":"2027-03-07","start":"13:00","end":"15:00"}]}}'
306 # where it stands: active, under_contract or sold (sold_price optional, whole dollars); adds or removes the status post and email
308 # where it stands: active, coming_soon, under_contract or sold (sold_price optional, whole dollars); adds or removes the status post and email
307309 curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"status":"sold","sold_price":752000}'
308310 curl -X DELETE https://<app>/api/listings/1
309311 ```
@@ -336,8 +338,8 @@ a real pty over the same data, as the account at the keyboard. Open a
336338 listing with `enter` to read every asset with its Fair Housing findings;
337339 `j`/`k` move between assets, `e` edits the one under the cursor (type `\n`
338340 for a line break, `enter` saves and checks it again), `t` rewrites them all
339 in the next tone, `s` sets the status (`sold 752000`, `under contract`, `active`), `x` removes a listing of your own. `tab` switches to the
340 notes. Launching a listing, with its photos, is the page's alone.
341 in the next tone, `s` sets the status (`sold 752000`, `under contract`, `coming soon`,
342 `active`), `x` removes a listing of your own. `tab` switches to the notes. Launching a listing, with its photos, is the page's alone.
341343
342344 No runtime dependencies: Bun's own `bun:sqlite`, `Bun.serve` and `node:`
343345 modules. The typefaces are the system's (a Palatino/Iowan serif for
contract/openapi.json +10 −6
@@ -320,9 +320,10 @@
320320 "active",
321321 "under_contract",
322322 "sold",
323 "coming_soon",
323324 "needs-approval"
324325 ],
325 "description": "Only listings that are active, under_contract, sold or needs-approval; anything else is all."
326 "description": "Only listings that are coming_soon, active, under_contract, sold or needs-approval; anything else is all."
326327 }
327328 },
328329 {
@@ -578,7 +579,7 @@
578579 }
579580 },
580581 "patch": {
581 "summary": "Edit one asset's words, withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, mark it active, under contract or sold ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.",
582 "summary": "Edit one asset's words, withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, mark it coming soon, active, under contract or sold ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.",
582583 "operationId": "patchApiListingsId",
583584 "parameters": [
584585 {
@@ -626,11 +627,12 @@
626627 },
627628 "status": {
628629 "enum": [
630 "coming_soon",
629631 "active",
630632 "under_contract",
631633 "sold"
632634 ],
633 "description": "Mark the listing active, under_contract or sold; the status post and email are written or removed, nothing else changes."
635 "description": "Mark the listing coming_soon, active, under_contract or sold; the status post and email are written or removed, nothing else changes."
634636 },
635637 "sold_price": {
636638 "type": [
@@ -787,7 +789,7 @@
787789 },
788790 "responses": {
789791 "200": {
790 "description": "Edit one asset's words, withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, mark it active, under contract or sold ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.",
792 "description": "Edit one asset's words, withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, mark it coming soon, active, under contract or sold ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.",
791793 "content": {
792794 "application/json": {
793795 "schema": {
@@ -1240,11 +1242,12 @@
12401242 },
12411243 "status": {
12421244 "enum": [
1245 "coming_soon",
12431246 "active",
12441247 "under_contract",
12451248 "sold"
12461249 ],
1247 "description": "Where the listing stands; under_contract and sold add a status post and a status email."
1250 "description": "Where the listing stands; coming_soon, under_contract and sold add a status post and a status email."
12481251 },
12491252 "sold_price": {
12501253 "type": [
@@ -1577,11 +1580,12 @@
15771580 },
15781581 "status": {
15791582 "enum": [
1583 "coming_soon",
15801584 "active",
15811585 "under_contract",
15821586 "sold"
15831587 ],
1584 "description": "Where the listing stands; under_contract and sold add a status post and a status email."
1588 "description": "Where the listing stands; coming_soon, under_contract and sold add a status post and a status email."
15851589 },
15861590 "sold_price": {
15871591 "type": [
src/actions.ts +4 −4
@@ -896,10 +896,10 @@ function changeFacts(
896896 }
897897
898898 /**
899 * Mark a listing active, under contract or sold (with the price it sold at,
900 * if the agent says). Under contract and sold add a status post and a status
901 * email in the listing's tone, each needing its own approval; active takes
902 * them away. The other assets, their edits and their approvals are left
899 * Mark a listing coming soon, active, under contract or sold (with the price it
900 * sold at, if the agent says). Coming soon, under contract and sold add a
outdated

Rewrap this comment; the second line runs far past the others.

901 * status post and a status email in the listing's tone, each needing its own
902 * approval; active takes them away. The other assets, their edits and their approvals are left
903903 * alone. Saying what is already so changes nothing.
904904 */
905905 export const setStatus = (
src/comingsoon.test.ts added +173
@@ -0,0 +1,173 @@
1 /**
2 * Coming soon: a fourth status with its own teaser post and email, offered
3 * first, reachable at every door, and added by a migration that rebuilds the
4 * listing table without losing a row, a photo name or an approval.
5 */
6 import { Database } from "bun:sqlite";
7 import { expect, test } from "bun:test";
8 import { mkdtempSync } from "node:fs";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { approveAsset, getListing, launchListing, listListings, setStatus } from "./actions.ts";
12 import { migrate, migrations, openDb } from "./db.ts";
13 import { check } from "./fairhousing.ts";
14 import { counts, parseFind } from "./find.ts";
15 import { STATUSES, TONES } from "./generate.ts";
16 import { kit } from "./kit.ts";
17 import { flyer } from "./pages/listing.ts";
18 import { SAMPLE } from "./sample.ts";
19 import type { Who } from "./who.ts";
20
21 const me: Who = { role: "user", id: "u1", name: "me" };
22 const tmp = (name: string) => mkdtempSync(join(tmpdir(), `app-${name}-`));
23
24 const launch = () => {
25 const db = openDb(tmp("soon"));
26 const made = launchListing(db, me, { fields: SAMPLE });
27 if (!made.ok) throw new Error(made.message);
28 return { db, id: made.value.id };
29 };
30 const get = (db: ReturnType<typeof openDb>, id: number) => {
31 const found = getListing(db, me, { id });
32 if (!found.ok) throw new Error(found.message);
33 return found.value;
34 };
35
36 test("coming soon is offered first, and the teaser says the facts only, in each tone", () => {
37 expect(STATUSES[0]).toBe("coming_soon");
38 for (const tone of TONES) {
39 const { db, id } = launch();
40 db.query("UPDATE listing SET tone = ?1 WHERE id = ?2").run(tone, id);
41 const soon = setStatus(db, me, { id, status: "coming_soon" });
42 if (!soon.ok) throw new Error(soon.message);
43 // Re-tone through the action so the words are written in the tone.
44 const post = get(db, id).assets.find((a) => a.kind === "social_status");
45 expect(post).toBeDefined();
46 const kinds = soon.value.assets.map((a) => a.kind);
47 expect(kinds).toContain("social_status");
48 expect(kinds).toContain("email_status");
49 expect(soon.value.assets).toHaveLength(8);
50 for (const a of soon.value.assets.filter((a) => a.kind.endsWith("_status"))) {
51 expect(a.text).toMatch(/coming soon/i);
52 expect(a.text).toContain("412 Alder Lane");
53 expect(a.text).toMatch(/first to see it/);
54 expect(a.text).not.toMatch(/\b(20\d\d|monday|tuesday|wednesday|thursday|friday|saturday|sunday|next week|this week)\b/i);
55 expect(check(a.text).findings).toEqual([]);
56 expect(a.approval).toBeNull();
57 }
58 expect(soon.value.assets.find((a) => a.kind === "email_status")!.text).toStartWith("Subject: Coming soon — 412 Alder Lane");
59 }
60 });
61
62 test("each is approved on its own, core approvals stay, and Active or another status replaces them", () => {
63 const { db, id } = launch();
64 const mls = get(db, id).assets.find((a) => a.kind === "mls")!;
65 expect(approveAsset(db, me, { id, kind: "mls", door: "page", seen: mls.updated_at }).ok).toBe(true);
66 const soon = setStatus(db, me, { id, status: "coming_soon" });
67 expect(soon.ok && soon.value.assets.filter((a) => a.approval !== null).map((a) => a.kind)).toEqual(["mls"]);
68 const summary = listListings(db, me);
69 expect(summary.ok && summary.value[0]!.approvals).toHaveLength(8);
70
71 const sold = setStatus(db, me, { id, status: "sold" });
72 expect(sold.ok && sold.value.assets.find((a) => a.kind === "social_status")!.text).toContain("Just sold");
73 const again = setStatus(db, me, { id, status: "coming_soon" });
74 expect(again.ok && again.value.assets.find((a) => a.kind === "social_status")!.text).toContain("Coming soon");
75 const back = setStatus(db, me, { id, status: "active" });
76 expect(back.ok && back.value.assets).toHaveLength(6);
77 expect(back.ok && back.value.assets.filter((a) => a.approval !== null)).toHaveLength(1);
78 expect(setStatus(db, me, { id, status: "coming-soon" })).toMatchObject({ ok: false, error: "invalid" });
79 });
80
81 test("the filter, its count, the flyer band, the page and the kit name the status", () => {
82 const { db, id } = launch();
83 setStatus(db, me, { id, status: "coming_soon" });
84 const all = listListings(db, me);
85 if (!all.ok) throw new Error(all.message);
86 expect(counts(all.value, "").coming_soon).toBe(1);
87 expect(counts(all.value, "").active).toBe(0);
88 expect(parseFind("coming-soon", "").status).toBe("coming_soon");
89 const only = listListings(db, me, { status: "coming_soon" });
90 expect(only.ok && only.value).toHaveLength(1);
91
92 const l = get(db, id);
93 expect(flyer(l)).toContain('class="flyer-status status-coming_soon"');
94 expect(flyer(l)).toContain("Coming soon");
95 expect(kit(l)).toContain("Status: Coming soon");
96 });
97
98 test("the web and API doors accept it and refuse the unknown with a 422", async () => {
99 const { serve } = await import("./server.ts");
100 const { sign } = await import("./who.ts");
101 const KEY = "a-key-only-the-forge-holds";
102 const server = serve({ port: 0, dataDir: tmp("soon-api"), identityKey: KEY });
103 try {
104 const base = `http://${server.hostname}:${server.port}`;
105 const mint = (name: string) => {
106 const identity = `${name}|${name}|user|${Date.now() + 600_000}`;
107 return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
108 };
109 const headers = { ...mint("soon-agent"), "content-type": "application/json" };
110 const made = (await (await fetch(`${base}/api/listings`, { method: "POST", headers, body: JSON.stringify(SAMPLE) })).json()) as { id: number };
111 const patch = (body: unknown) => fetch(`${base}/api/listings/${made.id}`, { method: "PATCH", headers, body: JSON.stringify(body) });
112 const ok = await patch({ status: "coming_soon" });
113 expect(ok.status).toBe(200);
114 expect(((await ok.json()) as { assets: unknown[] }).assets).toHaveLength(8);
115 expect((await patch({ status: "pending" })).status).toBe(422);
116
117 const post = (status: string) => {
118 const form = new FormData();
119 form.set("status", status);
120 form.set("sold_price", "");
121 return fetch(`${base}/listings/${made.id}/status`, { method: "POST", headers: mint("soon-agent"), body: form, redirect: "manual" });
122 };
123 expect((await post("active")).status).toBe(303);
124 expect((await post("coming_soon")).status).toBe(303);
125 expect((await post("pending")).status).toBe(422);
126 const home = await (await fetch(`${base}/?status=coming_soon`, { headers: mint("soon-agent") })).text();
127 expect(home).toContain("status-pill status-coming_soon");
128 expect(home).toContain("Coming soon <span");
129 const page = await (await fetch(`${base}/listings/${made.id}`, { headers: mint("soon-agent") })).text();
130 expect(page).toContain('<option value="coming_soon" selected>Coming soon</option>');
131 expect(page.indexOf('<option value="coming_soon"')).toBeLessThan(page.indexOf('<option value="active"'));
132 } finally {
133 server.stop(true);
134 }
135 });
136
137 test("the migration keeps every listing, photo name and approval", () => {
138 const dir = tmp("soon-mig");
139 const db = new Database(join(dir, "app.db"));
140 db.exec("PRAGMA foreign_keys = ON");
141 // A database as the previous migration list made it: replay all but the last, then add rows.
142 const last = migrations.length - 1;
143 const real = migrations.splice(last);
144 try {
145 migrate(db);
146 } finally {
147 migrations.push(...real);
148 }
149 const insert = db.query(
150 `INSERT INTO listing (author_id, author_name, address, beds, baths, sqft, lot, year, price, features, tone, palette,
151 agent_name, brokerage, license, agent_phone, agent_email, photos, headshot, open_houses, status, sold_price, was_price)
152 VALUES ('u1', 'me', ?1, 3, 2.5, 1800, '5,000 sq ft', 1990, 800000, '["a"]', 'warm', 'ink', 'A', 'B', 'L1', '555', '[email protected]',
153 ?2, 'face.jpg', '[]', ?3, ?4, ?5)`,
154 );
155 insert.run("1 Oak Lane, Portland, OR", '["p1.jpg","p2.jpg"]', "active", null, null);
156 insert.run("2 Elm Court, Portland, OR", "[]", "sold", 700000, 750000);
157 db.query("INSERT INTO asset (listing_id, kind, body, edited, approved_by_id, approved_by_name, approved_at, approved_via) VALUES (1, 'mls', 'words', 1, 'u1', 'me', '2026-01-01T00:00:00.000Z', 'page')").run();
158 db.query("INSERT INTO asset (listing_id, kind, body) VALUES (2, 'social_status', 'Just sold')").run();
159 const before = db.query("SELECT * FROM listing ORDER BY id").all();
160 const assetsBefore = db.query("SELECT * FROM asset ORDER BY listing_id, kind").all();
161 expect(() => db.query("UPDATE listing SET status = 'coming_soon' WHERE id = 1").run()).toThrow();
162
163 expect(migrate(db)).toBe(migrations.length);
164 expect(db.query("SELECT * FROM listing ORDER BY id").all()).toEqual(before);
165 expect(db.query("SELECT * FROM asset ORDER BY listing_id, kind").all()).toEqual(assetsBefore);
166 expect((db.query("PRAGMA foreign_key_check").all() as unknown[]).length).toBe(0);
167 expect((db.query("PRAGMA integrity_check").get() as { integrity_check: string }).integrity_check).toBe("ok");
168 db.query("UPDATE listing SET status = 'coming_soon' WHERE id = 1").run();
169 expect(() => db.query("UPDATE listing SET status = 'pending' WHERE id = 1").run()).toThrow();
170 // The cascade still guards the assets.
171 db.query("DELETE FROM listing WHERE id = 2").run();
172 expect(db.query("SELECT count(*) AS n FROM asset").get()).toEqual({ n: 1 });
173 });
src/db.ts +40
@@ -104,6 +104,46 @@ export const migrations: string[] = [
104104 approved_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), approved_via = 'sample'
105105 WHERE edited = 0 AND (approved_at IS NULL OR approved_by_id = 'sample')
106106 AND listing_id IN (SELECT id FROM listing WHERE author_id = 'sample' AND headshot = 'sample-agent.svg')`,
107 // Coming soon joins the statuses. SQLite cannot alter a CHECK, so the table is rebuilt; dropping it
108 // would cascade away its assets (foreign keys cannot be switched off inside this transaction), so
109 // both are held aside and put back, row for row, approvals and photo names included.
110 `CREATE TABLE listing_old AS SELECT * FROM listing;
111 CREATE TABLE asset_old AS SELECT * FROM asset;
112 DROP TABLE listing;
113 CREATE TABLE listing (
114 id INTEGER PRIMARY KEY,
115 author_id TEXT NOT NULL,
116 author_name TEXT NOT NULL,
117 address TEXT NOT NULL CHECK (length(address) BETWEEN 5 AND 120),
118 beds INTEGER NOT NULL,
119 baths REAL NOT NULL,
120 sqft INTEGER NOT NULL,
121 lot TEXT NOT NULL,
122 year INTEGER NOT NULL,
123 price INTEGER NOT NULL,
124 features TEXT NOT NULL,
125 tone TEXT NOT NULL CHECK (tone IN ('classic', 'warm', 'bold')),
126 palette TEXT NOT NULL,
127 agent_name TEXT NOT NULL,
128 brokerage TEXT NOT NULL,
129 license TEXT NOT NULL,
130 agent_phone TEXT NOT NULL,
131 agent_email TEXT NOT NULL,
132 photos TEXT NOT NULL DEFAULT '[]',
133 headshot TEXT,
134 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
135 updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
136 open_houses TEXT NOT NULL DEFAULT '[]',
137 status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('coming_soon', 'active', 'under_contract', 'sold')),
138 sold_price INTEGER,
139 was_price INTEGER
140 );
141 INSERT INTO listing (id, author_id, author_name, address, beds, baths, sqft, lot, year, price, features, tone, palette, agent_name, brokerage, license, agent_phone, agent_email, photos, headshot, created_at, updated_at, open_houses, status, sold_price, was_price) SELECT id, author_id, author_name, address, beds, baths, sqft, lot, year, price, features, tone, palette, agent_name, brokerage, license, agent_phone, agent_email, photos, headshot, created_at, updated_at, open_houses, status, sold_price, was_price FROM listing_old;
142 CREATE INDEX listing_author ON listing (author_id);
143 INSERT INTO asset (listing_id, kind, body, edited, updated_at, approved_by_id, approved_by_name, approved_at, approved_via)
144 SELECT listing_id, kind, body, edited, updated_at, approved_by_id, approved_by_name, approved_at, approved_via FROM asset_old;
145 DROP TABLE asset_old;
146 DROP TABLE listing_old;`,
107147 ];
108148
109149 export function openDb(dir = dataDir): Database {
src/find.test.ts +1 −1
@@ -23,5 +23,5 @@ test("each filter, search, and both together", () => {
2323 expect(f("needs-approval")).toEqual(["2 Elm Court"]);
2424 expect(f("all", "OAK")).toEqual(["1 Oak Lane", "3 Oak Road"]);
2525 expect(f("active", "oak")).toEqual(["1 Oak Lane"]);
26 expect(counts(rows, "oak")).toEqual({ all: 2, active: 1, under_contract: 1, sold: 0, "needs-approval": 0 });
26 expect(counts(rows, "oak")).toEqual({ all: 2, active: 1, under_contract: 1, sold: 0, coming_soon: 0, "needs-approval": 0 });
2727 });
src/find.ts +2 −1
@@ -6,7 +6,8 @@
66
77 import { STATUSES, type Status } from "./generate.ts";
88
9 export const FILTERS = ["all", ...STATUSES, "needs-approval"] as const;
9 /** The filters, in the order the list shows them; Coming soon follows the statuses that were there first. */
10 export const FILTERS = ["all", ...STATUSES.filter((s) => s !== "coming_soon"), "coming_soon", "needs-approval"] as const;
1011 export type Filter = (typeof FILTERS)[number];
1112
1213 export const Q_MAX = 100;
src/generate.ts +40 −4
@@ -44,22 +44,27 @@ export const LABELS: Readonly<Record<Kind, string>> = {
4444 email_price: "Price improvement email",
4545 };
4646
47 /** The assets that exist only while a listing is under contract or sold. */
47 /** The assets that exist only while a listing is coming soon, under contract or sold. */
4848 export const STATUS_KINDS = ["social_status", "email_status"] as const satisfies readonly Kind[];
4949
5050 /** The assets that exist only while the price stands below what it was. */
5151 export const PRICE_KINDS = ["social_price", "email_price"] as const satisfies readonly Kind[];
5252
53 export const STATUSES = ["active", "under_contract", "sold"] as const;
53 export const STATUSES = ["coming_soon", "active", "under_contract", "sold"] as const;
5454 export type Status = (typeof STATUSES)[number];
5555
5656 /** What a person calls each status. */
5757 export const STATUS_LABELS: Readonly<Record<Status, string>> = {
58 coming_soon: "Coming soon",
5859 active: "Active",
5960 under_contract: "Under contract",
6061 sold: "Sold",
6162 };
6263
64 /** "Sold at $752,000" for a sold listing with a price; the status's label otherwise. */
65 export const statusLine = (l: { status: Status; sold_price: number | null }): string =>
66 l.status === "sold" && l.sold_price !== null ? `Sold at ${money(l.sold_price)}` : STATUS_LABELS[l.status];
67
6368 /** One open house, as typed: the local date (YYYY-MM-DD) and 24-hour times (HH:MM). Never converted. */
6469 export interface OpenHouse {
6570 readonly date: string;
@@ -315,9 +320,39 @@ function email(f: Facts, a: Agent): string {
315320 return `Subject: ${subject}\n\n${when === null ? "" : `${when}\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}`;
316321 }
317322
323 /** What a teaser says of the home: the price, then beds · baths · square feet. Never a date. */
324 const teaserFacts = (f: Facts): string =>
325 `${money(f.price)} · ${plural(f.beds, "bedroom")} · ${plural(f.baths, "bath")} · ${count(f.sqft)} square feet`;
326
327 const comingSoonInvite = (tone: Tone): string =>
328 ({
329 classic: "Message me to be first to see it.",
330 warm: "Message me and I will make sure you are the first to see it.",
331 bold: "Message me to be first to see it. Don't wait.",
332 })[tone];
333
334 function comingSoonPost(f: Facts, a: Agent, where: string): string {
335 const line = {
336 classic: `Coming soon — ${where}.`,
337 warm: `Coming soon — ${where}! I am excited to share this one with you.`,
338 bold: `COMING SOON — ${where}.`,
339 }[f.tone];
340 return `${line} ${teaserFacts(f)}. ${comingSoonInvite(f.tone)} ${a.name}, ${a.brokerage}. #ComingSoon`;
341 }
342
343 function comingSoonEmail(f: Facts, a: Agent, where: string): string {
344 const open = {
345 classic: `I am pleased to share that ${f.address} is coming soon.`,
346 warm: `I have exciting news to share: ${f.address} is coming soon!`,
347 bold: `${f.address} is COMING SOON.`,
348 }[f.tone];
349 return `Subject: Coming soon — ${where}\n\n${open}\n\n${teaserFacts(f)}.\n\n${comingSoonInvite(f.tone)}\n\n${a.name}\n${a.brokerage} · License ${a.license}\n${a.phone} · ${a.email}`;
350 }
351
318352 /** The status announcement's opening, in the listing's tone: "Just sold" or "Under contract". */
319353 function statusPost(f: Facts, a: Agent): string {
320354 const where = street(f.address);
355 if (f.status === "coming_soon") return comingSoonPost(f, a, where);
321356 const sold = f.status === "sold";
322357 const price = sold && f.sold_price != null ? ` at ${money(f.sold_price)}` : "";
323358 const line = sold
@@ -347,6 +382,7 @@ function statusPost(f: Facts, a: Agent): string {
347382
348383 function statusEmail(f: Facts, a: Agent): string {
349384 const where = street(f.address);
385 if (f.status === "coming_soon") return comingSoonEmail(f, a, where);
350386 const sold = f.status === "sold";
351387 const subject = sold ? `Just sold — ${where}` : `Under contract — ${where}`;
352388 const price = sold && f.sold_price != null ? ` It sold at ${money(f.sold_price)}.` : "";
@@ -412,13 +448,13 @@ function priceEmail(f: Facts, a: Agent): string {
412448 return `Subject: New price — ${where}, now ${now}, was ${was}\n\n${open}\n\nThe facts: ${plural(f.beds, "bedroom")}, ${plural(f.baths, "bath")}, ${count(f.sqft)} square feet, built in ${f.year}.\n\n${close}\n\n${a.name}\n${a.brokerage} · License ${a.license}\n${a.phone} · ${a.email}`;
413449 }
414450
415 /** A listing's assets: the six, the two status ones when it is under contract or sold, the two price ones when the price was lowered. */
451 /** A listing's assets: the six, the two status ones unless it is active, the two price ones when the price was lowered. */
416452 export function generate(listing: {
417453 readonly facts: Facts;
418454 readonly agent: Agent;
419455 }): Partial<Record<Kind, string>> & Record<(typeof CORE_KINDS)[number], string> {
420456 const { facts: f, agent: a } = listing;
421 const announced = f.status === "under_contract" || f.status === "sold";
457 const announced = f.status === "coming_soon" || f.status === "under_contract" || f.status === "sold";
422458 const cut = priceCut(f);
423459 return {
424460 mls: mls(f),
src/kit.ts +2 −1
@@ -5,7 +5,7 @@
55 * permission; whoever may read the listing may read this.
66 */
77
8 import { factItems, money, openHouseLine } from "./generate.ts";
8 import { factItems, money, openHouseLine, statusLine } from "./generate.ts";
99 import { publicOrigin } from "./env.ts";
1010 import { propertyLine, type Asset, type Launched } from "./listing.ts";
outdated

A data module should not import a page renderer. Move statusLine somewhere neutral, or build it from STATUS_LABELS.

1111
@@ -49,6 +49,7 @@ export function kit(l: Launched, origin = publicOrigin): string {
4949 return `${[
5050 `LAUNCH KIT — ${oneLine(l.address)}`,
5151 `${money(l.price)}`,
52 ...(l.status === "active" ? [] : [`Status: ${oneLine(statusLine(l))}`]),
5253 factItems(l).map((i) => `${oneLine(String(i.value))} ${oneLine(i.label)}`).join(" · "),
5354 ...(open === null ? [] : [oneLine(open)]),
5455 `Agent: ${oneLine(l.agent_name)} · ${oneLine(l.brokerage)} · License ${oneLine(l.license)} · ${oneLine(l.agent_phone)} · ${oneLine(l.agent_email)}`,
src/mcp.ts +2 −2
@@ -101,7 +101,7 @@ export const tools: readonly Tool[] = [
101101 type: "object",
102102 properties: {
103103 limit: { type: "integer", minimum: 1, maximum: 500 },
104 status: { enum: [...FILTERS], description: "Only listings that are active, under_contract, sold or needs-approval; anything else is all." },
104 status: { enum: [...FILTERS], description: "Only listings that are coming_soon, active, under_contract, sold or needs-approval; anything else is all." },
105105 q: { type: "string", maxLength: Q_MAX, description: "Only listings whose address contains this, any case." },
106106 },
107107 },
@@ -239,7 +239,7 @@ export const tools: readonly Tool[] = [
239239 {
240240 name: "set_status",
241241 description:
242 "Mark a listing active, under_contract or sold (with sold_price in whole dollars, optional). Under contract and sold add a status post and a status email (\"Just sold — 412 Alder Lane\") in the listing's tone, each Fair Housing checked and each needing a person's approval; active removes them. The other assets and their approvals are left alone. Only the listing's agent or an admin can.",
242 "Mark a listing coming_soon, active, under_contract or sold (with sold_price in whole dollars, optional). Coming soon, under contract and sold add a status post and a status email (\"Just sold — 412 Alder Lane\") in the listing's tone, each Fair Housing checked and each needing a person's approval; active removes them. The other assets and their approvals are left alone. Only the listing's agent or an admin can.",
243243 inputSchema: {
244244 type: "object",
245245 properties: {
src/pages/home.ts +1
@@ -173,6 +173,7 @@ export function form(who: Who, draft: Draft, editing?: Editing): string {
173173
174174 const FILTER_LABELS: Readonly<Record<Filter, string>> = {
175175 all: "All",
176 coming_soon: STATUS_LABELS.coming_soon,
176177 active: STATUS_LABELS.active,
177178 under_contract: STATUS_LABELS.under_contract,
178179 sold: STATUS_LABELS.sold,
src/pages/listing.ts +3 −5
@@ -11,6 +11,7 @@ import {
1111 flyerParts,
1212 openHouseLine,
1313 money,
14 statusLine,
1415 STATUS_LABELS,
1516 STATUSES,
1617 TONES,
@@ -97,9 +98,6 @@ export const statusPill = (l: Pick<Listing, "status" | "sold_price">): string =>
9798 ? ""
9899 : `<span class="pill status-pill status-${l.status}">${escape(statusLine(l))}</span>`;
99100
100 export const statusLine = (l: Pick<Listing, "status" | "sold_price">): string =>
101 l.status === "sold" && l.sold_price !== null ? `Sold at ${money(l.sold_price)}` : STATUS_LABELS[l.status];
102
103101 /** The agent's way to say where the listing stands: a choice, the price it sold at, one button. */
104102 const statusForm = (l: Listing): string =>
105103 `<form class="row status-form" method="post" action="/listings/${l.id}/status">
@@ -107,7 +105,7 @@ const statusForm = (l: Listing): string =>
107105 <select class="input select" id="status" name="status">${STATUSES.map((s) => `<option value="${s}"${s === l.status ? " selected" : ""}>${STATUS_LABELS[s]}</option>`).join("")}</select>
108106 <label class="sr" for="sold_price">Sold price, optional</label>
109107 <input class="input" id="sold_price" name="sold_price" inputmode="numeric" maxlength="14" placeholder="Sold price (optional)" value="${l.sold_price === null ? "" : l.sold_price}">
110 <button class="button button-quiet" title="Under contract and sold add a status post and a status email; your other assets and approvals stay." aria-label="Set status for this listing">Set status</button>
108 <button class="button button-quiet" title="Coming soon, under contract and sold add a status post and a status email; your other assets and approvals stay." aria-label="Set status for this listing">Set status</button>
111109 </form>`;
112110
113111 /** The band an unapproved flyer or email wears, on screen and on paper; nothing once approved. */
@@ -259,7 +257,7 @@ export function flyer(l: Launched, origin = ""): string {
259257 ${l.status === "active" ? "" : `<p class="flyer-status status-${l.status}" role="note">${escape(statusLine(l))}</p>`}
260258 <div class="flyer-hero">
261259 ${hero === undefined ? `<div class="flyer-hero-empty brand-fill"></div>` : `<img src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="1200" height="800">`}
262 <p class="flyer-badge brand-fill">${l.status === "sold" ? "Just sold" : l.status === "under_contract" ? "Under contract" : "Just listed"}</p>
260 <p class="flyer-badge brand-fill">${l.status === "sold" ? "Just sold" : l.status === "active" ? "Just listed" : STATUS_LABELS[l.status]}</p>
263261 </div>
264262 <header class="flyer-head">
265263 <div>
src/pages/property.ts +2 −2
@@ -5,12 +5,12 @@
55 * approval, sees a notice that it is not public yet.
66 */
77
8 import { factItems, money, whenOf } from "../generate.ts";
8 import { factItems, money, statusLine, whenOf } from "../generate.ts";
99 import { escape } from "../http.ts";
1010 import { propertyPath, publicDescription, type Launched } from "../listing.ts";
1111 import type { Who } from "../who.ts";
1212 import { layout } from "./layout.ts";
13 import { photoUrl, statusLine } from "./listing.ts";
13 import { photoUrl } from "./listing.ts";
1414
1515 export function propertyPage(input: { who: Who; listing: Launched }): string {
1616 const { who, listing: l } = input;
src/routes.ts +5 −4
@@ -215,7 +215,7 @@ const LISTING_FIELDS = {
215215 ...FACTS,
216216 photos: { type: "array", items: { type: "string" }, maxItems: PHOTOS_MAX },
217217 headshot: { type: ["string", "null"] },
218 status: { enum: [...STATUSES], description: "Where the listing stands; under_contract and sold add a status post and a status email." },
218 status: { enum: [...STATUSES], description: "Where the listing stands; coming_soon, under_contract and sold add a status post and a status email." },
219219 sold_price: { type: ["integer", "null"], description: "Whole dollars; only a sold listing has one, and only if the agent said." },
220220 was_price: { type: ["integer", "null"], description: "Whole dollars: the price before it was lowered; set while the price stands below it, and then a price improvement post and email exist." },
221221 created_at: { type: "string", format: "date-time" },
@@ -293,7 +293,7 @@ const LISTING_CHANGE: Schema = {
293293 kind: { enum: [...KINDS] },
294294 text: { type: "string", minLength: 1, maxLength: ASSET_MAX },
295295 tone: { enum: [...TONES] },
296 status: { enum: [...STATUSES], description: "Mark the listing active, under_contract or sold; the status post and email are written or removed, nothing else changes." },
296 status: { enum: [...STATUSES], description: "Mark the listing coming_soon, active, under_contract or sold; the status post and email are written or removed, nothing else changes." },
297297 sold_price: { type: ["integer", "null"], description: "With status sold: the price it sold at, in whole dollars; leave out to say nothing." },
298298 facts: {
299299 type: "object",
@@ -675,6 +675,7 @@ export const routes: readonly Route[] = [
675675 if (form === undefined) return text("too large", 413);
676676 const id = Number(c.params.id);
677677 const done = setStatus(c.db, c.who, { id, status: form.get("status"), sold_price: form.get("sold_price") });
678 if (!done.ok && done.error === "invalid") return text(done.message, STATUS.invalid);
678679 return back(done.ok ? "Status saved." : done.message, `/listings/${id}`);
679680 },
680681 },
@@ -824,7 +825,7 @@ export const routes: readonly Route[] = [
824825 summary: "The listings, newest first, each with how many phrases the Fair Housing check flags. Filter by status and search the address.",
825826 query: {
826827 limit: LIMIT,
827 status: { enum: [...FILTERS], description: "Only listings that are active, under_contract, sold or needs-approval; anything else is all." },
828 status: { enum: [...FILTERS], description: "Only listings that are coming_soon, active, under_contract, sold or needs-approval; anything else is all." },
828829 q: { type: "string", maxLength: Q_MAX, description: "Only listings whose address contains this, any case." },
829830 },
830831 answer: list(LISTING_SUMMARY),
@@ -873,7 +874,7 @@ export const routes: readonly Route[] = [
873874 : editAsset(c.db, c.who, { id, kind: String(body.kind ?? ""), text: body.text }),
874875 );
875876 },
876 api: { summary: "Edit one asset's words, withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, mark it active, under contract or sold ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.", body: LISTING_CHANGE, answer: LISTING },
877 api: { summary: "Edit one asset's words, withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, mark it coming soon, active, under contract or sold ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.", body: LISTING_CHANGE, answer: LISTING },
877878 },
878879 {
879880 method: "DELETE",
src/sample.test.ts +2 −1
@@ -79,7 +79,8 @@ test("the migration approves an old sample once, and leaves edited or approved r
7979 db.run("UPDATE asset SET approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL");
8080 db.run(`UPDATE asset SET edited = 1 WHERE kind = 'flyer' AND listing_id = ${id}`);
8181 db.run(`UPDATE asset SET approved_by_id = 'u1', approved_by_name = 'Pat', approved_at = '2026-01-01T00:00:00Z', approved_via = 'page' WHERE kind = 'email' AND listing_id = ${id}`);
82 db.run("DELETE FROM schema_version WHERE version = (SELECT max(version) FROM schema_version)");
82 // Forget the sample-approval migration and the one after it (the status rebuild, which runs again harmlessly).
83 db.run("DELETE FROM schema_version WHERE version >= (SELECT max(version) FROM schema_version) - 1");
8384 migrate(db);
8485 const rows = () =>
8586 db.query<{ kind: string; approved_by_name: string | null }, [number]>("SELECT kind, approved_by_name FROM asset WHERE listing_id = ?1").all(id);
src/tty.test.ts +15
@@ -256,3 +256,18 @@ test("the listing view shows the property page URL, and that it is not public un
256256 expect(after).toContain(`Property page: ${url}`);
257257 expect(after).not.toContain("not public yet");
258258 });
259
260 test("s then `coming soon` sets the status, adds the teaser post and email, and names it", () => {
261 const { db, id } = fresh();
262 const tty = session(db, me);
263 tty.press("\r"); // open the listing
264 tty.press("s");
265 for (let i = 0; i < "active".length; i++) tty.press("\x7f"); // the box starts with the current status
266 for (const c of "coming soon") tty.press(c);
267 tty.press("\r");
268 const l = getListing(db, me, { id });
269 if (!l.ok) throw new Error(l.message);
270 expect(l.value.status).toBe("coming_soon");
271 expect(l.value.assets.filter((a) => a.kind === "social_status" || a.kind === "email_status")).toHaveLength(2);
272 expect(plain(tty.screen())).toContain("Status saved: coming soon");
273 });
src/tty.ts +2 −2
@@ -73,7 +73,7 @@ export const openFromLine = (line: string): { date: string; start: string; end:
7373 return { date: m?.[1] ?? r, start: m?.[2] ?? "", end: m?.[3] ?? "" };
7474 });
7575
76 /** The status in the one-line box: "sold 752000", "sold", "under contract" or "active". */
76 /** The status in the one-line box: "sold 752000", "sold", "under contract", "coming soon" or "active". */

The coming soon path through statusFromLine and the status box has no test. Add one that drives the session.

7777 export const statusToLine = (l: { status: string; sold_price: number | null }): string =>
7878 `${l.status.replace("_", " ")}${l.sold_price === null ? "" : ` ${l.sold_price}`}`;
7979 export const statusFromLine = (line: string): { status: string; sold_price: string } => {
@@ -196,7 +196,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
196196 }
197197 lines.push("");
198198 if (typing !== undefined) {
199 const label = typing.for === "note" ? "New note" : typing.for === "search" ? "Search by address (enter searches, empty clears, esc drops)" : typing.for === "price" ? "Price in whole dollars, like 849000; a lower price adds a price improvement post and email (enter saves, esc drops)" : typing.for === "status" ? "Status: active, under contract, or sold with an optional price, like sold 752000 (enter saves, esc drops)" : typing.for === "open" ? "Open house: date start-end, like 2027-03-09 13:00-15:00; separate two with ; (empty clears, enter saves, esc drops)" : `Edit (\\n breaks a line, enter saves, esc drops)`;
199 const label = typing.for === "note" ? "New note" : typing.for === "search" ? "Search by address (enter searches, empty clears, esc drops)" : typing.for === "price" ? "Price in whole dollars, like 849000; a lower price adds a price improvement post and email (enter saves, esc drops)" : typing.for === "status" ? "Status: coming soon, active, under contract, or sold with an optional price, like sold 752000 (enter saves, esc drops)" : typing.for === "open" ? "Open house: date start-end, like 2027-03-09 13:00-15:00; separate two with ; (empty clears, enter saves, esc drops)" : `Edit (\\n breaks a line, enter saves, esc drops)`;
200200 // The tail of what is typed, so the cursor stays on the screen.
201201 const room = Math.max(20, size.columns - 4);
202202 lines.push(dim(label), `${typed.length > room ? `…${typed.slice(-room + 1)}` : typed}${esc}7m ${esc}0m`);
static/app.css +2
@@ -954,6 +954,8 @@ main:focus {
954954 color: var(--color-ok-fg);
955955 background: var(--color-ok-bg);
956956 }
957 .status-pill.status-coming_soon,
958 .flyer-status.status-coming_soon,
957959 .status-pill.status-under_contract,
958960 .flyer-status.status-under_contract {
959961 color: var(--color-warn-fg);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs