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

Take a listing off the market: mark it Withdrawn or Expired, and its property page says it is no longer available #78

Merged opened this

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

Merged

mason/listing-launch:mason/76-take-a-listing-off-the-market-mark-it-wi → main 7 commits fork 1 thread · 1 unresolved

  • Merged. mason/76-take-a-listing-off-the-market-mark-it-wi was merged into main as 22030df.

Review

Sign in to review this change.
20 files changed +435 −49
README.md +9 −3
@@ -83,6 +83,12 @@ to make a listing of your own.
8383 a "Back on market" post and email (address, price, open house if set, your
8484 contact) wait for approval; from sold or coming soon it just takes them
8585 away, and saying Active on an active listing changes nothing.
86 **Withdrawn** and **Expired** take a listing off the market without a sale:
87 its property page answers with the address and "This home is no longer on
88 the market" and shows no price, words, photos or open house; its launch
89 week and calendar file list nothing; open house dates drop off the flyer,
90 email and posts. Pick **Back on market** to relist it, the same way as from
91 under contract.
8692 **Coming soon** teases a home before it is listed: a post and an email
8793 ("Coming soon — 412 Alder Lane", the price, the facts, "Message me to be first to see it"), never a date you did not give.
8894 **Lower the price.** In **Edit facts**, a lower price adds a price
@@ -92,7 +98,7 @@ to make a listing of your own.
9298 the price back to the original, or above it, takes them away. The same
9399 goes in the terminal (press `r` on the listing) and for other tools.
94100 **Find one.** Above the list, **All · Active · Under contract · Sold ·
95 Coming soon · Needs approval** are links with their counts, and the
101 Coming soon · Off market · Needs approval** are links with their counts, and the
96102 search box matches the address, any case, together with the filter.
97103 10. **Print the flyer.** Press **Print flyer**: a one-page flyer opens with
98104 the photo, price, facts, description and your name. Print it or save it
@@ -385,7 +391,7 @@ curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -
385391 curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"facts":{"price":649000}}'
386392 # an open house (up to two; local date and 24-hour times as typed; [] clears)
387393 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"}]}}'
388 # where it stands: active, coming_soon, under_contract or sold (sold_price optional, whole dollars); adds or removes the status post and email
394 # where it stands: active, coming_soon, under_contract, sold, withdrawn or expired (sold_price optional, whole dollars); adds or removes the status post and email
389395 curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"status":"sold","sold_price":752000}'
390396 curl -X DELETE https://<app>/api/listings/1
391397 ```
@@ -419,7 +425,7 @@ listing with `enter` to read every asset with its Fair Housing findings;
419425 `j`/`k` move between assets, `e` edits the one under the cursor (type `\n`
420426 for a line break, `enter` saves and checks it again), `t` rewrites them all
421427 in the next tone, `s` sets the status (`sold 752000`, `under contract`, `coming soon`,
422 `active`), `p` previews the email as the client reads it, `P` the open house follow-up with the sign-in visitors it would go to, `V` lists those visitors (`a` adds one as `Name, email`, `d` removes the one under the cursor; only the listing's agent or an admin, a guest sees none), `x` removes a listing of your own. `tab` switches to the notes. Launching a listing, with its photos, is the page's alone.
428 `withdrawn`, `expired`, `active`), `p` previews the email as the client reads it, `P` the open house follow-up with the sign-in visitors it would go to, `V` lists those visitors (`a` adds one as `Name, email`, `d` removes the one under the cursor; only the listing's agent or an admin, a guest sees none), `x` removes a listing of your own. `tab` switches to the notes. Launching a listing, with its photos, is the page's alone.
423429
424430 No runtime dependencies: Bun's own `bun:sqlite`, `Bun.serve` and `node:`
425431 modules. The typefaces are the system's (a Palatino/Iowan serif for
contract/openapi.json +16 −9
@@ -359,9 +359,10 @@
359359 "under_contract",
360360 "sold",
361361 "coming_soon",
362 "off-market",
362363 "needs-approval"
363364 ],
364 "description": "Only listings that are coming_soon, active, under_contract, sold or needs-approval; anything else is all."
365 "description": "Only listings that are coming_soon, active, under_contract, sold, off-market or needs-approval; anything else is all."
365366 }
366367 },
367368 {
@@ -617,7 +618,7 @@
617618 }
618619 },
619620 "patch": {
620 "summary": "Edit one asset's words, put an earlier version back ({ kind, restore }), 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.",
621 "summary": "Edit one asset's words, put an earlier version back ({ kind, restore }), 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, sold, withdrawn or expired ({ 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.",
621622 "operationId": "patchApiListingsId",
622623 "parameters": [
623624 {
@@ -669,9 +670,11 @@
669670 "coming_soon",
670671 "active",
671672 "under_contract",
672 "sold"
673 "sold",
674 "withdrawn",
675 "expired"
673676 ],
674 "description": "Mark the listing coming_soon, active, under_contract or sold; the status post and email are written or removed, nothing else changes."
677 "description": "Mark the listing coming_soon, active, under_contract, sold, withdrawn or expired; the status post and email are written or removed, nothing else changes."
675678 },
676679 "sold_price": {
677680 "type": [
@@ -839,7 +842,7 @@
839842 },
840843 "responses": {
841844 "200": {
842 "description": "Edit one asset's words, put an earlier version back ({ kind, restore }), 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.",
845 "description": "Edit one asset's words, put an earlier version back ({ kind, restore }), 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, sold, withdrawn or expired ({ 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.",
843846 "content": {
844847 "application/json": {
845848 "schema": {
@@ -1295,9 +1298,11 @@
12951298 "coming_soon",
12961299 "active",
12971300 "under_contract",
1298 "sold"
1301 "sold",
1302 "withdrawn",
1303 "expired"
12991304 ],
1300 "description": "Where the listing stands; coming_soon, under_contract and sold add a status post and a status email."
1305 "description": "Where the listing stands; coming_soon, under_contract and sold add a status post and a status email; withdrawn and expired take the listing off the market."
13011306 },
13021307 "sold_price": {
13031308 "type": [
@@ -1634,9 +1639,11 @@
16341639 "coming_soon",
16351640 "active",
16361641 "under_contract",
1637 "sold"
1642 "sold",
1643 "withdrawn",
1644 "expired"
16381645 ],
1639 "description": "Where the listing stands; coming_soon, under_contract and sold add a status post and a status email."
1646 "description": "Where the listing stands; coming_soon, under_contract and sold add a status post and a status email; withdrawn and expired take the listing off the market."
16401647 },
16411648 "sold_price": {
16421649 "type": [
src/actions.ts +25 −3
@@ -11,7 +11,7 @@ import { record, type AuditRow } from "./audit.ts";
1111 import { permit } from "./contract.ts";
1212 import { earlierAt, type Earlier } from "./duplicate.ts";
1313 import { check, type Check } from "./fairhousing.ts";
14 import { generate, isBackOnMarket, KINDS, STATUS_KINDS, TONES, type Kind, type Status } from "./generate.ts";
14 import { generate, isBackOnMarket, isOffMarket, KINDS, STATUS_KINDS, TONES, withoutOpenHouse, type Kind, type Status } from "./generate.ts";
1515 import {
1616 agentOf,
1717 ASSET_MAX,
@@ -464,7 +464,29 @@ export function markStatus(
464464 "UPDATE listing SET status = ?1, sold_price = ?2, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?3 RETURNING *",
465465 )
466466 .get(stand.status, stand.sold_price, id)!;
467 writeAssets(db, fromRow(row), STATUS_KINDS, undefined, isBackOnMarket(before.status, stand.status));
467 // Leaving or re-entering the market changes the open house lines in the words: the assets nobody edited are written again.
468 const kinds =
469 isOffMarket(before.status) === isOffMarket(stand.status)
470 ? STATUS_KINDS
471 : KINDS.filter(
472 (k) =>
473 (STATUS_KINDS as readonly string[]).includes(k) ||
474 db.query("SELECT 1 FROM asset WHERE listing_id = ?1 AND kind = ?2 AND edited = 0").get(id, k) !== null,
475 );
476 writeAssets(db, fromRow(row), kinds, undefined, isBackOnMarket(before.status, stand.status));
477 // Words a person edited keep everything they wrote but the open house, which an off-market listing no longer has.
478 if (isOffMarket(stand.status) && !isOffMarket(before.status)) {
479 const facts = { ...fromRow(row), status: "active" as const };
480 const edited = db.query<{ kind: string; body: string }, [number]>("SELECT kind, body FROM asset WHERE listing_id = ?1 AND edited = 1").all(id);
481 const put = db.query(
482 `UPDATE asset SET body = ?3, approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL,
483 updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE listing_id = ?1 AND kind = ?2`,
484 );
485 for (const a of edited) {
486 const body = withoutOpenHouse(a.body, facts);
487 if (body !== a.body) put.run(id, a.kind, body);
488 }
489 }
468490 return launched(db, id)!;
469491 })();
470492 }
@@ -559,7 +581,7 @@ export const getListing = (
559581 */
560582 export const getHome = (db: Database, who: Who, input: { id: number }): Result<Launched> => {
561583 const found = getListing(db, who, input);
562 if (found.ok && publicDescription(found.value) === null && !canEditListing(who, found.value))
584 if (found.ok && !isOffMarket(found.value.status) && publicDescription(found.value) === null && !canEditListing(who, found.value))
563585 return fail("not_found", "There is no such home.");
564586 return found;
565587 };
src/db.ts +68
@@ -177,6 +177,53 @@ export const migrations: string[] = [
177177 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
178178 );
179179 CREATE INDEX IF NOT EXISTS visitor_listing ON visitor (listing_id, id)`,
180 // Withdrawn and Expired join the statuses: the table is rebuilt again, holding assets, their versions and the visitors aside.
181 `CREATE TABLE listing_old AS SELECT * FROM listing;
182 CREATE TABLE asset_old AS SELECT * FROM asset;
183 CREATE TABLE asset_version_old AS SELECT * FROM asset_version;
184 CREATE TABLE visitor_old AS SELECT * FROM visitor;
185 DROP TABLE listing;
186 CREATE TABLE listing (
187 id INTEGER PRIMARY KEY,
188 author_id TEXT NOT NULL,
189 author_name TEXT NOT NULL,
190 address TEXT NOT NULL CHECK (length(address) BETWEEN 5 AND 120),
191 beds INTEGER NOT NULL,
192 baths REAL NOT NULL,
193 sqft INTEGER NOT NULL,
194 lot TEXT NOT NULL,
195 year INTEGER NOT NULL,
196 price INTEGER NOT NULL,
197 features TEXT NOT NULL,
198 tone TEXT NOT NULL CHECK (tone IN ('classic', 'warm', 'bold')),
199 palette TEXT NOT NULL,
200 agent_name TEXT NOT NULL,
201 brokerage TEXT NOT NULL,
202 license TEXT NOT NULL,
203 agent_phone TEXT NOT NULL,
204 agent_email TEXT NOT NULL,
205 photos TEXT NOT NULL DEFAULT '[]',
206 headshot TEXT,
207 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
208 updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
209 open_houses TEXT NOT NULL DEFAULT '[]',
210 status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('coming_soon', 'active', 'under_contract', 'sold', 'withdrawn', 'expired')),
211 sold_price INTEGER,
212 was_price INTEGER,
213 photo_alt TEXT NOT NULL DEFAULT '{}'
214 );
215 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, photo_alt) 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, photo_alt FROM listing_old;
216 CREATE INDEX listing_author ON listing (author_id);
217 INSERT INTO asset (listing_id, kind, body, edited, updated_at, approved_by_id, approved_by_name, approved_at, approved_via)
218 SELECT listing_id, kind, body, edited, updated_at, approved_by_id, approved_by_name, approved_at, approved_via FROM asset_old;
219 INSERT INTO asset_version (id, listing_id, kind, body, replaced_at, by_id, by_name, reason)
220 SELECT id, listing_id, kind, body, replaced_at, by_id, by_name, reason FROM asset_version_old;
221 INSERT INTO visitor (id, listing_id, name, email, created_at)
222 SELECT id, listing_id, name, email, created_at FROM visitor_old;
223 DROP TABLE visitor_old;
224 DROP TABLE asset_version_old;
225 DROP TABLE asset_old;
226 DROP TABLE listing_old;`,
180227 ];
181228
182229 export function openDb(dir = dataDir): Database {
@@ -189,6 +236,17 @@ export function openDb(dir = dataDir): Database {
189236 return db;
190237 }
191238
239 function hasTable(db: Database, table: string): boolean {
240 return db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1").get(table) !== null;
241 }
242
243 function hasColumn(db: Database, table: string, column: string): boolean {
244 return db
245 .query<{ name: string }, []>(`PRAGMA table_info(${table})`)
246 .all()
247 .some((c) => c.name === column);
248 }
249
192250 /** Apply the migrations past the recorded version, all or none. Returns the version. */
193251 export function migrate(db: Database): number {
194252 db.exec(
@@ -201,6 +259,16 @@ export function migrate(db: Database): number {
201259 []
202260 >("SELECT coalesce(max(version), 0) AS version FROM schema_version")
203261 .get()!.version;
262 // A database migrated while the status rebuild sat before the visitor table and the photo_alt column
263 // (an earlier draft of this list) has skipped them; put them back before the later steps rely on them.
264 const visitor = migrations.findIndex((sql) => sql.includes("CREATE TABLE IF NOT EXISTS visitor"));
265 if (visitor >= 0 && current > visitor && !hasTable(db, "visitor")) {
266 db.exec(migrations[visitor]!);
267 }
268 const photoAlt = migrations.findIndex((sql) => sql.includes("ADD COLUMN photo_alt"));
269 if (photoAlt >= 0 && current > photoAlt && !hasColumn(db, "listing", "photo_alt")) {
270 db.exec(migrations[photoAlt]!);
271 }
204272 migrations.slice(current).forEach((sql, i) => {
205273 db.exec(sql);
206274 db.query("INSERT INTO schema_version (version) VALUES (?1)").run(
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, coming_soon: 0, "needs-approval": 0 });
26 expect(counts(rows, "oak")).toEqual({ all: 2, active: 1, under_contract: 1, sold: 0, coming_soon: 0, "off-market": 0, "needs-approval": 0 });
2727 });
src/find.ts +13 −5
@@ -4,10 +4,16 @@
44 * or search text is read, and the match is done in memory, never in SQL text.
55 */
66
7 import { STATUSES, type Status } from "./generate.ts";
7 import { isOffMarket, STATUSES, type Status } from "./generate.ts";
88
99 /** 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;
10 export const FILTERS = [
11 "all",
12 ...STATUSES.filter((s) => s !== "coming_soon" && !isOffMarket(s)),
13 "coming_soon",
14 "off-market",
15 "needs-approval",
16 ] as const;
1117 export type Filter = (typeof FILTERS)[number];
1218
1319 export const Q_MAX = 100;
@@ -36,9 +42,11 @@ interface Findable {
3642 export const matchesStatus = (l: Findable, status: Filter): boolean =>
3743 status === "all"
3844 ? true
39 : status === "needs-approval"
40 ? l.approvals.some((a) => !a.approved)
41 : l.status === status;
45 : status === "off-market"
46 ? isOffMarket(l.status)
47 : status === "needs-approval"
48 ? l.approvals.some((a) => !a.approved)
49 : l.status === status;
4250
4351 export const matchesText = (l: Findable, q: string): boolean =>
4452 q === "" || l.address.toLowerCase().includes(q.toLowerCase());
src/generate.ts +28 −4
@@ -52,7 +52,7 @@ export const STATUS_KINDS = ["social_status", "email_status"] as const satisfies
5252 /** The assets that exist only while the price stands below what it was. */
5353 export const PRICE_KINDS = ["social_price", "email_price"] as const satisfies readonly Kind[];
5454
55 export const STATUSES = ["coming_soon", "active", "under_contract", "sold"] as const;
55 export const STATUSES = ["coming_soon", "active", "under_contract", "sold", "withdrawn", "expired"] as const;
5656 export type Status = (typeof STATUSES)[number];
5757
5858 /** What a person calls each status. */
@@ -61,10 +61,16 @@ export const STATUS_LABELS: Readonly<Record<Status, string>> = {
6161 active: "Active",
6262 under_contract: "Under contract",
6363 sold: "Sold",
64 withdrawn: "Withdrawn",
65 expired: "Expired",
6466 };
6567
66 /** The one transition that announces itself on an active listing: a deal that fell through. */
67 export const isBackOnMarket = (from: Status, to: Status): boolean => from === "under_contract" && to === "active";
68 /** Whether the listing left the market without selling: nothing public advertises it. */
69 export const isOffMarket = (status: Status): boolean => status === "withdrawn" || status === "expired";
70
71 /** The transitions that announce themselves on an active listing: a deal that fell through, or a listing put back after it left the market. */
72 export const isBackOnMarket = (from: Status, to: Status): boolean =>
73 (from === "under_contract" || isOffMarket(from)) && to === "active";
6874
6975 /** "Sold at $752,000" for a sold listing with a price; the status's label otherwise. */
7076 export const statusLine = (l: { status: Status; sold_price: number | null }): string =>
@@ -213,7 +219,7 @@ export const whenShort = (o: OpenHouse): string => {
213219
214220 /** The open house as a person would say it, in the listing's tone; null when there is none. */
215221 export const openHouseLine = (f: Facts): string | null => {
216 const when = (f.open_houses ?? []).map(whenOf).join(" and ");
222 const when = isOffMarket(f.status) ? "" : (f.open_houses ?? []).map(whenOf).join(" and ");
217223 if (when === "") return null;
218224 return {
219225 classic: `Open ${when}.`,
@@ -222,6 +228,24 @@ export const openHouseLine = (f: Facts): string | null => {
222228 }[f.tone];
223229 };
224230
231 /**
232 * A person's edited words with the open house taken out: the generated line
233 * (in any tone) and any other line that names one of the open house times.
234 * Everything else they wrote stays as written.
235 */
236 export function withoutOpenHouse(text: string, f: Facts): string {
237 const whens = (f.open_houses ?? []).map(whenOf);
238 if (whens.length === 0) return text;
239 const lines = TONES.map((tone) => openHouseLine({ ...f, status: "active", tone }));
240 let out = text;
241 for (const line of lines) if (line !== null) out = out.split(line).join("");
242 out = out
243 .split("\n")
244 .filter((l) => !whens.some((w) => l.includes(w)))
245 .join("\n");
246 return out.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").replace(/ {2,}/g, " ").trim();
247 }
248
225249 const signature = (a: Agent): string =>
226250 `${a.name} · ${a.brokerage} · Lic. ${a.license}`;
227251
src/listing.ts +2
@@ -7,6 +7,7 @@
77
88 import { check, type Check } from "./fairhousing.ts";
99 import {
10 isOffMarket,
1011 KINDS,
1112 LABELS,
1213 STATUSES,
@@ -387,6 +388,7 @@ export const propertyLine = (l: Launched, origin: string): string =>
387388
388389 /** The MLS description as buyers may read it: its words once approved, else null. */
389390 export const publicDescription = (l: Launched): string | null => {
391 if (isOffMarket(l.status)) return null;
390392 const mls = l.assets.find((a) => a.kind === "mls");
391393 return mls === undefined || mls.approval === null ? null : mls.text;
392394 };
src/mcp.ts +2 −2
@@ -103,7 +103,7 @@ export const tools: readonly Tool[] = [
103103 type: "object",
104104 properties: {
105105 limit: { type: "integer", minimum: 1, maximum: 500 },
106 status: { enum: [...FILTERS], description: "Only listings that are coming_soon, active, under_contract, sold or needs-approval; anything else is all." },
106 status: { enum: [...FILTERS], description: "Only listings that are coming_soon, active, under_contract, sold, off-market or needs-approval; anything else is all." },
107107 q: { type: "string", maxLength: Q_MAX, description: "Only listings whose address contains this, any case." },
108108 },
109109 },
@@ -261,7 +261,7 @@ export const tools: readonly Tool[] = [
261261 {
262262 name: "set_status",
263263 description:
264 "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, except from under_contract, where it writes \"Back on market\" words instead. The other assets and their approvals are left alone. Only the listing's agent or an admin can.",
264 "Mark a listing coming_soon, active, under_contract, sold, withdrawn or expired (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; withdrawn and expired take the listing off the market (its property page says so and shows nothing else; its launch week lists nothing); active removes them, except from under_contract, withdrawn or expired, where it writes \"Back on market\" words instead. The other assets and their approvals are left alone. Only the listing's agent or an admin can.",
265265 inputSchema: {
266266 type: "object",
267267 properties: {
src/migrate-photo-alt.test.ts added +53
@@ -0,0 +1,53 @@
1 import { expect, test } from "bun:test";
2 import { Database } from "bun:sqlite";
3 import { migrate, migrations } from "./db";
4
5 test("a database that rebuilt listing before photo_alt existed gets the column back", () => {
6 const i = migrations.findIndex((sql) => sql.includes("ADD COLUMN photo_alt"));
7 const rebuild = migrations.find((sql, k) => k > i && sql.includes("CREATE TABLE listing_old"))!
8 .replace(/, photo_alt/g, "")
9 .replace("was_price INTEGER,\n photo_alt TEXT NOT NULL DEFAULT '{}'", "was_price INTEGER");
10 const db = new Database(":memory:");
11 db.exec("PRAGMA foreign_keys = ON");
12 db.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT 'x')");
13 [...migrations.slice(0, i), migrations.find((sql) => sql.includes("TABLE IF NOT EXISTS visitor"))!, rebuild].forEach((sql, k) => {
14 db.exec(sql);
15 db.query("INSERT INTO schema_version (version) VALUES (?1)").run(k + 1);
16 });
17 expect(migrate(db)).toBe(migrations.length);
18 const cols = db.query<{ name: string }, []>("PRAGMA table_info(listing)").all();
19 expect(cols.some((c) => c.name === "photo_alt")).toBe(true);
20 });
21
22 test("the status rebuild keeps the open house visitors", () => {
23 const rebuild = migrations.findIndex((sql) => sql.includes("CREATE TABLE listing_old") && sql.includes("'withdrawn'"));
24 const db = new Database(":memory:");
25 db.exec("PRAGMA foreign_keys = ON");
26 db.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT 'x')");
27 migrations.slice(0, rebuild).forEach((sql, k) => {
28 db.exec(sql);
29 db.query("INSERT INTO schema_version (version) VALUES (?1)").run(k + 1);
30 });
31 db.exec(`INSERT INTO listing (author_id, author_name, address, beds, baths, sqft, lot, year, price, features, tone, palette, agent_name, brokerage, license, agent_phone, agent_email, photos)
32 VALUES ('pat', 'n', '1 Old Road, Town', 1, 1, 1, 'l', 1999, 1, '[]', 'warm', 'teal', 'a', 'b', 'c', 'd', 'e', '[]')`);
33 db.exec("INSERT INTO visitor (listing_id, name, email) VALUES (1, 'Sam', '[email protected]')");
34 migrate(db);
35 expect(db.query("SELECT name FROM visitor WHERE listing_id = 1").all()).toEqual([{ name: "Sam" }]);
36 });
37
38 test("a database that rebuilt listing before the visitor table existed gets the table back", () => {
39 const visitor = migrations.findIndex((sql) => sql.includes("TABLE IF NOT EXISTS visitor"));
40 const rebuild = migrations
41 .find((sql, k) => k > visitor && sql.includes("CREATE TABLE listing_old"))!
42 .replace(" CREATE TABLE visitor_old AS SELECT * FROM visitor;\n", "")
43 .replace(/ INSERT INTO visitor[\s\S]*?DROP TABLE visitor_old;\n/, "");
44 const db = new Database(":memory:");
45 db.exec("PRAGMA foreign_keys = ON");
46 db.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT 'x')");
47 [...migrations.slice(0, visitor), rebuild].forEach((sql, k) => {
48 db.exec(sql);
49 db.query("INSERT INTO schema_version (version) VALUES (?1)").run(k + 1);
50 });
51 expect(migrate(db)).toBe(migrations.length);
52 expect(db.query("SELECT count(*) AS n FROM visitor").get()).toEqual({ n: 0 });
53 });
src/offmarket.test.ts added +172
@@ -0,0 +1,172 @@
1 /**
2 * Off the market: Withdrawn and Expired are statuses the agent can set; the
3 * property page stops advertising, the launch week empties, open houses drop
4 * off the words, the list has an Off market filter, and Back on market works
5 * from either.
6 */
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, editAsset, getListing, launchListing, setStatus } from "./actions.ts";
12 import { openDb } from "./db.ts";
13 import { counts, matchesStatus, parseFind } from "./find.ts";
14 import { generate } from "./generate.ts";
15 import { agentOf, validateStatus } from "./listing.ts";
16 import { rpc } from "./mcp.ts";
17 import { propertyPage } from "./pages/property.ts";
18 import { statusForm, statusPill } from "./pages/listing.ts";
19 import { scheduleIcs, scheduleRows } from "./schedule.ts";
20 import { SAMPLE } from "./sample.ts";
21 import { statusFromLine, statusToLine } from "./tty.ts";
22 import type { Who } from "./who.ts";
23
24 const me: Who = { role: "user", id: "u1", name: "me" };
25 const anon: Who = { role: "anon" };
26 const open_houses = [{ date: "2027-03-09", start: "13:00", end: "15:00" }];
27
28 const launch = () => {
29 const db = openDb(mkdtempSync(join(tmpdir(), "app-off-")));
30 const made = launchListing(db, me, { fields: { ...SAMPLE, open_houses } });
31 if (!made.ok) throw new Error(made.message);
32 const id = made.value.id;
33 for (const a of made.value.assets) approveAsset(db, me, { id, kind: a.kind, door: "page", seen: a.updated_at });
34 return { db, id };
35 };
36 const get = (db: ReturnType<typeof openDb>, id: number, who: Who = me) => {
37 const found = getListing(db, who, { id });
38 if (!found.ok) throw new Error(found.message);
39 return found.value;
40 };
41
42 test("the status check accepts withdrawn and expired and refuses the rest plainly", () => {
43 for (const status of ["withdrawn", "expired"]) expect(validateStatus({ status })).toEqual({ ok: true, value: { status, sold_price: null } });
44 const bad = validateStatus({ status: "pending" });
45 expect(bad.ok).toBe(false);
46 if (!bad.ok) expect(bad.message).toContain("withdrawn, expired");
47 expect(validateStatus({ status: "withdrawn", sold_price: "5" }).ok).toBe(false);
48 });
49
50 test("a withdrawn or expired property page says so and shows no approved words, price or open house", () => {
51 for (const status of ["withdrawn", "expired"] as const) {
52 const { db, id } = launch();
53 const on = propertyPage({ who: anon, listing: get(db, id) });
54 expect(on).toContain("$689,000");
55 expect(on).toContain("Open house");
56 expect(setStatus(db, me, { id, status }).ok).toBe(true);
57 const html = propertyPage({ who: anon, listing: get(db, id) });
58 expect(html).toContain("412 Alder Lane");
59 expect(html).toContain("This home is no longer on the market");
60 expect(html).toContain(status === "withdrawn" ? "Withdrawn" : "Expired");
61 expect(html).not.toContain("$689,000");
62 expect(html).not.toContain("Open house");
63 expect(html).not.toContain("home-words\">" + get(db, id).assets.find((a) => a.kind === "mls")!.text.slice(0, 20));
64 expect(html).not.toContain("home-facts");
65 expect(html).not.toContain("home-hero");
66 }
67 });
68
69 test("the page route answers 200 to a stranger, even for a listing never approved", async () => {
70 const { serve } = await import("./server.ts");
71 const { sign } = await import("./who.ts");
72 const KEY = "a-key-only-the-forge-holds";
73 const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-off-http-")), identityKey: KEY });
74 try {
75 const base = `http://${server.hostname}:${server.port}`;
76 const identity = `agent|agent|user|${Date.now() + 600_000}`;
77 const headers = { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY), "content-type": "application/json" };
78 const made = (await (await fetch(`${base}/api/listings`, { method: "POST", headers, body: JSON.stringify(SAMPLE) })).json()) as { id: number };
79 expect((await fetch(`${base}/homes/${made.id}`)).status).toBe(404);
80 const set = await fetch(`${base}/api/listings/${made.id}`, { method: "PATCH", headers, body: JSON.stringify({ status: "expired" }) });
81 expect(set.status).toBe(200);
82 const res = await fetch(`${base}/homes/${made.id}`);
83 expect(res.status).toBe(200);
84 const html = await res.text();
85 expect(html).toContain("This home is no longer on the market");
86 expect(html).not.toContain("$689,000");
87 expect(await (await fetch(`${base}/listings/${made.id}/schedule`, { headers })).text()).toContain("nothing further goes out");
88 } finally {
89 server.stop(true);
90 }
91 });
92
93 test("the launch week and the calendar list nothing, and open houses leave the words", () => {
94 const { db, id } = launch();
95 expect(scheduleRows(get(db, id)).length).toBeGreaterThan(0);
96 for (const status of ["withdrawn", "expired"] as const) {
97 setStatus(db, me, { id, status });
98 const l = get(db, id);
99 expect(scheduleRows(l)).toEqual([]);
100 expect(scheduleIcs(l, "https://x.test")).not.toContain("BEGIN:VEVENT");
101 for (const a of l.assets) expect(a.text).not.toMatch(/open house|Open Mar|OPEN HOUSE/i);
102 const words = generate({ facts: { ...l, open_houses }, agent: agentOf(l) });
103 for (const text of Object.values(words)) expect(text).not.toMatch(/open house|OPEN HOUSE/i);
104 expect(l.assets.some((a) => a.kind === "social_status")).toBe(false);
105 }
106 });
107
108 test("the list has an Off market filter, and the pill reads Withdrawn or Expired", () => {
109 expect(parseFind("off-market", "").status).toBe("off-market");
110 const rows = (["active", "withdrawn", "expired", "sold"] as const).map((status) => ({ address: status, status, approvals: [] }));
111 expect(rows.filter((r) => matchesStatus(r, "off-market")).map((r) => r.status)).toEqual(["withdrawn", "expired"]);
112 expect(counts(rows, "")["off-market"]).toBe(2);
113 expect(statusPill({ status: "withdrawn", sold_price: null })).toContain("Withdrawn");
114 expect(statusPill({ status: "expired", sold_price: null })).toContain("Expired");
115 });
116
117 test("Back on market works from withdrawn and expired as from under contract", () => {
118 for (const status of ["withdrawn", "expired"] as const) {
119 const { db, id } = launch();
120 setStatus(db, me, { id, status });
121 expect(statusForm(get(db, id))).toContain(">Back on market</option>");
122 expect(setStatus(db, me, { id, status: "active" }).ok).toBe(true);
123 const l = get(db, id);
124 expect(l.assets.find((a) => a.kind === "social_status")!.text).toContain("#BackOnMarket");
125 expect(l.assets.some((a) => a.kind === "email_status")).toBe(true);
126 expect(scheduleRows(l).length).toBeGreaterThan(0);
127 expect(l.assets.find((a) => a.kind === "mls")!.text).toContain("open house");
128 }
129 });
130
131 test("the terminal's line and MCP set the same statuses", () => {
132 expect(statusFromLine("withdrawn")).toEqual({ status: "withdrawn", sold_price: "" });
133 expect(statusToLine({ status: "expired", sold_price: null })).toBe("expired");
134 const { db, id } = launch();
135 const call = (name: string, args: object) =>
136 JSON.stringify(rpc(db, me, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }).body);
137 expect(call("set_status", { id, status: "withdrawn" })).toContain("withdrawn");
138 expect(get(db, id).status).toBe("withdrawn");
139 expect(call("list_listings", { status: "off-market" })).toContain("412 Alder Lane");
140 expect(call("set_status", { id, status: "pending" })).toContain("invalid");
141 });
142
143 test("existing rows keep their statuses and assets across the migration", () => {
144 const dir = mkdtempSync(join(tmpdir(), "app-off-mig-"));
145 const db = openDb(dir);
146 const made = launchListing(db, me, { fields: SAMPLE });
147 if (!made.ok) throw new Error(made.message);
148 setStatus(db, me, { id: made.value.id, status: "sold", sold_price: 700000 });
149 db.close();
150 const again = openDb(dir);
151 const l = get(again, made.value.id);
152 expect(l).toMatchObject({ status: "sold", sold_price: 700000 });
153 expect(l.assets.length).toBe(8);
154 });
155
156 test("a person's edited flyer, email and post lose their open house line but keep their other words", () => {
157 const { db, id } = launch();
158 for (const kind of ["flyer", "email", "social_short"] as const) {
159 const text = get(db, id).assets.find((a) => a.kind === kind)!.text;
160 expect(text).toMatch(/open/i);
161 const mine = `My own opening words. ${text} Call me any time.\nSee you at the open house: Tuesday, March 9, 1–3 pm.`;
162 expect(editAsset(db, me, { id, kind, text: mine }).ok).toBe(true);
163 }
164 setStatus(db, me, { id, status: "withdrawn" });
165 for (const kind of ["flyer", "email", "social_short"] as const) {
166 const a = get(db, id).assets.find((x) => x.kind === kind)!;
167 expect(a.text).not.toMatch(/March 9|open house/i);
168 expect(a.text).toContain("My own opening words.");
169 expect(a.text).toContain("Call me any time.");
170 expect(a.approval).toBeNull();
171 }
172 });
src/pages/card.ts +2 −1
@@ -5,7 +5,7 @@
55 * asset text. The agent saves it as a PDF or a screenshot.
66 */
77
8 import { factItems, money, priceCut, whenShort } from "../generate.ts";
8 import { factItems, money, priceCut, STATUS_LABELS, whenShort } from "../generate.ts";
99 import { escape } from "../http.ts";
1010 import { altOf, type Launched } from "../listing.ts";
1111 import type { Who } from "../who.ts";
@@ -16,6 +16,7 @@ import { draftBand, photoUrl } from "./listing.ts";
1616 export function cardHeadline(l: Launched): string {
1717 if (l.status === "sold") return "Sold";
1818 if (l.status === "under_contract") return "Under contract";
19 if (l.status === "withdrawn" || l.status === "expired") return STATUS_LABELS[l.status];
1920 if (priceCut(l)) return "New price";
2021 const open = l.status === "active" ? l.open_houses[0] : undefined;
2122 if (open !== undefined) return `Open house ${whenShort(open)}`;
src/pages/home.ts +1
@@ -196,6 +196,7 @@ const FILTER_LABELS: Readonly<Record<Filter, string>> = {
196196 active: STATUS_LABELS.active,
197197 under_contract: STATUS_LABELS.under_contract,
198198 sold: STATUS_LABELS.sold,
199 "off-market": "Off market",
199200 "needs-approval": "Needs approval",
200201 };
201202
src/pages/listing.ts +3 −2
@@ -13,6 +13,7 @@ import {
1313 flyerParts,
1414 openHouseLine,
1515 money,
16 isOffMarket,
1617 statusLine,
1718 STATUS_LABELS,
1819 STATUSES,
@@ -105,10 +106,10 @@ export const statusPill = (l: Pick<Listing, "status" | "sold_price">): string =>
105106 export const statusForm = (l: Listing): string =>
106107 `<form class="row status-form" method="post" action="/listings/${l.id}/status">
107108 <label class="sr" for="status">Status</label>
108 <select class="input select" id="status" name="status">${STATUSES.map((s) => `<option value="${s}"${s === l.status ? " selected" : ""}>${s === "active" && l.status === "under_contract" ? "Back on market" : STATUS_LABELS[s]}</option>`).join("")}</select>
109 <select class="input select" id="status" name="status">${STATUSES.map((s) => `<option value="${s}"${s === l.status ? " selected" : ""}>${s === "active" && (l.status === "under_contract" || isOffMarket(l.status)) ? "Back on market" : STATUS_LABELS[s]}</option>`).join("")}</select>
109110 <label class="sr" for="sold_price">Sold price, optional</label>
110111 <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}">
111 <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>
112 <button class="button button-quiet" title="Coming soon, under contract and sold add a status post and a status email; withdrawn and expired take the listing off the market; your other assets and approvals stay." aria-label="Set status for this listing">Set status</button>
112113 </form>`;
113114
114115 /** The band an unapproved flyer, email or square picture (its first social post) wears, on screen and on paper; nothing once approved. */
src/pages/property.ts +29 −10
@@ -5,15 +5,42 @@
55 * approval, sees a notice that it is not public yet.
66 */
77
8 import { factItems, money, statusLine, whenOf } from "../generate.ts";
8 import { factItems, isOffMarket, money, statusLine, whenOf } from "../generate.ts";
99 import { escape } from "../http.ts";
1010 import { altOf, propertyPath, publicDescription, type Launched } from "../listing.ts";
1111 import type { Who } from "../who.ts";
1212 import { layout } from "./layout.ts";
1313 import { photoUrl } from "./listing.ts";
1414
15 /** Where a withdrawn or expired listing's address leads: the address and the agent, and no price, words or open house. */
16 function offMarketPage(who: Who, l: Launched): string {
17 return layout(
18 `<article class="home palette-${escape(l.palette)}">
19 <header class="home-head">
20 <h2>${escape(l.address)}</h2>
21 <p><span class="pill status-pill status-${l.status}">${escape(statusLine(l))}</span></p>
22 </header>
23 <p class="home-words">This home is no longer on the market.</p>
24 ${agentFooter(l)}
25 </article>`,
26 who,
27 { path: propertyPath(l), heading: l.address },
28 );
29 }
30
31 const agentFooter = (l: Launched): string => `<footer class="home-agent card">
32 ${l.headshot === null ? "" : `<img class="home-face" src="${escape(photoUrl(l, l.headshot))}" alt="Photo of ${escape(l.agent_name)}" width="72" height="72">`}
33 <div>
34 <p class="home-name brand">${escape(l.agent_name)}</p>
35 <p>${escape(l.brokerage)} · License ${escape(l.license)}</p>
36 <p><a href="tel:${escape(l.agent_phone.replace(/[^0-9+]/g, ""))}">${escape(l.agent_phone)}</a></p>
37 <p><a href="mailto:${escape(l.agent_email)}">${escape(l.agent_email)}</a></p>
38 </div>
39 </footer>`;
40
1541 export function propertyPage(input: { who: Who; listing: Launched }): string {
1642 const { who, listing: l } = input;
43 if (isOffMarket(l.status)) return offMarketPage(who, l);
1744 const words = publicDescription(l);
1845 const [hero, ...more] = l.photos;
1946 const status = l.status === "active" ? "Active" : statusLine(l);
@@ -32,15 +59,7 @@ export function propertyPage(input: { who: Who; listing: Launched }): string {
3259 ${l.open_houses.length === 0 || l.status !== "active" ? "" : `<p class="home-open">Open house: ${l.open_houses.map((o) => escape(whenOf(o))).join(" and ")}</p>`}
3360 ${words === null ? "" : `<p class="home-words">${escape(words).replace(/\n/g, "<br>")}</p>`}
3461 ${more.length === 0 ? "" : `<ul class="strip">${more.map((p, i) => `<li><img src="${escape(photoUrl(l, p))}" alt="${escape(altOf(l, p, `Photo ${i + 2} of ${l.address}`))}" width="240" height="160" loading="lazy"></li>`).join("")}</ul>`}
35 <footer class="home-agent card">
36 ${l.headshot === null ? "" : `<img class="home-face" src="${escape(photoUrl(l, l.headshot))}" alt="Photo of ${escape(l.agent_name)}" width="72" height="72">`}
37 <div>
38 <p class="home-name brand">${escape(l.agent_name)}</p>
39 <p>${escape(l.brokerage)} · License ${escape(l.license)}</p>
40 <p><a href="tel:${escape(l.agent_phone.replace(/[^0-9+]/g, ""))}">${escape(l.agent_phone)}</a></p>
41 <p><a href="mailto:${escape(l.agent_email)}">${escape(l.agent_email)}</a></p>
42 </div>
43 </footer>
62 ${agentFooter(l)}
4463 <p class="muted"><small>Equal Housing Opportunity.</small></p>
4564 </article>`,
4665 who,
src/pages/schedule.ts +2 −1
@@ -4,6 +4,7 @@
44 * Draft and is never shown as going out.
55 */
66
7 import { isOffMarket, STATUS_LABELS } from "../generate.ts";
78 import { escape } from "../http.ts";
89 import type { Launched } from "../listing.ts";
910 import { dayName, scheduleDays, type ScheduleRow } from "../schedule.ts";
@@ -33,7 +34,7 @@ export function schedulePage(input: { who: Who; listing: Launched }): string {
3334 <header>
3435 <p class="muted">Launch week · ${escape(l.agent_name)}, ${escape(l.brokerage)}</p>
3536 <h2>${escape(l.address)}</h2>
36 <p class="muted">${signed} of ${total} steps use words a person approved. Nothing is sent from here.</p>
37 <p class="muted">${isOffMarket(l.status) ? `This listing is ${escape(STATUS_LABELS[l.status].toLowerCase())}: nothing further goes out.` : `${signed} of ${total} steps use words a person approved. Nothing is sent from here.`}</p>
3738 </header>
3839 <ol class="schedule-days">
3940 ${days
src/photoalt.test.ts +1 −1
@@ -121,7 +121,7 @@ test("the migration keeps existing rows and describes samples already planted",
121121 const old = new Database(":memory:");
122122 old.exec("PRAGMA foreign_keys = ON");
123123 old.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT '')");
124 const last = migrations.length - 4;
124 const last = migrations.findIndex((sql) => sql.includes("ADD COLUMN photo_alt"));
125125 migrations.slice(0, last).forEach((sql, i) => {
126126 old.exec(sql);
127127 old.query("INSERT INTO schema_version (version) VALUES (?1)").run(i + 1);
src/routes.ts +4 −4
@@ -227,7 +227,7 @@ const LISTING_FIELDS = {
227227 ...FACTS,
228228 photos: { type: "array", items: { type: "string" }, maxItems: PHOTOS_MAX },
229229 headshot: { type: ["string", "null"] },
230 status: { enum: [...STATUSES], description: "Where the listing stands; coming_soon, under_contract and sold add a status post and a status email." },
230 status: { enum: [...STATUSES], description: "Where the listing stands; coming_soon, under_contract and sold add a status post and a status email; withdrawn and expired take the listing off the market." },
231231 sold_price: { type: ["integer", "null"], description: "Whole dollars; only a sold listing has one, and only if the agent said." },
232232 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." },
233233 created_at: { type: "string", format: "date-time" },
@@ -305,7 +305,7 @@ const LISTING_CHANGE: Schema = {
305305 kind: { enum: [...KINDS] },
306306 text: { type: "string", minLength: 1, maxLength: ASSET_MAX },
307307 tone: { enum: [...TONES] },
308 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." },
308 status: { enum: [...STATUSES], description: "Mark the listing coming_soon, active, under_contract, sold, withdrawn or expired; the status post and email are written or removed, nothing else changes." },
309309 sold_price: { type: ["integer", "null"], description: "With status sold: the price it sold at, in whole dollars; leave out to say nothing." },
310310 facts: {
311311 type: "object",
@@ -965,7 +965,7 @@ export const routes: readonly Route[] = [
965965 summary: "The listings, newest first, each with how many phrases the Fair Housing check flags. Filter by status and search the address.",
966966 query: {
967967 limit: LIMIT,
968 status: { enum: [...FILTERS], description: "Only listings that are coming_soon, active, under_contract, sold or needs-approval; anything else is all." },
968 status: { enum: [...FILTERS], description: "Only listings that are coming_soon, active, under_contract, sold, off-market or needs-approval; anything else is all." },
969969 q: { type: "string", maxLength: Q_MAX, description: "Only listings whose address contains this, any case." },
970970 },
971971 answer: list(LISTING_SUMMARY),
@@ -1017,7 +1017,7 @@ export const routes: readonly Route[] = [
10171017 : editAsset(c.db, c.who, { id, kind: String(body.kind ?? ""), text: body.text }),
10181018 );
10191019 },
1020 api: { summary: "Edit one asset's words, put an earlier version back ({ kind, restore }), 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 },
1020 api: { summary: "Edit one asset's words, put an earlier version back ({ kind, restore }), 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, sold, withdrawn or expired ({ 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 },
10211021 },
10221022 {
10231023 method: "DELETE",
src/schedule.ts +2 −1
@@ -9,7 +9,7 @@
99 */
1010
1111 import { publicOrigin } from "./env.ts";
12 import { STATUS_KINDS, PRICE_KINDS, type Kind } from "./generate.ts";
12 import { isOffMarket, STATUS_KINDS, PRICE_KINDS, type Kind } from "./generate.ts";
1313 import type { Launched } from "./listing.ts";
1414 import { approvedLine, oneLine } from "./report.ts";
1515
@@ -40,6 +40,7 @@ export const addDays = (day: string, n: number): string => {
4040 export const launchDay = (l: Launched): string => l.created_at.slice(0, 10);
4141
4242 export function scheduleRows(l: Launched): ScheduleRow[] {
43 if (isOffMarket(l.status)) return [];
4344 const launch = launchDay(l);
4445 const row = (day: string, what: string, kind: Kind, start: string | null = null, end: string | null = null): ScheduleRow[] => {
4546 const a = l.assets.find((x) => x.kind === kind);
src/tty.ts +2 −2
@@ -86,7 +86,7 @@ export const openFromLine = (line: string): { date: string; start: string; end:
8686 return { date: m?.[1] ?? r, start: m?.[2] ?? "", end: m?.[3] ?? "" };
8787 });
8888
89 /** The status in the one-line box: "sold 752000", "sold", "under contract", "coming soon" or "active". */
89 /** The status in the one-line box: "sold 752000", "sold", "under contract", "coming soon", "withdrawn", "expired" or "active". */
9090 export const statusToLine = (l: { status: string; sold_price: number | null }): string =>
9191 `${l.status.replace("_", " ")}${l.sold_price === null ? "" : ` ${l.sold_price}`}`;
9292 export const statusFromLine = (line: string): { status: string; sold_price: string } => {
@@ -246,7 +246,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
246246 }
247247 lines.push("");
248248 if (typing !== undefined) {
249 const label = typing.for === "note" ? "New note" : typing.for === "visitor" ? "Visitor: name, email (enter adds, esc drops)" : 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)`;
249 const label = typing.for === "note" ? "New note" : typing.for === "visitor" ? "Visitor: name, email (enter adds, esc drops)" : 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, withdrawn, expired, 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)`;
250250 // The tail of what is typed, so the cursor stays on the screen.
251251 const room = Math.max(20, size.columns - 4);
252252 lines.push(dim(label), `${typed.length > room ? `…${typed.slice(-room + 1)}` : typed}${esc}7m ${esc}0m`);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs