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
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/55-mark-a-listing-coming-soon-and-get-a-tea → main 2 commits fork 3 threads · 3 unresolved
18 files
changed +314
−39
README.md +8 −6
| @@ -50,7 +50,9 @@ to make a listing of your own. | ||
| 50 | 50 | flyer wears a "Sold" band, and the list on the first screen shows the |
| 51 | 51 | status beside the approval count. *88 Meridian Street* is already sold, so |
| 52 | 52 | 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. | |
| 54 | 56 | **Lower the price.** In **Edit facts**, a lower price adds a price |
| 55 | 57 | improvement post (Social) and email ("New price — 412 Alder Lane, now |
| 56 | 58 | $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. | ||
| 58 | 60 | the price back to the original, or above it, takes them away. The same |
| 59 | 61 | goes in the terminal (press `r` on the listing) and for other tools. |
| 60 | 62 | **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. | |
| 63 | 65 | 9. **Print the flyer.** Press **Print flyer**: a one-page flyer opens with |
| 64 | 66 | the photo, price, facts, description and your name. Print it or save it |
| 65 | 67 | as a PDF. |
| @@ -303,7 +305,7 @@ curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' - | ||
| 303 | 305 | curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"facts":{"price":649000}}' |
| 304 | 306 | # an open house (up to two; local date and 24-hour times as typed; [] clears) |
| 305 | 307 | 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 | |
| 307 | 309 | curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"status":"sold","sold_price":752000}' |
| 308 | 310 | curl -X DELETE https://<app>/api/listings/1 |
| 309 | 311 | |
| @@ -336,8 +338,8 @@ a real pty over the same data, as the account at the keyboard. Open a | ||
| 336 | 338 | listing with `enter` to read every asset with its Fair Housing findings; |
| 337 | 339 | `j`/`k` move between assets, `e` edits the one under the cursor (type `\n` |
| 338 | 340 | 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. | |
| 341 | 343 | |
| 342 | 344 | No runtime dependencies: Bun's own `bun:sqlite`, `Bun.serve` and `node:` |
| 343 | 345 | modules. The typefaces are the system's (a Palatino/Iowan serif for |
contract/openapi.json +10 −6
| @@ -320,9 +320,10 @@ | ||
| 320 | 320 | "active", |
| 321 | 321 | "under_contract", |
| 322 | 322 | "sold", |
| 323 | "coming_soon", | |
| 323 | 324 | "needs-approval" |
| 324 | 325 | ], |
| 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." | |
| 326 | 327 | } |
| 327 | 328 | }, |
| 328 | 329 | { |
| @@ -578,7 +579,7 @@ | ||
| 578 | 579 | } |
| 579 | 580 | }, |
| 580 | 581 | "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.", | |
| 582 | 583 | "operationId": "patchApiListingsId", |
| 583 | 584 | "parameters": [ |
| 584 | 585 | { |
| @@ -626,11 +627,12 @@ | ||
| 626 | 627 | }, |
| 627 | 628 | "status": { |
| 628 | 629 | "enum": [ |
| 630 | "coming_soon", | |
| 629 | 631 | "active", |
| 630 | 632 | "under_contract", |
| 631 | 633 | "sold" |
| 632 | 634 | ], |
| 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." | |
| 634 | 636 | }, |
| 635 | 637 | "sold_price": { |
| 636 | 638 | "type": [ |
| @@ -787,7 +789,7 @@ | ||
| 787 | 789 | }, |
| 788 | 790 | "responses": { |
| 789 | 791 | "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.", | |
| 791 | 793 | "content": { |
| 792 | 794 | "application/json": { |
| 793 | 795 | "schema": { |
| @@ -1240,11 +1242,12 @@ | ||
| 1240 | 1242 | }, |
| 1241 | 1243 | "status": { |
| 1242 | 1244 | "enum": [ |
| 1245 | "coming_soon", | |
| 1243 | 1246 | "active", |
| 1244 | 1247 | "under_contract", |
| 1245 | 1248 | "sold" |
| 1246 | 1249 | ], |
| 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." | |
| 1248 | 1251 | }, |
| 1249 | 1252 | "sold_price": { |
| 1250 | 1253 | "type": [ |
| @@ -1577,11 +1580,12 @@ | ||
| 1577 | 1580 | }, |
| 1578 | 1581 | "status": { |
| 1579 | 1582 | "enum": [ |
| 1583 | "coming_soon", | |
| 1580 | 1584 | "active", |
| 1581 | 1585 | "under_contract", |
| 1582 | 1586 | "sold" |
| 1583 | 1587 | ], |
| 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." | |
| 1585 | 1589 | }, |
| 1586 | 1590 | "sold_price": { |
| 1587 | 1591 | "type": [ |
src/actions.ts +4 −4
| @@ -896,10 +896,10 @@ function changeFacts( | ||
| 896 | 896 | } |
| 897 | 897 | |
| 898 | 898 | /** |
| 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 | |
| 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 | |
| 903 | 903 | * alone. Saying what is already so changes nothing. |
| 904 | 904 | */ |
| 905 | 905 | 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[] = [ | ||
| 104 | 104 | approved_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), approved_via = 'sample' |
| 105 | 105 | WHERE edited = 0 AND (approved_at IS NULL OR approved_by_id = 'sample') |
| 106 | 106 | 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;`, | |
| 107 | 147 | ]; |
| 108 | 148 | |
| 109 | 149 | export function openDb(dir = dataDir): Database { |
src/find.test.ts +1 −1
| @@ -23,5 +23,5 @@ test("each filter, search, and both together", () => { | ||
| 23 | 23 | expect(f("needs-approval")).toEqual(["2 Elm Court"]); |
| 24 | 24 | expect(f("all", "OAK")).toEqual(["1 Oak Lane", "3 Oak Road"]); |
| 25 | 25 | 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 }); | |
| 27 | 27 | }); |
src/find.ts +2 −1
| @@ -6,7 +6,8 @@ | ||
| 6 | 6 | |
| 7 | 7 | import { STATUSES, type Status } from "./generate.ts"; |
| 8 | 8 | |
| 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; | |
| 10 | 11 | export type Filter = (typeof FILTERS)[number]; |
| 11 | 12 | |
| 12 | 13 | export const Q_MAX = 100; |
src/generate.ts +40 −4
| @@ -44,22 +44,27 @@ export const LABELS: Readonly<Record<Kind, string>> = { | ||
| 44 | 44 | email_price: "Price improvement email", |
| 45 | 45 | }; |
| 46 | 46 | |
| 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. */ | |
| 48 | 48 | export const STATUS_KINDS = ["social_status", "email_status"] as const satisfies readonly Kind[]; |
| 49 | 49 | |
| 50 | 50 | /** The assets that exist only while the price stands below what it was. */ |
| 51 | 51 | export const PRICE_KINDS = ["social_price", "email_price"] as const satisfies readonly Kind[]; |
| 52 | 52 | |
| 53 | export const STATUSES = ["active", "under_contract", "sold"] as const; | |
| 53 | export const STATUSES = ["coming_soon", "active", "under_contract", "sold"] as const; | |
| 54 | 54 | export type Status = (typeof STATUSES)[number]; |
| 55 | 55 | |
| 56 | 56 | /** What a person calls each status. */ |
| 57 | 57 | export const STATUS_LABELS: Readonly<Record<Status, string>> = { |
| 58 | coming_soon: "Coming soon", | |
| 58 | 59 | active: "Active", |
| 59 | 60 | under_contract: "Under contract", |
| 60 | 61 | sold: "Sold", |
| 61 | 62 | }; |
| 62 | 63 | |
| 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 | ||
| 63 | 68 | /** One open house, as typed: the local date (YYYY-MM-DD) and 24-hour times (HH:MM). Never converted. */ |
| 64 | 69 | export interface OpenHouse { |
| 65 | 70 | readonly date: string; |
| @@ -315,9 +320,39 @@ function email(f: Facts, a: Agent): string { | ||
| 315 | 320 | 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}`; |
| 316 | 321 | } |
| 317 | 322 | |
| 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 | ||
| 318 | 352 | /** The status announcement's opening, in the listing's tone: "Just sold" or "Under contract". */ |
| 319 | 353 | function statusPost(f: Facts, a: Agent): string { |
| 320 | 354 | const where = street(f.address); |
| 355 | if (f.status === "coming_soon") return comingSoonPost(f, a, where); | |
| 321 | 356 | const sold = f.status === "sold"; |
| 322 | 357 | const price = sold && f.sold_price != null ? ` at ${money(f.sold_price)}` : ""; |
| 323 | 358 | const line = sold |
| @@ -347,6 +382,7 @@ function statusPost(f: Facts, a: Agent): string { | ||
| 347 | 382 | |
| 348 | 383 | function statusEmail(f: Facts, a: Agent): string { |
| 349 | 384 | const where = street(f.address); |
| 385 | if (f.status === "coming_soon") return comingSoonEmail(f, a, where); | |
| 350 | 386 | const sold = f.status === "sold"; |
| 351 | 387 | const subject = sold ? `Just sold — ${where}` : `Under contract — ${where}`; |
| 352 | 388 | 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 { | ||
| 412 | 448 | 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 | 449 | } |
| 414 | 450 | |
| 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. */ | |
| 416 | 452 | export function generate(listing: { |
| 417 | 453 | readonly facts: Facts; |
| 418 | 454 | readonly agent: Agent; |
| 419 | 455 | }): Partial<Record<Kind, string>> & Record<(typeof CORE_KINDS)[number], string> { |
| 420 | 456 | 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"; | |
| 422 | 458 | const cut = priceCut(f); |
| 423 | 459 | return { |
| 424 | 460 | mls: mls(f), |
src/kit.ts +2 −1
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | * permission; whoever may read the listing may read this. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { factItems, money, openHouseLine } from "./generate.ts"; | |
| 8 | import { factItems, money, openHouseLine, statusLine } from "./generate.ts"; | |
| 9 | 9 | import { publicOrigin } from "./env.ts"; |
| 10 | 10 | 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. |
||
| 11 | 11 | |
| @@ -49,6 +49,7 @@ export function kit(l: Launched, origin = publicOrigin): string { | ||
| 49 | 49 | return `${[ |
| 50 | 50 | `LAUNCH KIT — ${oneLine(l.address)}`, |
| 51 | 51 | `${money(l.price)}`, |
| 52 | ...(l.status === "active" ? [] : [`Status: ${oneLine(statusLine(l))}`]), | |
| 52 | 53 | factItems(l).map((i) => `${oneLine(String(i.value))} ${oneLine(i.label)}`).join(" · "), |
| 53 | 54 | ...(open === null ? [] : [oneLine(open)]), |
| 54 | 55 | `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[] = [ | ||
| 101 | 101 | type: "object", |
| 102 | 102 | properties: { |
| 103 | 103 | 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." }, | |
| 105 | 105 | q: { type: "string", maxLength: Q_MAX, description: "Only listings whose address contains this, any case." }, |
| 106 | 106 | }, |
| 107 | 107 | }, |
| @@ -239,7 +239,7 @@ export const tools: readonly Tool[] = [ | ||
| 239 | 239 | { |
| 240 | 240 | name: "set_status", |
| 241 | 241 | 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.", | |
| 243 | 243 | inputSchema: { |
| 244 | 244 | type: "object", |
| 245 | 245 | properties: { |
src/pages/home.ts +1
| @@ -173,6 +173,7 @@ export function form(who: Who, draft: Draft, editing?: Editing): string { | ||
| 173 | 173 | |
| 174 | 174 | const FILTER_LABELS: Readonly<Record<Filter, string>> = { |
| 175 | 175 | all: "All", |
| 176 | coming_soon: STATUS_LABELS.coming_soon, | |
| 176 | 177 | active: STATUS_LABELS.active, |
| 177 | 178 | under_contract: STATUS_LABELS.under_contract, |
| 178 | 179 | sold: STATUS_LABELS.sold, |
src/pages/listing.ts +3 −5
| @@ -11,6 +11,7 @@ import { | ||
| 11 | 11 | flyerParts, |
| 12 | 12 | openHouseLine, |
| 13 | 13 | money, |
| 14 | statusLine, | |
| 14 | 15 | STATUS_LABELS, |
| 15 | 16 | STATUSES, |
| 16 | 17 | TONES, |
| @@ -97,9 +98,6 @@ export const statusPill = (l: Pick<Listing, "status" | "sold_price">): string => | ||
| 97 | 98 | ? "" |
| 98 | 99 | : `<span class="pill status-pill status-${l.status}">${escape(statusLine(l))}</span>`; |
| 99 | 100 | |
| 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 | ||
| 103 | 101 | /** The agent's way to say where the listing stands: a choice, the price it sold at, one button. */ |
| 104 | 102 | const statusForm = (l: Listing): string => |
| 105 | 103 | `<form class="row status-form" method="post" action="/listings/${l.id}/status"> |
| @@ -107,7 +105,7 @@ const statusForm = (l: Listing): string => | ||
| 107 | 105 | <select class="input select" id="status" name="status">${STATUSES.map((s) => `<option value="${s}"${s === l.status ? " selected" : ""}>${STATUS_LABELS[s]}</option>`).join("")}</select> |
| 108 | 106 | <label class="sr" for="sold_price">Sold price, optional</label> |
| 109 | 107 | <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> | |
| 111 | 109 | </form>`; |
| 112 | 110 | |
| 113 | 111 | /** 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 { | ||
| 259 | 257 | ${l.status === "active" ? "" : `<p class="flyer-status status-${l.status}" role="note">${escape(statusLine(l))}</p>`} |
| 260 | 258 | <div class="flyer-hero"> |
| 261 | 259 | ${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> | |
| 263 | 261 | </div> |
| 264 | 262 | <header class="flyer-head"> |
| 265 | 263 | <div> |
src/pages/property.ts +2 −2
| @@ -5,12 +5,12 @@ | ||
| 5 | 5 | * approval, sees a notice that it is not public yet. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { factItems, money, whenOf } from "../generate.ts"; | |
| 8 | import { factItems, money, statusLine, whenOf } from "../generate.ts"; | |
| 9 | 9 | import { escape } from "../http.ts"; |
| 10 | 10 | import { propertyPath, publicDescription, type Launched } from "../listing.ts"; |
| 11 | 11 | import type { Who } from "../who.ts"; |
| 12 | 12 | import { layout } from "./layout.ts"; |
| 13 | import { photoUrl, statusLine } from "./listing.ts"; | |
| 13 | import { photoUrl } from "./listing.ts"; | |
| 14 | 14 | |
| 15 | 15 | export function propertyPage(input: { who: Who; listing: Launched }): string { |
| 16 | 16 | const { who, listing: l } = input; |
src/routes.ts +5 −4
| @@ -215,7 +215,7 @@ const LISTING_FIELDS = { | ||
| 215 | 215 | ...FACTS, |
| 216 | 216 | photos: { type: "array", items: { type: "string" }, maxItems: PHOTOS_MAX }, |
| 217 | 217 | 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." }, | |
| 219 | 219 | sold_price: { type: ["integer", "null"], description: "Whole dollars; only a sold listing has one, and only if the agent said." }, |
| 220 | 220 | 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." }, |
| 221 | 221 | created_at: { type: "string", format: "date-time" }, |
| @@ -293,7 +293,7 @@ const LISTING_CHANGE: Schema = { | ||
| 293 | 293 | kind: { enum: [...KINDS] }, |
| 294 | 294 | text: { type: "string", minLength: 1, maxLength: ASSET_MAX }, |
| 295 | 295 | 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." }, | |
| 297 | 297 | sold_price: { type: ["integer", "null"], description: "With status sold: the price it sold at, in whole dollars; leave out to say nothing." }, |
| 298 | 298 | facts: { |
| 299 | 299 | type: "object", |
| @@ -675,6 +675,7 @@ export const routes: readonly Route[] = [ | ||
| 675 | 675 | if (form === undefined) return text("too large", 413); |
| 676 | 676 | const id = Number(c.params.id); |
| 677 | 677 | 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); | |
| 678 | 679 | return back(done.ok ? "Status saved." : done.message, `/listings/${id}`); |
| 679 | 680 | }, |
| 680 | 681 | }, |
| @@ -824,7 +825,7 @@ export const routes: readonly Route[] = [ | ||
| 824 | 825 | summary: "The listings, newest first, each with how many phrases the Fair Housing check flags. Filter by status and search the address.", |
| 825 | 826 | query: { |
| 826 | 827 | 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." }, | |
| 828 | 829 | q: { type: "string", maxLength: Q_MAX, description: "Only listings whose address contains this, any case." }, |
| 829 | 830 | }, |
| 830 | 831 | answer: list(LISTING_SUMMARY), |
| @@ -873,7 +874,7 @@ export const routes: readonly Route[] = [ | ||
| 873 | 874 | : editAsset(c.db, c.who, { id, kind: String(body.kind ?? ""), text: body.text }), |
| 874 | 875 | ); |
| 875 | 876 | }, |
| 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 }, | |
| 877 | 878 | }, |
| 878 | 879 | { |
| 879 | 880 | 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 | ||
| 79 | 79 | db.run("UPDATE asset SET approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL"); |
| 80 | 80 | db.run(`UPDATE asset SET edited = 1 WHERE kind = 'flyer' AND listing_id = ${id}`); |
| 81 | 81 | 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"); | |
| 83 | 84 | migrate(db); |
| 84 | 85 | const rows = () => |
| 85 | 86 | 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 | ||
| 256 | 256 | expect(after).toContain(`Property page: ${url}`); |
| 257 | 257 | expect(after).not.toContain("not public yet"); |
| 258 | 258 | }); |
| 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: | ||
| 73 | 73 | return { date: m?.[1] ?? r, start: m?.[2] ?? "", end: m?.[3] ?? "" }; |
| 74 | 74 | }); |
| 75 | 75 | |
| 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. |
||
| 77 | 77 | export const statusToLine = (l: { status: string; sold_price: number | null }): string => |
| 78 | 78 | `${l.status.replace("_", " ")}${l.sold_price === null ? "" : ` ${l.sold_price}`}`; |
| 79 | 79 | 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 } | ||
| 196 | 196 | } |
| 197 | 197 | lines.push(""); |
| 198 | 198 | 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)`; | |
| 200 | 200 | // The tail of what is typed, so the cursor stays on the screen. |
| 201 | 201 | const room = Math.max(20, size.columns - 4); |
| 202 | 202 | 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 { | ||
| 954 | 954 | color: var(); |
| 955 | 955 | background: var(); |
| 956 | 956 | } |
| 957 | .status-pill.status-coming_soon, | |
| 958 | .flyer-status.status-coming_soon, | |
| 957 | 959 | .status-pill.status-under_contract, |
| 958 | 960 | .flyer-status.status-under_contract { |
| 959 | 961 | color: var(); |
Rewrap this comment; the second line runs far past the others.