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

Lower a listing's price and get a 'Price improvement' post and email to approve #46

Merged opened this

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

Merged

mason/listing-launch:mason/44-lower-a-listing-s-price-and-get-a-price → main 4 commits fork 1 thread · 1 unresolved

  • Merged. mason/44-lower-a-listing-s-price-and-get-a-price was merged into main as 1f4ef80.

Review

Sign in to review this change.
13 files changed +298 −21
README.md +7 −1
@@ -48,6 +48,12 @@ to make a listing of your own.
4848 status beside the approval count. *88 Meridian Street* is already sold, so
4949 you can see it. **Under contract** does the same with its own words, and
5050 **Active** takes the two away again.
51 **Lower the price.** In **Edit facts**, a lower price adds a price
52 improvement post (Social) and email ("New price — 412 Alder Lane, now
53 $849,000, was $899,000") in the listing's tone, each Fair Housing checked
54 and needing its own approval. A raise or no change adds nothing; raising
55 the price back to the original, or above it, takes them away. The same

The terminal cannot change the price (src/tty.ts has no price edit), so this sentence is untrue today. Add the tty price edit with a test, or drop the claim.

56 goes in the terminal (press `r` on the listing) and for other tools.
5157 9. **Print the flyer.** Press **Print flyer**: a one-page flyer opens with
5258 the photo, price, facts, description and your name. Print it or save it
5359 as a PDF.
@@ -186,7 +192,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
186192 | ---------------------------------- | -------------------------------------------------------------- |
187193 | `src/actions.ts` | listings (launch, edit an asset, retone, change facts, remove), notes, the audit trail |
188194 | `src/listing.ts` | what a listing is, and the check at the boundary that one arrived |
189 | `src/generate.ts` | `generate(listing)`: the six assets from the facts (eight once it is under contract or sold), in three tones |
195 | `src/generate.ts` | `generate(listing)`: the six assets from the facts (more once it is under contract, sold or lowered in price), in three tones |
190196 | `src/postlength.ts` | a social post's length by what X, Instagram and Facebook count |
191197 | `src/fairhousing.ts` | the Fair Housing rules: phrase, context, class, reason, rewrite |
192198 | `src/photos.ts` | photos as files beside the database, known by their bytes |
contract/openapi.json +25 −3
@@ -544,9 +544,11 @@
544544 "social_medium",
545545 "social_carousel",
546546 "social_status",
547 "social_price",
547548 "flyer",
548549 "email",
549 "email_status"
550 "email_status",
551 "email_price"
550552 ]
551553 },
552554 "text": {
@@ -1190,6 +1192,13 @@
11901192 ],
11911193 "description": "Whole dollars; only a sold listing has one, and only if the agent said."
11921194 },
1195 "was_price": {
1196 "type": [
1197 "integer",
1198 "null"
1199 ],
1200 "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."
1201 },
11931202 "created_at": {
11941203 "type": "string",
11951204 "format": "date-time"
@@ -1210,9 +1219,11 @@
12101219 "social_medium",
12111220 "social_carousel",
12121221 "social_status",
1222 "social_price",
12131223 "flyer",
12141224 "email",
1215 "email_status"
1225 "email_status",
1226 "email_price"
12161227 ]
12171228 },
12181229 "label": {
@@ -1362,6 +1373,7 @@
13621373 "headshot",
13631374 "status",
13641375 "sold_price",
1376 "was_price",
13651377 "created_at",
13661378 "updated_at",
13671379 "assets"
@@ -1517,6 +1529,13 @@
15171529 ],
15181530 "description": "Whole dollars; only a sold listing has one, and only if the agent said."
15191531 },
1532 "was_price": {
1533 "type": [
1534 "integer",
1535 "null"
1536 ],
1537 "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."
1538 },
15201539 "created_at": {
15211540 "type": "string",
15221541 "format": "date-time"
@@ -1542,9 +1561,11 @@
15421561 "social_medium",
15431562 "social_carousel",
15441563 "social_status",
1564 "social_price",
15451565 "flyer",
15461566 "email",
1547 "email_status"
1567 "email_status",
1568 "email_price"
15481569 ]
15491570 },
15501571 "approved": {
@@ -1582,6 +1603,7 @@
15821603 "headshot",
15831604 "status",
15841605 "sold_price",
1606 "was_price",
15851607 "created_at",
15861608 "updated_at",
15871609 "flags",
src/actions.ts +4 −1
@@ -857,8 +857,11 @@ function changeFacts(
857857 JSON.stringify(v.features), v.tone, v.palette, v.agent_name, v.brokerage,
858858 v.license, v.agent_phone, v.agent_email, JSON.stringify(v.open_houses), now.id,
859859 );
860 // A cut remembers the price it came from; a raise to or above it forgets.
861 const was = v.price < now.price ? (now.was_price ?? now.price) : now.was_price !== null && v.price < now.was_price ? now.was_price : null;
862 db.query("UPDATE listing SET was_price = ?1 WHERE id = ?2").run(was, now.id);
860863 touch(db, now.id);
861 writeAssets(db, { ...now, ...v });
864 writeAssets(db, { ...now, ...v, was_price: was });
862865 })();
863866 emit(db, "listing.changed", { id: now.id });
864867 return ok(launched(db, now.id)!);
src/audit.test.ts +3 −3
@@ -33,9 +33,9 @@ const as = (
3333 "x-forge-signature": sign(identity, KEY),
3434 };
3535 };
36 const alice = as("u1", "alice");
37 const bob = as("u2", "bob");
38 const root = as("u0", "root", "admin");
36 const alice = as("audit-u1", "alice");
37 const bob = as("audit-u2", "bob");
38 const root = as("audit-u0", "root", "admin");
3939 const call = (
4040 path: string,
4141 init: RequestInit = {},
src/db.ts +2
@@ -95,6 +95,8 @@ export const migrations: string[] = [
9595 // Where the listing stands: active, under_contract or sold, and the price it sold at if the agent said.
9696 `ALTER TABLE listing ADD COLUMN status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'under_contract', 'sold'))`,
9797 `ALTER TABLE listing ADD COLUMN sold_price INTEGER`,
98 // The price before it was lowered, while it stands below it; the price improvement assets are written from it.
99 `ALTER TABLE listing ADD COLUMN was_price INTEGER`,
98100 ];
99101
100102 export function openDb(dir = dataDir): Database {
src/generate.ts +50 −1
@@ -22,9 +22,11 @@ export const KINDS = [
2222 "social_medium",
2323 "social_carousel",
2424 "social_status",
25 "social_price",
2526 "flyer",
2627 "email",
2728 "email_status",
29 "email_price",
2830 ] as const;
2931 export type Kind = (typeof KINDS)[number];
3032
@@ -38,11 +40,16 @@ export const LABELS: Readonly<Record<Kind, string>> = {
3840 flyer: "Flyer copy",
3941 email: "Email blast",
4042 email_status: "Status email",
43 social_price: "Price improvement post",
44 email_price: "Price improvement email",
4145 };
4246
4347 /** The assets that exist only while a listing is under contract or sold. */
4448 export const STATUS_KINDS = ["social_status", "email_status"] as const satisfies readonly Kind[];
4549
50 /** The assets that exist only while the price stands below what it was. */
51 export const PRICE_KINDS = ["social_price", "email_price"] as const satisfies readonly Kind[];
52
4653 export const STATUSES = ["active", "under_contract", "sold"] as const;
4754 export type Status = (typeof STATUSES)[number];
4855
@@ -75,6 +82,8 @@ export interface Facts {
7582 readonly status?: Status;
7683 /** Whole dollars, only when sold, and only if the agent chose to say. */
7784 readonly sold_price?: number | null;
85 /** Whole dollars: the price before it was lowered; absent or null when it has not been. */
86 readonly was_price?: number | null;
7887 }
7988
8089 export interface Agent {
@@ -365,13 +374,52 @@ function statusEmail(f: Facts, a: Agent): string {
365374 return `Subject: ${subject}\n\n${open}\n\n${thanks}\n\n${close}\n\n${a.name}\n${a.brokerage} · License ${a.license}\n${a.phone} · ${a.email}`;
366375 }
367376
368 /** A listing's assets: the six, and the two status ones when it is under contract or sold. */
377 /** True when the price now stands below the one it was lowered from. */
378 export const priceCut = (f: Pick<Facts, "price" | "was_price">): boolean =>
379 f.was_price != null && f.was_price > f.price;
380
381 function pricePost(f: Facts, a: Agent): string {
382 const where = street(f.address);
383 const was = money(f.was_price!);
384 const now = money(f.price);
385 const line = {
386 classic: `New price: ${where}, now ${now}, was ${was}.`,
387 warm: `Good news: ${where} has a new price! It is now ${now}, down from ${was}.`,
388 bold: `PRICE IMPROVEMENT: ${where}. NOW ${now}. WAS ${was}.`,
389 }[f.tone];
390 const close = {
391 classic: "Message me to arrange a private showing.",
392 warm: "I would love to show you around. Send me a message to set up a visit.",
393 bold: "Now is the time. Message me today.",
394 }[f.tone];
395 return `${line} ${close} ${a.name}, ${a.brokerage}. #PriceImprovement #NewPrice`;
396 }
397
398 function priceEmail(f: Facts, a: Agent): string {
399 const where = street(f.address);
400 const was = money(f.was_price!);
401 const now = money(f.price);
402 const open = {
403 classic: `I am pleased to share that the price of ${f.address} has been improved. It is now ${now}, was ${was}.`,
404 warm: `I have good news to share: ${f.address} has a new price! It is now ${now}, down from ${was}.`,
405 bold: `${f.address} just got a new price: NOW ${now}, WAS ${was}.`,
406 }[f.tone];
407 const close = {
408 classic: "To arrange a private showing or request the disclosure package, reply to this email or call me directly.",
409 warm: "If you or someone you know would like to see it, just reply to this email and I will set up a time.",
410 bold: "Want in? Reply now and I will get you on the showing schedule.",
411 }[f.tone];
412 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}`;
413 }
414
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. */
369416 export function generate(listing: {
370417 readonly facts: Facts;
371418 readonly agent: Agent;
372419 }): Partial<Record<Kind, string>> & Record<(typeof CORE_KINDS)[number], string> {
373420 const { facts: f, agent: a } = listing;
374421 const announced = f.status === "under_contract" || f.status === "sold";
422 const cut = priceCut(f);
375423 return {
376424 mls: mls(f),
377425 social_short: socialShort(f, a),
@@ -380,6 +428,7 @@ export function generate(listing: {
380428 flyer: flyer(f),
381429 email: email(f, a),
382430 ...(announced ? { social_status: statusPost(f, a), email_status: statusEmail(f, a) } : {}),
431 ...(cut ? { social_price: pricePost(f, a), email_price: priceEmail(f, a) } : {}),
383432 };
384433 }
385434
src/listing.ts +3
@@ -43,6 +43,8 @@ export interface Listing extends ListingInput {
4343 readonly status: Status;
4444 /** Whole dollars; only a sold listing has one, and only if the agent said. */
4545 readonly sold_price: number | null;
46 /** Whole dollars: what the price was before it was lowered; null when it has not been. */
47 readonly was_price: number | null;
4648 readonly id: number;
4749 readonly author_id: string;
4850 readonly author_name: string;
@@ -109,6 +111,7 @@ export interface ListingRow {
109111 readonly open_houses: string;
110112 readonly status: Status;
111113 readonly sold_price: number | null;
114 readonly was_price: number | null;
112115 readonly agent_name: string;
113116 readonly brokerage: string;
114117 readonly license: string;
src/openhouse.test.ts +1 −1
@@ -111,7 +111,7 @@ test("adding, changing and clearing it writes the assets again and clears approv
111111 expect(text("mls")).toContain("Sunday, March 14, 1–3 pm");
112112 expect(text("mls")).not.toContain("March 7");
113113
114 expect(approve()).toBe(6);
114 expect(approve()).toBe(8);
115115 const cleared = updateFacts(db, me, { id, fields: { open_houses: [] } });
116116 if (!cleared.ok) throw new Error(cleared.message);
117117 expect(cleared.value.open_houses).toEqual([]);
src/pages/listing.ts +2 −2
@@ -157,9 +157,9 @@ function editor(l: Launched, a: Asset, editable: boolean): string {
157157
158158 const TABS: readonly { id: string; label: string; kinds: readonly string[] }[] = [
159159 { id: "mls", label: "MLS", kinds: ["mls"] },
160 { id: "social", label: "Social", kinds: ["social_short", "social_medium", "social_carousel", "social_status"] },
160 { id: "social", label: "Social", kinds: ["social_short", "social_medium", "social_carousel", "social_status", "social_price"] },
161161 { id: "flyer", label: "Flyer", kinds: ["flyer"] },
162 { id: "email", label: "Email", kinds: ["email", "email_status"] },
162 { id: "email", label: "Email", kinds: ["email", "email_status", "email_price"] },
163163 ];
164164
165165 /** The tab that holds an asset, or a tab named outright; the first otherwise. */
src/priceimprovement.test.ts added +181
@@ -0,0 +1,181 @@
1 /**
2 * Price improvement: lowering the price adds a post and an email naming the
3 * old and new price, each checked and needing its own approval; a raise or no
4 * change adds nothing, and a raise back to the old price removes them.
5 */
6 import { expect, test } from "bun:test";
7 import { mkdtempSync } from "node:fs";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { approveAsset, getListing, launchListing, listListings, updateFacts } from "./actions.ts";
11 import { openDb } from "./db.ts";
12 import { check } from "./fairhousing.ts";
13 import { generate, PRICE_KINDS, TONES } from "./generate.ts";
14 import { agentOf } from "./listing.ts";
15 import { rpc } from "./mcp.ts";
16 import { listingPage } from "./pages/listing.ts";
17 import { SAMPLE } from "./sample.ts";
18 import { session } from "./tty.ts";
19 import type { Who } from "./who.ts";
20
21 const me: Who = { role: "user", id: "u1", name: "me" };
22
23 const launch = () => {
24 const db = openDb(mkdtempSync(join(tmpdir(), "app-price-")));
25 const made = launchListing(db, me, { fields: { ...SAMPLE, price: 899000 } });
26 if (!made.ok) throw new Error(made.message);
27 return { db, id: made.value.id };
28 };
29 const kinds = (db: ReturnType<typeof openDb>, id: number) => {
30 const found = getListing(db, me, { id });
31 if (!found.ok) throw new Error(found.message);
32 return found.value;
33 };
34 const price = (db: ReturnType<typeof openDb>, id: number, p: number) => {
35 const done = updateFacts(db, me, { id, fields: { price: p } });
36 if (!done.ok) throw new Error(done.message);
37 return done.value;
38 };
39
40 test("the words: both name the street, the new and the old price, clear in every tone", () => {
41 const agent = agentOf(SAMPLE);
42 for (const tone of TONES) {
43 const words = generate({ facts: { ...SAMPLE, tone, price: 849000, was_price: 899000 }, agent });
44 for (const kind of PRICE_KINDS) {
45 const text = words[kind]!;
46 expect(text).toContain("$849,000");
47 expect(text).toContain("$899,000");
48 expect(check(text).clear).toBe(true);
49 }
50 }
51 expect(generate({ facts: { ...SAMPLE, tone: "classic", price: 849000, was_price: 899000 }, agent }).email_price).toStartWith(
52 `Subject: New price — ${SAMPLE.address.split(",")[0]}, now $849,000, was $899,000`,
53 );
54 for (const was of [undefined, null, 849000, 800000])
55 expect(generate({ facts: { ...SAMPLE, price: 849000, was_price: was }, agent }).social_price).toBeUndefined();
56 });
57
58 test("lowering adds both assets with old and new price; raising back removes them", () => {
59 const { db, id } = launch();
60 expect(kinds(db, id).assets.map((a) => a.kind)).not.toContain("social_price");
61 const cut = price(db, id, 849000);
62 expect(cut.was_price).toBe(899000);
63 for (const kind of PRICE_KINDS) {
64 const a = cut.assets.find((x) => x.kind === kind)!;
65 expect(a.text).toContain("$849,000");
66 expect(a.text).toContain("$899,000");
67 expect(a.approval).toBeNull();
68 }
69 // a second cut keeps the original
70 expect(price(db, id, 829000).assets.find((a) => a.kind === "social_price")!.text).toContain("$899,000");
71 // a raise that stays below the original keeps them; to the original removes them
72 expect(price(db, id, 840000).assets.map((a) => a.kind)).toContain("email_price");
73 const back = price(db, id, 899000);
74 expect(back.was_price).toBeNull();
75 expect(back.assets.map((a) => a.kind)).not.toContain("social_price");
76 expect(back.assets.map((a) => a.kind)).not.toContain("email_price");
77 expect(price(db, id, 950000).assets.map((a) => a.kind)).not.toContain("email_price");
78 });
79
80 test("a raise or no change adds nothing", () => {
81 const { db, id } = launch();
82 expect(price(db, id, 950000).assets.map((a) => a.kind)).not.toContain("social_price");
83 expect(price(db, id, 950000).was_price).toBeNull();
84 });
85
86 test("each needs its own approval and counts toward ready to send", () => {
87 const { db, id } = launch();
88 const cut = price(db, id, 849000);
89 const post = cut.assets.find((a) => a.kind === "social_price")!;
90 const done = approveAsset(db, me, { id, kind: "social_price", door: "page", seen: post.updated_at });
91 expect(done.ok && done.value.assets.filter((a) => a.approval !== null).map((a) => a.kind)).toEqual(["social_price"]);
92 const row = listListings(db, me, {});
93 expect(row.ok && row.value.find((l) => l.id === id)!.approvals.length).toBe(cut.assets.length);
94 expect(row.ok && row.value.find((l) => l.id === id)!.approvals.filter((a) => a.approved).length).toBe(1);
95 });
96
97 test("the page shows both with their length; the MCP cannot approve them", () => {
98 const { db, id } = launch();
99 const cut = price(db, id, 849000);
100 const page = listingPage({ who: me, listing: cut, editable: true });
101 expect(page).toContain('id="social_price"');
102 expect(page).toContain('id="email_price"');
103 expect(page).toMatch(/characters[^<]*Instagram/);
104 for (const kind of PRICE_KINDS) {
105 const res = rpc(db, me, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "approve_asset", arguments: { id, kind } } });
106 expect(JSON.stringify(res.body)).toMatch(/person|isError|refus|cannot/i);
107 expect(kinds(db, id).assets.find((a) => a.kind === kind)!.approval).toBeNull();
108 }
109 });
110
111 const plain = (s: string): string => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
112
113 test("the terminal lowers the price with r and shows the new assets", () => {
114 const { db, id } = launch();
115 const tty = session(db, me);
116 tty.press("\r");
117 tty.press("r");
118 expect(plain(tty.screen())).toContain("899000");
119 for (let i = 0; i < 10; i++) tty.press("\x7f");
120 for (const key of "849000") tty.press(key);
121 tty.press("\r");
122 expect(plain(tty.screen())).toContain("down from $899,000");
123 const now = kinds(db, id);
124 expect(now.price).toBe(849000);
125 expect(now.was_price).toBe(899000);
126 expect(now.assets.map((a) => a.kind)).toContain("social_price");
127 expect(plain(tty.screen())).toContain("0 of 8 approved");
128 tty.press("r");
129 for (let i = 0; i < 10; i++) tty.press("\x7f");
130 for (const key of "lots") tty.press(key);
131 tty.press("\r");
132 expect(kinds(db, id).price).toBe(849000);
133 });
134
135 test("the MCP update_facts lowers the price", () => {
136 const { db, id } = launch();
137 const res = rpc(db, me, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "update_facts", arguments: { id, facts: { price: 849000 } } } }).body as { result: { content: { text: string }[] } };
138 expect(res.result.content[0]!.text).toContain("social_price");
139 const now = kinds(db, id);
140 expect(now.was_price).toBe(899000);
141 expect(now.assets.map((a) => a.kind)).toContain("email_price");
142 });
143
144 test("the JSON API PATCH and the Edit facts form lower the price", async () => {
145 const { serve } = await import("./server.ts");
146 const { sign } = await import("./who.ts");
147 const KEY = "a-key-only-the-forge-holds";
148 const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-price-api-")), identityKey: KEY });
149 try {
150 const base = `http://${server.hostname}:${server.port}`;
151 const identity = `price-api|agent|user|${Date.now() + 600_000}`;
152 const auth = { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
153 const mk = async () => {
154 const r = await fetch(`${base}/api/listings`, { method: "POST", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ ...SAMPLE, price: 899000 }) });
155 return ((await r.json()) as { id: number }).id;
156 };
157 type Out = { price: number; was_price: number | null; assets: { kind: string; text: string }[] };
158
159 const apiId = await mk();
160 const patched = await fetch(`${base}/api/listings/${apiId}`, { method: "PATCH", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ facts: { price: 849000 } }) });
161 expect(patched.status).toBe(200);
162 const out = (await patched.json()) as Out;
163 expect(out.was_price).toBe(899000);
164 expect(out.assets.find((a) => a.kind === "social_price")!.text).toContain("$899,000");
165 expect(out.assets.map((a) => a.kind)).toContain("email_price");
166
167 const formId = await mk();
168 const form = new FormData();
169 for (const k of ["address", "beds", "baths", "sqft", "lot", "year", "tone", "palette", "agent_name", "brokerage", "license", "agent_phone", "agent_email"] as const)
170 form.set(k, String(SAMPLE[k]));
171 form.set("features", SAMPLE.features.join("\n"));
172 form.set("price", "849000");
173 const posted = await fetch(`${base}/listings/${formId}/facts`, { method: "POST", headers: auth, body: form, redirect: "manual" });
174 expect(posted.status).toBe(303);
175 const read = (await (await fetch(`${base}/api/listings/${formId}`, { headers: auth })).json()) as Out;
176 expect(read.was_price).toBe(899000);
177 expect(read.assets.map((a) => a.kind)).toEqual(expect.arrayContaining(["social_price", "email_price"]));
178 } finally {
179 server.stop(true);
180 }
181 });
src/routes.ts +1
@@ -210,6 +210,7 @@ const LISTING_FIELDS = {
210210 headshot: { type: ["string", "null"] },
211211 status: { enum: [...STATUSES], description: "Where the listing stands; under_contract and sold add a status post and a status email." },
212212 sold_price: { type: ["integer", "null"], description: "Whole dollars; only a sold listing has one, and only if the agent said." },
213 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." },
213214 created_at: { type: "string", format: "date-time" },
214215 updated_at: { type: "string", format: "date-time" },
215216 };
src/tty.ts +14 −4
@@ -89,7 +89,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
8989 let previewing = false;
9090 let cursor = 0;
9191 /** What the one-line input is for: a new note, or an asset's new words. */
92 let typing: { for: "note" } | { for: "asset"; kind: Kind } | { for: "open" } | { for: "status" } | undefined;
92 let typing: { for: "note" } | { for: "asset"; kind: Kind } | { for: "open" } | { for: "status" } | { for: "price" } | undefined;
9393 let typed = "";
9494 let said = "";
9595 /** The short post over X that `a` was pressed on once: a second `a` approves it knowing so. */
@@ -154,7 +154,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
154154 if (previewing) help = "esc back to the assets · q quit";
155155 else help =
156156 found?.ok === true && canEditListing(me, found.value)
157 ? "j/k asset · e edit · o open house · s status · a approve/withdraw · u use suggested wording · t next tone · p preview email · esc back · q quit"
157 ? "j/k asset · e edit · o open house · r price · s status · a approve/withdraw · u use suggested wording · t next tone · p preview email · esc back · q quit"
158158 : "j/k asset · p preview email · esc back · q quit";
159159 } else if (view === "listings") {
160160 const rows = listings();
@@ -186,7 +186,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
186186 }
187187 lines.push("");
188188 if (typing !== undefined) {
189 const label = typing.for === "note" ? "New note" : 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)`;
189 const label = typing.for === "note" ? "New note" : 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)`;
190190 // The tail of what is typed, so the cursor stays on the screen.
191191 const room = Math.max(20, size.columns - 4);
192192 lines.push(dim(label), `${typed.length > room ? `…${typed.slice(-room + 1)}` : typed}${esc}7m ${esc}0m`);
@@ -205,6 +205,13 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
205205 } else if (typing.for === "open") {
206206 const done = updateFacts(db, me, { id: reading!, fields: { open_houses: openFromLine(typed) } });
207207 said = done.ok ? "Open house saved. Every asset is written again: read it through." : done.message;
208 } else if (typing.for === "price") {
209 const done = updateFacts(db, me, { id: reading!, fields: { price: typed } });
210 said = !done.ok
211 ? done.message
212 : done.value.was_price === null
213 ? "Price saved. Every asset is written again: read it through."
214 : `Price saved, down from ${money(done.value.was_price)}. A price improvement post and email are ready to approve.`;
208215 } else if (typing.for === "status") {
209216 const { status, sold_price } = statusFromLine(typed);
210217 const done = setStatus(db, me, { id: reading!, status, sold_price });
@@ -256,7 +263,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
256263 if (previewing) return undefined;
257264 if (down) cursor = Math.min(cursor + 1, Math.max(0, assets.length - 1));
258265 if (up) cursor = Math.max(cursor - 1, 0);
259 if (found?.ok === true && (key === "e" || key === "t" || key === "a" || key === "u" || key === "o" || key === "s")) {
266 if (found?.ok === true && (key === "e" || key === "t" || key === "a" || key === "u" || key === "o" || key === "s" || key === "r")) {
260267 if (!canEditListing(me, found.value))
261268 said = me.role === "anon" ? "Sign in to edit this listing." : "Only the listing's agent or an admin can edit it.";
262269 else if (key === "u") {
@@ -287,6 +294,9 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
287294 } else if (key === "s") {
288295 typing = { for: "status" };
289296 typed = statusToLine(found.value);
297 } else if (key === "r") {
298 typing = { for: "price" };
299 typed = String(found.value.price);
290300 } else if (key === "o") {
291301 typing = { for: "open" };
292302 typed = openToLine(found.value.open_houses);
src/walkthrough.test.ts +5 −5
@@ -169,7 +169,7 @@ test("every step of the section works, with the labels it names", async () => {
169169 expect((await post(`${mine}/facts`, drop)).status).toBe(303);
170170 const afterDrop = await (await get(mine)).text();
171171 expect(afterDrop).toContain("$649,000");
172 expect(afterDrop).toContain("0 of 6 approved");
172 expect(afterDrop).toContain("0 of 8 approved");
173173 expect(await (await get(`${mine}/flyer`)).text()).toContain("$649,000");
174174
175175 // 7. Add an open house: Edit facts, the band on the flyer, the line in the email, approvals cleared.
@@ -187,7 +187,7 @@ test("every step of the section works, with the labels it names", async () => {
187187 approveOpen.set("seen", /name="seen" value="([^"]+)"/.exec(beforeOpen)![1]!.replaceAll("&amp;", "&"));
188188 approveOpen.set("text", textareaOf(beforeOpen));
189189 await post(`${mine}/assets/mls/approve`, approveOpen);
190 expect(await (await get(mine)).text()).toContain("1 of 6 approved");
190 expect(await (await get(mine)).text()).toContain("1 of 8 approved");
191191 const hold = new FormData();
192192 for (const [k, v] of drop.entries()) hold.set(k, v);
193193 hold.set("tone", "bold");
@@ -196,7 +196,7 @@ test("every step of the section works, with the labels it names", async () => {
196196 hold.set("open1_end", "15:00");
197197 expect((await post(`${mine}/facts`, hold)).status).toBe(303);
198198 const afterOpen = await (await get(mine)).text();
199 expect(afterOpen).toContain("0 of 6 approved");
199 expect(afterOpen).toContain("0 of 8 approved");
200200 expect(await (await get(`${mine}/flyer`)).text()).toContain("OPEN HOUSE: Sunday, March 7, 1–3 pm.");
201201 expect(await (await get(`${mine}/email`)).text()).toContain("OPEN HOUSE: Sunday, March 7, 1–3 pm.");
202202 const bad = new FormData();
@@ -221,7 +221,7 @@ test("every step of the section works, with the labels it names", async () => {
221221 sell.set("sold_price", "640000");
222222 expect((await post(`${mine}/status`, sell)).status).toBe(303);
223223 const afterSold = await (await get(mine)).text();
224 expect(afterSold).toContain("0 of 8 approved");
224 expect(afterSold).toContain("0 of 10 approved");
225225 expect(afterSold).toContain("Sold at $640,000");
226226 expect(afterSold).toContain("Just sold — 9 Walkthrough Way");
227227 expect(await (await get(`${mine}/flyer`)).text()).toContain('<p class="flyer-status status-sold" role="note">Sold at $640,000</p>');
@@ -234,7 +234,7 @@ test("every step of the section works, with the labels it names", async () => {
234234 const active = new FormData();
235235 active.set("status", "active");
236236 expect((await post(`${mine}/status`, active)).status).toBe(303);
237 expect(await (await get(mine)).text()).toContain("0 of 6 approved");
237 expect(await (await get(mine)).text()).toContain("0 of 8 approved");
238238
239239 // 9. Print the flyer.
240240 expect(afterTone).toMatch(control("Print flyer"));

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs