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

Add an open house to a listing: its date and time on the flyer, the email and every social post #40

Merged opened this

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

Merged

mason/listing-launch:mason/34-add-an-open-house-to-a-listing-its-date → main 2 commits fork 4 threads · 4 unresolved

  • Merged. mason/34-add-an-open-house-to-a-listing-its-date was merged into main as 28ff3a0.

Review

Sign in to review this change.
16 files changed +605 −28
README.md +10 −1
@@ -31,7 +31,14 @@ to make a listing of your own.
3131 press **Save and write all again**: the new price is on the flyer, every
3232 asset is written again from the facts (your edits are replaced), and the
3333 approval is cleared, so it says "0 of 6 approved". The photos stay.
34 7. **Print the flyer.** Press **Print flyer**: a one-page flyer opens with
34 7. **Add an open house.** Press **Edit facts** again. Under "Open house"
35 pick a date, a start and an end time (Alder Lane's sample already has one),
36 then press **Save and write all again**. The flyer gets a band under the
37 price, the email a line above its body, and the MLS description and every
38 post a closing line, such as "Open Sunday, March 7, 1–3 pm", all in the
39 listing's tone. Clearing the three boxes takes it off again. The times are
40 kept as you type them: no time zone is applied.
41 8. **Print the flyer.** Press **Print flyer**: a one-page flyer opens with
3542 the photo, price, facts, description and your name. Print it or save it
3643 as a PDF.
3744
@@ -235,6 +242,8 @@ curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -
235242 curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"tone":"bold"}'
236243 # change facts (any launch field); every asset is written again, approvals cleared
237244 curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"facts":{"price":649000}}'
245 # an open house (up to two; local date and 24-hour times as typed; [] clears)
246 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"}]}}'
238247 curl -X DELETE https://<app>/api/listings/1
239248 ```
240249
contract/openapi.json +110
@@ -397,6 +397,33 @@
397397 "type": "string",
398398 "format": "email",
399399 "maxLength": 120
400 },
401 "open_houses": {
402 "type": "array",
403 "maxItems": 2,
404 "description": "Up to two open houses, in local time as typed: a real date (YYYY-MM-DD), start and end as HH:MM with the end after the start. An empty list clears them.",
405 "items": {
406 "type": "object",
407 "properties": {
408 "date": {
409 "type": "string",
410 "format": "date"
411 },
412 "start": {
413 "type": "string",
414 "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$"
415 },
416 "end": {
417 "type": "string",
418 "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$"
419 }
420 },
421 "required": [
422 "date",
423 "start",
424 "end"
425 ]
426 }
400427 }
401428 },
402429 "required": [
@@ -618,6 +645,33 @@
618645 "type": "string",
619646 "format": "email",
620647 "maxLength": 120
648 },
649 "open_houses": {
650 "type": "array",
651 "maxItems": 2,
652 "description": "Up to two open houses, in local time as typed: a real date (YYYY-MM-DD), start and end as HH:MM with the end after the start. An empty list clears them.",
653 "items": {
654 "type": "object",
655 "properties": {
656 "date": {
657 "type": "string",
658 "format": "date"
659 },
660 "start": {
661 "type": "string",
662 "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$"
663 },
664 "end": {
665 "type": "string",
666 "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$"
667 }
668 },
669 "required": [
670 "date",
671 "start",
672 "end"
673 ]
674 }
621675 }
622676 }
623677 },
@@ -1064,6 +1118,33 @@
10641118 "format": "email",
10651119 "maxLength": 120
10661120 },
1121 "open_houses": {
1122 "type": "array",
1123 "maxItems": 2,
1124 "description": "Up to two open houses, in local time as typed: a real date (YYYY-MM-DD), start and end as HH:MM with the end after the start. An empty list clears them.",
1125 "items": {
1126 "type": "object",
1127 "properties": {
1128 "date": {
1129 "type": "string",
1130 "format": "date"
1131 },
1132 "start": {
1133 "type": "string",
1134 "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$"
1135 },
1136 "end": {
1137 "type": "string",
1138 "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$"
1139 }
1140 },
1141 "required": [
1142 "date",
1143 "start",
1144 "end"
1145 ]
1146 }
1147 },
10671148 "photos": {
10681149 "type": "array",
10691150 "items": {
@@ -1242,6 +1323,7 @@
12421323 "license",
12431324 "agent_phone",
12441325 "agent_email",
1326 "open_houses",
12451327 "photos",
12461328 "headshot",
12471329 "created_at",
@@ -1344,6 +1426,33 @@
13441426 "format": "email",
13451427 "maxLength": 120
13461428 },
1429 "open_houses": {
1430 "type": "array",
1431 "maxItems": 2,
1432 "description": "Up to two open houses, in local time as typed: a real date (YYYY-MM-DD), start and end as HH:MM with the end after the start. An empty list clears them.",
1433 "items": {
1434 "type": "object",
1435 "properties": {
1436 "date": {
1437 "type": "string",
1438 "format": "date"
1439 },
1440 "start": {
1441 "type": "string",
1442 "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$"
1443 },
1444 "end": {
1445 "type": "string",
1446 "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$"
1447 }
1448 },
1449 "required": [
1450 "date",
1451 "start",
1452 "end"
1453 ]
1454 }
1455 },
13471456 "photos": {
13481457 "type": "array",
13491458 "items": {
@@ -1415,6 +1524,7 @@
14151524 "license",
14161525 "agent_phone",
14171526 "agent_email",
1527 "open_houses",
14181528 "photos",
14191529 "headshot",
14201530 "created_at",
docs/design/areas/open-house.md added +7
@@ -0,0 +1,7 @@
1 # Open house
2
3 New component classes, all built from existing tokens:
4
5 - `flyer-open` — the band under the price on the flyer (`static/app.css`); it also carries `brand-fill`, the agent's colour.
6 - `listing-open` — the open house line under the facts in a listing's head.
7 - `open-grid` — the form's date / start / end row, a `facts-grid` with the same columns.
src/actions.ts +8 −4
@@ -19,6 +19,7 @@ import {
1919 fromRow,
2020 isKind,
2121 isTone,
22 touchesOpenHouses,
2223 validate,
2324 type AssetRow,
2425 type Caller,
@@ -325,7 +326,7 @@ export interface Upload {
325326 }
326327
327328 const LISTING_COLUMNS =
328 "address, beds, baths, sqft, lot, year, price, features, tone, palette, agent_name, brokerage, license, agent_phone, agent_email";
329 "address, beds, baths, sqft, lot, year, price, features, tone, palette, agent_name, brokerage, license, agent_phone, agent_email, open_houses";
329330
330331 /** A listing with its assets, or nothing. */
331332 function launched(db: Database, id: number): Launched | undefined {
@@ -365,7 +366,7 @@ export function insertListing(
365366 const row = db
366367 .query<ListingRow, (string | number | null)[]>(
367368 `INSERT INTO listing (author_id, author_name, ${LISTING_COLUMNS}, photos, headshot)
368 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19) RETURNING *`,
369 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20) RETURNING *`,
369370 )
370371 .get(
371372 author.id,
@@ -385,6 +386,7 @@ export function insertListing(
385386 input.license,
386387 input.agent_phone,
387388 input.agent_email,
389 JSON.stringify(input.open_houses),
388390 JSON.stringify(photos),
389391 headshot,
390392 )!;
@@ -794,6 +796,8 @@ function changeFacts(
794796 license: now.license,
795797 agent_phone: now.agent_phone,
796798 agent_email: now.agent_email,
799 // Left out, the open houses stay; the form's open1_… fields or `open_houses` replace them.
800 ...(touchesOpenHouses(input.fields) ? {} : { open_houses: now.open_houses }),
797801 ...input.fields,
798802 });
799803 if (!valid.ok) return fail("invalid", valid.message);
@@ -802,11 +806,11 @@ function changeFacts(
802806 db.query(
803807 `UPDATE listing SET address = ?1, beds = ?2, baths = ?3, sqft = ?4, lot = ?5, year = ?6, price = ?7,
804808 features = ?8, tone = ?9, palette = ?10, agent_name = ?11, brokerage = ?12, license = ?13,
805 agent_phone = ?14, agent_email = ?15 WHERE id = ?16`,
809 agent_phone = ?14, agent_email = ?15, open_houses = ?16 WHERE id = ?17`,
806810 ).run(
807811 v.address, v.beds, v.baths, v.sqft, v.lot, v.year, v.price,
808812 JSON.stringify(v.features), v.tone, v.palette, v.agent_name, v.brokerage,
809 v.license, v.agent_phone, v.agent_email, now.id,
813 v.license, v.agent_phone, v.agent_email, JSON.stringify(v.open_houses), now.id,
810814 );
811815 touch(db, now.id);
812816 writeAssets(db, { ...now, ...v });
src/db.ts +2
@@ -90,6 +90,8 @@ export const migrations: string[] = [
9090 `ALTER TABLE asset ADD COLUMN approved_at TEXT`,
9191 // Which door the sign-off came through (page, api, tty); null before this was recorded.
9292 `ALTER TABLE asset ADD COLUMN approved_via TEXT`,
93 // Up to two open houses, as typed: a JSON list of { date, start, end }, local time, never converted.
94 `ALTER TABLE listing ADD COLUMN open_houses TEXT NOT NULL DEFAULT '[]'`,
9395 ];
9496
9597 export function openDb(dir = dataDir): Database {
src/generate.ts +75 −8
@@ -33,6 +33,13 @@ export const LABELS: Readonly<Record<Kind, string>> = {
3333 email: "Email blast",
3434 };
3535
36 /** One open house, as typed: the local date (YYYY-MM-DD) and 24-hour times (HH:MM). Never converted. */
37 export interface OpenHouse {
38 readonly date: string;
39 readonly start: string;
40 readonly end: string;
41 }
42
3643 export interface Facts {
3744 readonly address: string;
3845 readonly beds: number;
@@ -43,6 +50,7 @@ export interface Facts {
4350 readonly price: number;
4451 readonly features: readonly string[];
4552 readonly tone: Tone;
53 readonly open_houses?: readonly OpenHouse[];
4654 }
4755
4856 export interface Agent {
@@ -123,10 +131,55 @@ export const factsLine = (f: Facts): string => {
123131 ].join(" · ");
124132 };
125133
134 const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
135 const MONTHS = [
136 "January", "February", "March", "April", "May", "June",
137 "July", "August", "September", "October", "November", "December",
138 ];
139
140 /** "13:30" → { h: 1, m: "30", pm: true }, on a 12-hour clock. */
141 const clock = (t: string): { h: number; m: string; pm: boolean } => {
142 const h = Number(t.slice(0, 2));
143 return { h: h % 12 === 0 ? 12 : h % 12, m: t.slice(3, 5), pm: h >= 12 };
144 };
145 const hm = (c: { h: number; m: string }): string => (c.m === "00" ? `${c.h}` : `${c.h}:${c.m}`);
146
147 /** "1–3 pm", "11 am–1 pm", "1:30–3 pm": the meridiem said once when both ends share it. */
148 export const timeRange = (start: string, end: string): string => {
149 const a = clock(start);
150 const b = clock(end);
151 const tail = `${hm(b)} ${b.pm ? "pm" : "am"}`;
152 return a.pm === b.pm ? `${hm(a)}–${tail}` : `${hm(a)} ${a.pm ? "pm" : "am"}–${tail}`;
153 };
154
155 /** "Sunday, March 9, 1–3 pm", from the date and times as typed (no zone is ever applied). */
156 export const whenOf = (o: OpenHouse): string => {
157 const [y, m, d] = o.date.split("-").map(Number) as [number, number, number];
158 const day = WEEKDAYS[new Date(Date.UTC(y, m - 1, d)).getUTCDay()]!;
159 return `${day}, ${MONTHS[m - 1]} ${d}, ${timeRange(o.start, o.end)}`;
160 };
161
162 /** The open house as a person would say it, in the listing's tone; null when there is none. */
163 export const openHouseLine = (f: Facts): string | null => {
164 const when = (f.open_houses ?? []).map(whenOf).join(" and ");
165 if (when === "") return null;
166 return {
167 classic: `Open ${when}.`,
168 warm: `Come by the open house: ${when}.`,
169 bold: `OPEN HOUSE: ${when}.`,
170 }[f.tone];
171 };
172
126173 const signature = (a: Agent): string =>
127174 `${a.name} · ${a.brokerage} · Lic. ${a.license}`;
128175
129176 function mls(f: Facts): string {
177 const text = mlsText(f);
178 const open = openHouseLine(f);
179 return open === null ? text : `${text}\n${open}`;
180 }
181
182 function mlsText(f: Facts): string {
130183 const where = f.address;
131184 const size = `${num(f.beds)}-bedroom, ${num(f.baths)}-bath`;
132185 const sized = withArticle(size);
@@ -142,13 +195,14 @@ function mls(f: Facts): string {
142195 }
143196
144197 function socialShort(f: Facts, a: Agent): string {
198 const open = openHouseLine(f);
145199 const lead = {
146200 classic: "Just listed",
147201 warm: "Just listed, and we love it",
148202 bold: "JUST LISTED",
149203 }[f.tone];
150204 const first = f.features[0] === undefined ? "" : ` ${sentence(f.features[0])}`;
151 return `${lead}: ${street(f.address)} · ${num(f.beds)} bd · ${num(f.baths)} ba · ${count(f.sqft)} sq ft · ${money(f.price)}.${first} ${a.name}, ${a.brokerage}. #JustListed #NewListing`;
205 return `${lead}: ${street(f.address)} · ${num(f.beds)} bd · ${num(f.baths)} ba · ${count(f.sqft)} sq ft · ${money(f.price)}.${first}${open === null ? "" : ` ${open}`} ${a.name}, ${a.brokerage}. #JustListed #NewListing`;
152206 }
153207
154208 function socialMedium(f: Facts, a: Agent): string {
@@ -166,10 +220,12 @@ function socialMedium(f: Facts, a: Agent): string {
166220 warm: "I would love to show you around. Send me a message to set up a visit.",
167221 bold: "Showings are filling up. Message me today.",
168222 }[f.tone];
169 return `${open}\n\n${plural(f.beds, "bedroom")} · ${plural(f.baths, "bath")} · ${count(f.sqft)} sq ft · built ${f.year}\n\n${top}\n\n${close}\n${signature(a)} · ${a.phone}\n#JustListed #HomeForSale`;
223 const when = openHouseLine(f);
224 return `${open}\n\n${plural(f.beds, "bedroom")} · ${plural(f.baths, "bath")} · ${count(f.sqft)} sq ft · built ${f.year}\n\n${top}\n\n${when === null ? "" : `${when}\n`}${close}\n${signature(a)} · ${a.phone}\n#JustListed #HomeForSale`;
170225 }
171226
172227 function socialCarousel(f: Facts, a: Agent): string {
228 const open = openHouseLine(f);
173229 const slides = [
174230 {
175231 classic: `Just listed: ${street(f.address)}`,
@@ -177,6 +233,7 @@ function socialCarousel(f: Facts, a: Agent): string {
177233 bold: `${street(f.address)}. Just listed.`,
178234 }[f.tone],
179235 `${money(f.price)} · ${num(f.beds)} bd · ${num(f.baths)} ba · ${count(f.sqft)} sq ft`,
outdated

openHouseLine(f) is called twice here; compute it once above the array.

236 ...(open === null ? [] : [open]),
180237 ...f.features.slice(0, 5).map(bare),
181238 `Built ${f.year}${lotPhrase(f, " on ")}`,
182239 `Showings with ${a.name}, ${a.brokerage} · ${a.phone} · Lic. ${a.license}`,
@@ -199,7 +256,8 @@ function flyer(f: Facts): string {
199256 warm: "Come and see it for yourself.",
200257 bold: "See it before it is gone.",
201258 }[f.tone];
202 return `${headline}\n\n${plural(f.beds, "bedroom")} and ${plural(f.baths, "bath")} across ${count(f.sqft)} square feet. ${body} ${close}`;
259 const open = openHouseLine(f);
260 return `${headline}\n\n${plural(f.beds, "bedroom")} and ${plural(f.baths, "bath")} across ${count(f.sqft)} square feet. ${body} ${close}${open === null ? "" : `\n\n${open}`}`;
203261 }
204262
205263 function email(f: Facts, a: Agent): string {
@@ -220,7 +278,8 @@ function email(f: Facts, a: Agent): string {
220278 warm: "If you or someone you know would like to see it, just reply to this email and I will set up a time.",
221279 bold: "Want in? Reply now and I will get you on the showing schedule.",
222280 }[f.tone];
223 return `Subject: ${subject}\n\n${open}\n\nThe facts: ${plural(f.beds, "bedroom")}, ${plural(f.baths, "bath")}, ${count(f.sqft)} square feet${lotPhrase(f, " on ")}, built in ${f.year}.\n\nHighlights:\n${list}\n\n${close}\n\n${a.name}\n${a.brokerage} · License ${a.license}\n${a.phone} · ${a.email}`;
281 const when = openHouseLine(f);
282 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}`;
224283 }
225284
226285 /** All five assets (six texts: social is three posts), for one listing. */
@@ -239,8 +298,16 @@ export function generate(listing: {
239298 };
240299 }
241300
242 /** The flyer's copy, split back into what the page lays out. */
243 export function flyerParts(text: string): { headline: string; body: string } {
244 const [headline = "", ...rest] = text.split(/\n\s*\n/);
245 return { headline: headline.trim(), body: rest.join("\n\n").trim() };
301 /**
302 * The flyer's copy, split back into what the page lays out. The open house
303 * line (`openLine`, as generated) closes the copy and is lifted out for the
304 * band under the price; words a person has edited it out of stay as written.
305 */
306 export function flyerParts(
307 text: string,
308 openLine: string | null = null,
309 ): { headline: string; body: string; open: string } {
310 const [headline = "", ...rest] = text.split(/\n\s*\n/).map((p) => p.trim());
311 const open = openLine !== null && rest.at(-1) === openLine ? rest.pop()! : "";
312 return { headline, body: rest.join("\n\n").trim(), open };
246313 }
src/listing.ts +61
@@ -13,6 +13,7 @@ import {
1313 type Agent,
1414 type Facts,
1515 type Kind,
16 type OpenHouse,
1617 type Tone,
1718 } from "./generate.ts";
1819
@@ -23,9 +24,11 @@ export type Palette = (typeof PALETTES)[number];
2324 export const FEATURES_MIN = 5;
2425 export const FEATURES_MAX = 10;
2526 export const ASSET_MAX = 5000;
27 export const OPEN_HOUSES_MAX = 2;
2628
2729 /** What a person sends to launch a listing, validated. */
2830 export interface ListingInput extends Facts {
31 readonly open_houses: readonly OpenHouse[];
2932 readonly palette: Palette;
3033 readonly agent_name: string;
3134 readonly brokerage: string;
@@ -98,6 +101,7 @@ export interface ListingRow {
98101 readonly features: string;
99102 readonly tone: Tone;
100103 readonly palette: Palette;
104 readonly open_houses: string;
101105 readonly agent_name: string;
102106 readonly brokerage: string;
103107 readonly license: string;
@@ -112,6 +116,7 @@ export interface ListingRow {
112116 export const fromRow = (row: ListingRow): Listing => ({
113117 ...row,
114118 features: JSON.parse(row.features) as string[],
119 open_houses: JSON.parse(row.open_houses) as OpenHouse[],
115120 photos: JSON.parse(row.photos) as string[],
116121 });
117122
@@ -182,6 +187,58 @@ export const featureLines = (v: unknown): string[] =>
182187 .map((l) => line(l).replace(/^[-•*]\s*/, ""))
183188 .filter((l) => l !== "");
184189
190 /** The form posts `open1_date`, `open1_start`, `open1_end` (and `open2_…`); a script sends `open_houses: [{ date, start, end }]`. */
191 const OPEN_FIELD = /^open[1-9]_(date|start|end)$/;
192 export const touchesOpenHouses = (raw: Readonly<Record<string, unknown>>): boolean =>
193 raw.open_houses !== undefined || Object.keys(raw).some((k) => OPEN_FIELD.test(k));
194
195 const openHousesRaw = (raw: Readonly<Record<string, unknown>>): unknown[] | undefined => {
196 if (raw.open_houses !== undefined)
197 return raw.open_houses === null || (typeof raw.open_houses === "string" && raw.open_houses.trim() === "")
198 ? []
199 : Array.isArray(raw.open_houses)
200 ? raw.open_houses
201 : undefined;
202 return Array.from({ length: OPEN_HOUSES_MAX }, (_, i) => ({
203 date: raw[`open${i + 1}_date`],
204 start: raw[`open${i + 1}_start`],
205 end: raw[`open${i + 1}_end`],
206 }));
207 };
208
209 const realDate = (t: string): boolean => {
210 const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(t);
211 if (m === null) return false;
212 const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
213 const at = new Date(Date.UTC(y, mo - 1, d));
214 return y >= 1900 && at.getUTCFullYear() === y && at.getUTCMonth() === mo - 1 && at.getUTCDate() === d;
215 };
216 const realTime = (t: string): boolean => /^([01]\d|2[0-3]):[0-5]\d$/.test(t);
217
218 /** The open houses, checked: blank rows dropped, a real date, an end after the start, at most two, in date order. */
219 export function openHouses(raw: Readonly<Record<string, unknown>>): Checked<OpenHouse[]> {
220 const rows = openHousesRaw(raw);
221 const bad = (message: string): Checked<OpenHouse[]> => ({ ok: false, message });
222 if (rows === undefined) return bad("Open houses is a list of dates and times.");
223 const filled = rows.filter((r) => typeof r !== "object" || r === null || Object.values(r).some((v) => line(v) !== ""));
224 if (filled.length > OPEN_HOUSES_MAX) return bad(`At most ${OPEN_HOUSES_MAX} open houses.`);
225 const found: OpenHouse[] = [];
226 for (const r of filled) {
227 const o = typeof r === "object" && r !== null ? (r as Record<string, unknown>) : {};
228 const date = line(o.date);
229 const start = line(o.start);
230 const end = line(o.end);
231 if (date === "" && start === "" && end === "") continue;
232 if (!realDate(date)) return bad("The open house date is a real date, like 2027-03-09.");
outdated

Every row is walked before the max-two check; refuse a list longer than OPEN_HOUSES_MAX up front so a large array costs nothing.

233 if (!realTime(start) || !realTime(end))
234 return bad("The open house start and end are times like 13:00.");
235 if (end <= start) return bad("The open house ends after it starts.");
236 found.push({ date, start, end });
237 }
238 found.sort((a, b) => `${a.date}${a.start}`.localeCompare(`${b.date}${b.start}`));
239 return { ok: true, value: found };
240 }
241
185242 /**
186243 * Is this a listing? Every field from the wire, checked and normalised, or
187244 * the first thing wrong with it in words a person can act on.
@@ -228,6 +285,9 @@ export function validate(
228285 const palette = raw.palette ?? "navy";
229286 if (!isPalette(palette)) return bad(`The colour is one of ${PALETTES.join(", ")}.`);
230287
288 const open = openHouses(raw);
289 if (!open.ok) return bad(open.message);
290
231291 const agent_name = text("agent_name", 2, 80);
232292 if (agent_name === undefined) return bad("Your name is 2 to 80 characters.");
233293 const brokerage = text("brokerage", 2, 100);
@@ -256,6 +316,7 @@ export function validate(
256316 features,
257317 tone,
258318 palette,
319 open_houses: open.value,
259320 agent_name,
260321 brokerage,
261322 license,
src/mcp.ts +15 −3
@@ -40,6 +40,17 @@ interface Tool {
4040 ) => Result<unknown>;
4141 }
4242
43 const OPEN_HOUSES = {
44 type: "array",
45 maxItems: 2,
46 description: "Up to two open houses in local time as typed: { date: YYYY-MM-DD, start: HH:MM, end: HH:MM }.",
47 items: {
48 type: "object",
49 properties: { date: { type: "string" }, start: { type: "string" }, end: { type: "string" } },
50 required: ["date", "start", "end"],
51 },
52 };
53
4354 export const tools: readonly Tool[] = [
4455 {
4556 name: "list_notes",
@@ -104,7 +115,7 @@ export const tools: readonly Tool[] = [
104115 {
105116 name: "launch_listing",
106117 description:
107 "Launch a listing from its facts and the agent's brand (no photos here): address, beds, baths, sqft, lot, year, price, five to ten features, tone, palette, agent_name, brokerage, license, agent_phone, agent_email.",
118 "Launch a listing from its facts and the agent's brand (no photos here): address, beds, baths, sqft, lot, year, price, five to ten features, tone, palette, open_houses (optional, up to two), agent_name, brokerage, license, agent_phone, agent_email.",
108119 inputSchema: {
109120 type: "object",
110121 properties: {
@@ -123,6 +134,7 @@ export const tools: readonly Tool[] = [
123134 license: { type: "string" },
124135 agent_phone: { type: "string" },
125136 agent_email: { type: "string" },
137 open_houses: OPEN_HOUSES,
126138 },
127139 required: ["address", "beds", "baths", "sqft", "lot", "year", "price", "features", "agent_name", "brokerage", "license", "agent_phone", "agent_email"],
128140 },
@@ -209,7 +221,7 @@ export const tools: readonly Tool[] = [
209221 {
210222 name: "update_facts",
211223 description:
212 "Change a listing's facts (price, square feet, features, brand...): pass only the fields that change. Every asset is written again in its tone, replacing edits and clearing every approval. Only the listing's agent or an admin can.",
224 "Change a listing's facts (price, square feet, features, open houses, brand...): pass only the fields that change. Every asset is written again in its tone, replacing edits and clearing every approval. Only the listing's agent or an admin can.",
213225 inputSchema: {
214226 type: "object",
215227 properties: {
@@ -217,7 +229,7 @@ export const tools: readonly Tool[] = [
217229 facts: {
218230 type: "object",
219231 description:
220 "Any of address, beds, baths, sqft, lot, year, price, features, tone, palette, agent_name, brokerage, license, agent_phone, agent_email.",
232 "Any of address, beds, baths, sqft, lot, year, price, features, tone, palette, open_houses (a list of up to two { date: YYYY-MM-DD, start: HH:MM, end: HH:MM }; [] clears), agent_name, brokerage, license, agent_phone, agent_email.",
221233 },
222234 },
223235 required: ["id", "facts"],
src/openhouse.test.ts added +187
@@ -0,0 +1,187 @@
1 /**
2 * The open house: validated at the boundary, written into every asset in
3 * every tone, rewritten (and the approvals cleared) when it changes, and
4 * reachable from the page, the API, the MCP and the terminal.
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, updateFacts } from "./actions.ts";
11 import { openDb } from "./db.ts";
12 import { check } from "./fairhousing.ts";
13 import { generate, openHouseLine, timeRange, TONES, KINDS, flyerParts, type OpenHouse } from "./generate.ts";
14 import { agentOf, validate } from "./listing.ts";
15 import { rpc } from "./mcp.ts";
16 import { SAMPLE } from "./sample.ts";
17 import { session } from "./tty.ts";
18 import type { Who } from "./who.ts";
19
20 const sunday: OpenHouse = { date: "2027-03-07", start: "13:00", end: "15:00" };
21 const saturday: OpenHouse = { date: "2027-03-06", start: "11:00", end: "13:30" };
22 const me: Who = { role: "user", id: "u1", name: "me" };
23 const plain = (s: string): string => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
24
25 test("times read like a person said them", () => {
26 expect(timeRange("13:00", "15:00")).toBe("1–3 pm");
27 expect(timeRange("11:00", "13:00")).toBe("11 am–1 pm");
28 expect(timeRange("13:30", "15:00")).toBe("1:30–3 pm");
29 expect(timeRange("09:00", "11:30")).toBe("9–11:30 am");
30 expect(timeRange("00:00", "12:00")).toBe("12 am–12 pm");
31 expect(openHouseLine({ ...SAMPLE, tone: "classic", open_houses: [sunday] })).toBe("Open Sunday, March 7, 1–3 pm.");
32 expect(openHouseLine({ ...SAMPLE, tone: "classic", open_houses: [saturday, sunday] })).toBe(
33 "Open Saturday, March 6, 11 am–1:30 pm and Sunday, March 7, 1–3 pm.",
34 );
35 expect(openHouseLine({ ...SAMPLE, open_houses: [] })).toBeNull();
36 });
37
38 test("validation: a real date, an end after the start, at most two", () => {
39 const ok = validate({ ...SAMPLE, open_houses: [sunday] });
40 expect(ok.ok && ok.value.open_houses).toEqual([sunday]);
41 // The form's fields mean the same, and a blank row is no open house.
42 const form = validate({ ...SAMPLE, open_houses: undefined, open1_date: "2027-03-07", open1_start: "13:00", open1_end: "15:00", open2_date: "", open2_start: "", open2_end: "" });
43 expect(form.ok && form.value.open_houses).toEqual([sunday]);
44 // In date order, whichever order they came.
45 const two = validate({ ...SAMPLE, open_houses: [sunday, saturday] });
46 expect(two.ok && two.value.open_houses).toEqual([saturday, sunday]);
47 const refused: [unknown, string][] = [
48 [[{ ...sunday, date: "2027-02-30" }], "real date"],
49 [[{ ...sunday, date: "next Sunday" }], "real date"],
50 [[{ ...sunday, start: "25:00" }], "times"],
51 [[{ ...sunday, end: "" }], "times"],
52 [[{ ...sunday, end: "13:00" }], "ends after"],
53 [[{ ...sunday, end: "12:00" }], "ends after"],
54 [[sunday, saturday, { ...sunday, date: "2027-03-08" }], "At most 2"],
55 ["Sunday", "list"],
56 ];
57 for (const [open_houses, says] of refused) {
58 const r = validate({ ...SAMPLE, open_houses });
59 expect([says, r.ok ? "accepted" : r.message.includes(says)]).toEqual([says, true]);
60 }
61 });
62
63 test("every asset carries the line when set, in every tone, and none when cleared", () => {
64 for (const tone of TONES) {
65 const facts = { ...SAMPLE, tone, open_houses: [sunday] };
66 const line = openHouseLine(facts)!;
67 const words = generate({ facts, agent: agentOf(SAMPLE) });
68 for (const kind of KINDS) expect([tone, kind, words[kind].includes(line)]).toEqual([tone, kind, true]);
69 expect(words.mls.split("\n").at(-1)).toBe(line);
70 expect(words.email.split("\n\n")[1]).toBe(line);
71 expect(flyerParts(words.flyer, line).open).toBe(line);
72 expect(flyerParts(words.flyer, line).body).not.toContain("Sunday");
73 for (const kind of KINDS) expect(check(words[kind]).clear).toBe(true);
74 const none = generate({ facts: { ...facts, open_houses: [] }, agent: agentOf(SAMPLE) });
75 for (const kind of KINDS) expect([tone, kind, /open|Sunday/i.test(none[kind])]).toEqual([tone, kind, false]);
76 }
77 });
78
79 test("adding, changing and clearing it writes the assets again and clears approval", () => {
80 const db = openDb(mkdtempSync(join(tmpdir(), "app-open-")));
81 const made = launchListing(db, me, { fields: { ...SAMPLE, tone: "classic" } });
82 if (!made.ok) throw new Error(made.message);
83 const id = made.value.id;
84 const text = (kind: string): string => {
85 const l = getListing(db, me, { id });
86 if (!l.ok) throw new Error(l.message);
87 return l.value.assets.find((a) => a.kind === kind)!.text;
88 };
89 const approve = () => {
90 const l = getListing(db, me, { id });
91 if (!l.ok) throw new Error(l.message);
92 for (const a of l.value.assets) approveAsset(db, me, { id, kind: a.kind, door: "page", seen: a.updated_at });
93 const again = getListing(db, me, { id });
94 return again.ok ? again.value.assets.filter((a) => a.approval !== null).length : -1;
95 };
96 expect(text("flyer")).not.toContain("Open ");
97 expect(approve()).toBe(6);
98
99 const set = updateFacts(db, me, { id, fields: { open_houses: [sunday] } });
100 expect(set).toMatchObject({ ok: true, value: { open_houses: [sunday] } });
101 expect(text("flyer")).toContain("Open Sunday, March 7, 1–3 pm.");
102 expect(text("email")).toContain("Open Sunday, March 7, 1–3 pm.");
103 if (set.ok) expect(set.value.assets.every((a) => a.approval === null)).toBe(true);
104
105 // A change to something else keeps it.
106 expect(approve()).toBe(6);
107 expect(updateFacts(db, me, { id, fields: { price: 650000 } })).toMatchObject({ ok: true, value: { open_houses: [sunday] } });
108
109 const moved = updateFacts(db, me, { id, fields: { open_houses: [{ ...sunday, date: "2027-03-14" }] } });
110 expect(moved.ok).toBe(true);
111 expect(text("mls")).toContain("Sunday, March 14, 1–3 pm");
112 expect(text("mls")).not.toContain("March 7");
113
114 expect(approve()).toBe(6);
115 const cleared = updateFacts(db, me, { id, fields: { open_houses: [] } });
116 if (!cleared.ok) throw new Error(cleared.message);
117 expect(cleared.value.open_houses).toEqual([]);
118 expect(cleared.value.assets.every((a) => a.approval === null && !/open house|Open /i.test(a.text))).toBe(true);
119
120 // The form's fields, blank, clear it too.
121 updateFacts(db, me, { id, fields: { open_houses: [sunday] } });
122 const blank = updateFacts(db, me, { id, fields: { open1_date: "", open1_start: "", open1_end: "" } });
123 expect(blank).toMatchObject({ ok: true, value: { open_houses: [] } });
124
125 // A bad one is refused and changes nothing.
126 const before = JSON.stringify(getListing(db, me, { id }));
127 expect(updateFacts(db, me, { id, fields: { open_houses: [{ ...sunday, end: "09:00" }] } })).toMatchObject({ ok: false, error: "invalid" });
128 expect(JSON.stringify(getListing(db, me, { id }))).toBe(before);
129 });
130
131 test("MCP and the terminal set it through the same action", () => {
132 const db = openDb(mkdtempSync(join(tmpdir(), "app-open-doors-")));
133 const made = launchListing(db, me, { fields: { ...SAMPLE, open_houses: [saturday] } });
134 if (!made.ok) throw new Error(made.message);
135 const id = made.value.id;
136 expect(made.value.open_houses).toEqual([saturday]);
137
138 const call = rpc(db, me, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "update_facts", arguments: { id, facts: { open_houses: [sunday] } } } }).body as { result: { content: { text: string }[] } };
139 expect(call.result.content[0]!.text).toContain("2027-03-07");
140
141 const tty = session(db, me);
142 tty.press("\r");
143 expect(plain(tty.screen())).toContain("Sunday, March 7, 1–3 pm");
144 tty.press("o");
145 expect(plain(tty.screen())).toContain("2027-03-07 13:00-15:00");
146 for (let i = 0; i < 60; i++) tty.press("\x7f");
147 for (const key of "2027-03-13 10:00-12:00; 2027-03-14 14:00-16:00") tty.press(key);
148 tty.press("\r");
149 expect(plain(tty.screen())).toContain("Open house saved");
150 const now = getListing(db, me, { id });
151 expect(now.ok && now.value.open_houses.map((o) => o.date)).toEqual(["2027-03-13", "2027-03-14"]);
152 tty.press("o");
153 for (let i = 0; i < 80; i++) tty.press("\x7f");
154 for (const key of "2027-02-30 10:00-12:00") tty.press(key);
155 tty.press("\r");
156 expect(plain(tty.screen())).toContain("real date");
157 tty.press("o");
158 for (let i = 0; i < 80; i++) tty.press("\x7f");
159 tty.press("\r");
160 const gone = getListing(db, me, { id });
161 expect(gone.ok && gone.value.open_houses).toEqual([]);
162 });
163
164 test("PATCH /api/listings/:id sets and clears it, and the API describes it", async () => {
165 const { serve } = await import("./server.ts");
166 const { sign } = await import("./who.ts");
167 const KEY = "a-key-only-the-forge-holds";
168 const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-open-api-")), identityKey: KEY });
169 try {
170 const base = `http://${server.hostname}:${server.port}`;
171 const identity = `open-api|open-api-agent|user|${Date.now() + 600_000}`;
172 const headers = { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY), "content-type": "application/json" };
173 const launch = await fetch(`${base}/api/listings`, { method: "POST", headers, body: JSON.stringify({ ...SAMPLE, open_houses: [sunday] }) });
174 expect(launch.status).toBe(201);
175 const made = (await launch.json()) as { id: number; open_houses: unknown[]; assets: { text: string }[] };
176 expect(made.open_houses).toEqual([sunday]);
177 expect(made.assets.every((a) => a.text.includes("Sunday, March 7, 1–3 pm"))).toBe(true);
178 const patch = (facts: unknown) => fetch(`${base}/api/listings/${made.id}`, { method: "PATCH", headers, body: JSON.stringify({ facts }) });
179 expect((await patch({ open_houses: [{ ...sunday, end: "12:00" }] })).status).toBe(422);
180 const cleared = (await (await patch({ open_houses: [] })).json()) as { open_houses: unknown[]; assets: { text: string }[] };
181 expect(cleared.open_houses).toEqual([]);
182 expect(cleared.assets.some((a) => a.text.includes("Sunday"))).toBe(false);
183 expect(await (await fetch(`${base}/openapi.json`)).text()).toContain("open_houses");
184 } finally {
185 server.stop(true);
186 }
187 });
src/pages/home.ts +15
@@ -51,6 +51,13 @@ export function draftOf(listing: Listing, brandOnly: boolean): Draft {
5151 year: String(listing.year),
5252 price: String(listing.price),
5353 features: listing.features.join("\n"),
54 ...Object.fromEntries(
55 listing.open_houses.flatMap((o, i) => [
56 [`open${i + 1}_date`, o.date],
57 [`open${i + 1}_start`, o.start],
58 [`open${i + 1}_end`, o.end],
59 ]),
60 ),
5461 };
5562 }
5663
@@ -127,6 +134,14 @@ export function form(who: Who, draft: Draft, editing?: Editing): string {
127134 <textarea class="input textarea" name="features" rows="7" required placeholder="${ph(SAMPLE.features.slice(0, 5).join("\n"))}">${v(draft, "features")}</textarea></label>
128135 ${editing === undefined ? photos : ""}
129136 </fieldset>
137 <fieldset>
138 <legend>Open house <small class="muted">optional; up to two, on the flyer, the email and every post</small></legend>
139 ${[1, 2].map((n) => `<div class="facts-grid open-grid">
140 ${field(draft, `open${n}_date`, n === 1 ? "Date" : "Second date", 'type="date"', "")}
141 ${field(draft, `open${n}_start`, "Starts", 'type="time"', "")}

open-grid has no CSS rule anywhere: dead class. Add the rule or drop it.

142 ${field(draft, `open${n}_end`, "Ends", 'type="time"', "")}
143 </div>`).join("")}
144 </fieldset>
130145 <fieldset>
131146 <legend>Tone</legend>
132147 <div class="choices">${TONES.map(
src/pages/listing.ts +5 −1
@@ -9,6 +9,7 @@ import {
99 factItems,
1010 factsLine,
1111 flyerParts,
12 openHouseLine,
1213 money,
1314 TONES,
1415 } from "../generate.ts";
@@ -148,6 +149,7 @@ export function listingPage(input: {
148149 l.assets
149150 .filter((a) => kinds.includes(a.kind))
150151 .reduce((s, a) => s + a.check.findings.length, 0);
152 const whenLine = openHouseLine(l);
151153 const tone = editable
152154 ? `<form class="row tone" method="post" action="/listings/${l.id}/tone">
153155 <label class="sr" for="tone">Tone</label>
@@ -163,6 +165,7 @@ export function listingPage(input: {
163165 <h2>${escape(l.address)}</h2>
164166 <p class="listing-price">${money(l.price)}</p>
165167 <p class="muted">${escape(factsLine(l))}</p>
outdated

listing-open has no CSS rule; style it or reuse a class, and name it in docs/design/areas. Also bind openHouseLine(l) once instead of calling it twice.

168 ${whenLine === null ? "" : `<p class="listing-open">${escape(whenLine)}</p>`}
166169 <p class="muted"><small>${escape(l.agent_name)} · ${escape(l.brokerage)} · updated ${ago(l.updated_at)}</small></p>
167170 </div>
168171 <div class="listing-tools">
@@ -207,7 +210,7 @@ export function listingPage(input: {
207210
208211 /** The flyer: the showpiece, a letter-size page in the agent's colours. */
209212 export function flyer(l: Launched): string {
210 const copy = flyerParts(l.assets.find((a) => a.kind === "flyer")?.text ?? "");
213 const copy = flyerParts(l.assets.find((a) => a.kind === "flyer")?.text ?? "", openHouseLine(l));
211214 const [hero, ...more] = l.photos;
212215 return `<article class="flyer palette-${l.palette}">
213216 ${draftBand(l, "flyer")}
@@ -222,6 +225,7 @@ export function flyer(l: Launched): string {
222225 </div>
223226 <p class="flyer-price brand">${money(l.price)}</p>
224227 </header>
228 ${copy.open === "" ? "" : `<p class="flyer-open brand-fill">${escape(copy.open)}</p>`}
225229 <ul class="flyer-facts brand-fill">
226230 ${factItems(l).map((i) => `<li><b>${escape(i.value)}</b> ${i.label}</li>`).join("\n ")}
227231 </ul>
src/routes.ts +21 −2
@@ -162,6 +162,20 @@ const CHECK: Schema = {
162162 },
163163 required: ["clear", "findings"],
164164 };
165 const OPEN_HOUSES = {
166 type: "array",
167 maxItems: 2,
168 description: "Up to two open houses, in local time as typed: a real date (YYYY-MM-DD), start and end as HH:MM with the end after the start. An empty list clears them.",
169 items: {
170 type: "object",
171 properties: {
172 date: { type: "string", format: "date" },
173 start: { type: "string", pattern: "^([01]\\d|2[0-3]):[0-5]\\d$" },
174 end: { type: "string", pattern: "^([01]\\d|2[0-3]):[0-5]\\d$" },
175 },
176 required: ["date", "start", "end"],
177 },
178 } as const;
165179 const FACTS = {
166180 address: { type: "string", minLength: 5, maxLength: 120 },
167181 beds: { type: "integer", minimum: 0, maximum: 50 },
@@ -183,6 +197,7 @@ const FACTS = {
183197 license: { type: "string", minLength: 2, maxLength: 40 },
184198 agent_phone: { type: "string", maxLength: 30 },
185199 agent_email: { type: "string", format: "email", maxLength: 120 },
200 open_houses: OPEN_HOUSES,
186201 } as const;
187202 const LISTING_FIELDS = {
188203 id: { type: "integer" },
@@ -256,7 +271,7 @@ export const LISTING: Schema = {
256271 const LISTING_BODY: Schema = {
257272 type: "object",
258273 properties: FACTS,
259 required: Object.keys(FACTS).filter((k) => k !== "tone" && k !== "palette"),
274 required: Object.keys(FACTS).filter((k) => k !== "tone" && k !== "palette" && k !== "open_houses"),
260275 };
261276 const LISTING_CHANGE: Schema = {
262277 type: "object",
@@ -361,6 +376,10 @@ const textFields = (form: FormData): Record<string, string> =>
361376 [...form.entries()].flatMap(([k, v]) => (typeof v === "string" ? [[k, v]] : [])),
362377 );
363378
379 /** A new listing started from another keeps its facts, not its open house. */
380 const withoutOpenHouses = (draft: Draft): Draft =>
381 Object.fromEntries(Object.entries(draft).filter(([k]) => !/^open\d_/.test(k)));
382
364383 /** A whole number from the query, or undefined. */
365384 const whole = (value: string | null): number | undefined =>
366385 value !== null && /^\d+$/.test(value) ? Number(value) : undefined;
@@ -387,7 +406,7 @@ export const routes: readonly Route[] = [
387406 : listings.value.find((l) => l.author_id === (c.who as { id: string }).id);
388407 const draft: Draft =
389408 chosen?.ok === true
390 ? draftOf(chosen.value, false)
409 ? withoutOpenHouses(draftOf(chosen.value, false))
391410 : mine === undefined
392411 ? {}
393412 : draftOf(mine, true);
src/sample.ts +7 −1
@@ -28,6 +28,7 @@ export const SAMPLE: ListingInput = {
2828 ],
2929 tone: "warm",
3030 palette: "forest",
31 open_houses: [],
3132 agent_name: "Morgan Reyes",
3233 brokerage: "Harbor & Pine Realty",
3334 license: "OR 201255501",
@@ -35,6 +36,9 @@ export const SAMPLE: ListingInput = {
3536 agent_email: "[email protected]",
3637 };
3738
39 /** The one open house a sample plants, so the meeting sees it on the flyer and in the email. */
40 export const SAMPLE_OPEN_HOUSE = { date: "2027-03-07", start: "13:00", end: "15:00" };
41
3842 export const SAMPLE_PHOTOS = ["sample-front.svg", "sample-garden.svg"];
3943 export const SAMPLE_HEADSHOT = "sample-agent.svg";
4044
@@ -58,6 +62,7 @@ export const SAMPLE_CONDO: ListingInput = {
5862 ],
5963 tone: "bold",
6064 palette: "navy",
65 open_houses: [],
6166 agent_name: "Jordan Whitfield",
6267 brokerage: "Cascade Row Properties",
6368 license: "WA 700123456",
@@ -88,6 +93,7 @@ export const SAMPLE_HOUSE: ListingInput = {
8893 ],
8994 tone: "classic",
9095 palette: "burgundy",
96 open_houses: [],
9197 agent_name: "Casey Lindqvist",
9298 brokerage: "Gem State Homes",
9399 license: "ID SP98765432",
@@ -104,7 +110,7 @@ interface Sample {
104110 }
105111
106112 const SAMPLES: readonly Sample[] = [
107 { name: "sample", listing: SAMPLE, photos: SAMPLE_PHOTOS, headshot: SAMPLE_HEADSHOT },
113 { name: "sample", listing: { ...SAMPLE, open_houses: [SAMPLE_OPEN_HOUSE] }, photos: SAMPLE_PHOTOS, headshot: SAMPLE_HEADSHOT },
108114 {
109115 name: "sample-condo",
110116 listing: SAMPLE_CONDO,
src/tty.ts +23 −6
@@ -29,12 +29,13 @@ import {
2929 listListings,
3030 listNotes,
3131 retoneListing,
32 updateFacts,
3233 useSuggestion,
3334 } from "./actions.ts";
3435 import { ssh, title, where } from "./env.ts";
3536 import { approvalLabel } from "./listing.ts";
3637 import { emailParts } from "./pages/email.ts";
37 import { factItems, money, TONES, type Kind } from "./generate.ts";
38 import { factItems, money, openHouseLine, TONES, type Kind, type OpenHouse } from "./generate.ts";
3839 import type { Who } from "./who.ts";
3940
4041 const esc = "\x1b[";
@@ -58,6 +59,15 @@ const scrub = <T>(value: T): T => {
5859 return value;
5960 };
6061
62 /** The open houses in the one-line box: "2027-03-09 13:00-15:00; 2027-03-10 11:00-13:00"; empty clears them. */
63 export const openToLine = (rows: readonly OpenHouse[]): string =>
64 rows.map((o) => `${o.date} ${o.start}-${o.end}`).join("; ");
65 export const openFromLine = (line: string): { date: string; start: string; end: string }[] =>
66 line.split(";").map((r) => r.trim()).filter((r) => r !== "").map((r) => {
67 const m = /^(\S+)\s+(\S+?)-(\S+)$/.exec(r);
68 return { date: m?.[1] ?? r, start: m?.[2] ?? "", end: m?.[3] ?? "" };
69 });
70
6171 /** In the one-line edit box, `\n` typed as two characters is a line break. */
6272 export const toLine = (text: string): string => text.replace(/\n/g, "\\n");
6373 export const fromLine = (line: string): string => line.replace(/\\n/g, "\n");
@@ -69,7 +79,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
6979 let previewing = false;
7080 let cursor = 0;
7181 /** What the one-line input is for: a new note, or an asset's new words. */
72 let typing: { for: "note" } | { for: "asset"; kind: Kind } | undefined;
82 let typing: { for: "note" } | { for: "asset"; kind: Kind } | { for: "open" } | undefined;
7383 let typed = "";
7484 let said = "";
7585
@@ -105,7 +115,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
105115 ];
106116 }
107117 const signed = l.assets.filter((a) => a.approval !== null).length;
108 const head = [bold(`${l.address} · ${money(l.price)}`), dim(`${l.tone} tone · ${approvalLabel(signed, l.assets.length)}`), ""];
118 const whenLine = openHouseLine(l);
119 const head = [bold(`${l.address} · ${money(l.price)}`), dim(`${l.tone} tone · ${approvalLabel(signed, l.assets.length)}`), ...(whenLine === null ? [] : [whenLine]), ""];
109120 // Scroll so the asset under the cursor starts the page.
110121 const body = l.assets.slice(cursor).flatMap((a, i) => [
111122 `${i === 0 ? "▸" : " "} ${i === 0 ? bold(a.label) : a.label} ${a.check.clear ? green("Fair Housing: clear") : amber(`Fair Housing: ${a.check.findings.length} to review`)}${a.edited ? dim(" edited") : ""}${a.approval === null ? "" : green(` approved by ${a.approval.by_name}`)}`,
@@ -129,7 +140,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
129140 if (previewing) help = "esc back to the assets · q quit";
130141 else help =
131142 found?.ok === true && canEditListing(me, found.value)
132 ? "j/k asset · e edit · a approve/withdraw · u use suggested wording · t next tone · p preview email · esc back · q quit"
143 ? "j/k asset · e edit · o open house · a approve/withdraw · u use suggested wording · t next tone · p preview email · esc back · q quit"
133144 : "j/k asset · p preview email · esc back · q quit";
134145 } else if (view === "listings") {
135146 const rows = listings();
@@ -161,7 +172,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
161172 }
162173 lines.push("");
163174 if (typing !== undefined) {
164 const label = typing.for === "note" ? "New note" : `Edit (\\n breaks a line, enter saves, esc drops)`;
175 const label = typing.for === "note" ? "New note" : 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)`;
165176 // The tail of what is typed, so the cursor stays on the screen.
166177 const room = Math.max(20, size.columns - 4);
167178 lines.push(dim(label), `${typed.length > room ? `…${typed.slice(-room + 1)}` : typed}${esc}7m ${esc}0m`);
@@ -177,6 +188,9 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
177188 const added = addNote(db, me, { body: typed });
178189 if (!added.ok) said = added.message;
179190 cursor = 0;
191 } else if (typing.for === "open") {
192 const done = updateFacts(db, me, { id: reading!, fields: { open_houses: openFromLine(typed) } });
193 said = done.ok ? "Open house saved. Every asset is written again: read it through." : done.message;
180194 } else {
181195 const kind = typing.kind;
182196 const edited = editAsset(db, me, { id: reading!, kind, text: fromLine(typed) });
@@ -222,7 +236,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
222236 if (previewing) return undefined;
223237 if (down) cursor = Math.min(cursor + 1, Math.max(0, assets.length - 1));
224238 if (up) cursor = Math.max(cursor - 1, 0);
225 if (found?.ok === true && (key === "e" || key === "t" || key === "a" || key === "u")) {
239 if (found?.ok === true && (key === "e" || key === "t" || key === "a" || key === "u" || key === "o")) {
226240 if (!canEditListing(me, found.value))
227241 said = me.role === "anon" ? "Sign in to edit this listing." : "Only the listing's agent or an admin can edit it.";
228242 else if (key === "u") {
@@ -243,6 +257,9 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
243257 ? withdrawing ? "Approval withdrawn." : green("Approved.")
244258 : done.message;
245259 }
260 } else if (key === "o") {
261 typing = { for: "open" };
262 typed = openToLine(found.value.open_houses);
246263 } else if (key === "e") {
247264 const asset = assets[cursor];
248265 if (asset !== undefined) {
src/walkthrough.test.ts +38 −1
@@ -172,7 +172,44 @@ test("every step of the section works, with the labels it names", async () => {
172172 expect(afterDrop).toContain("0 of 6 approved");
173173 expect(await (await get(`${mine}/flyer`)).text()).toContain("$649,000");
174174
175 // 7. Print the flyer.
175 // 7. Add an open house: Edit facts, the band on the flyer, the line in the email, approvals cleared.
176 expect(section).toContain("Add an open house");
177 expect(section).toContain("Open Sunday, March 7, 1–3 pm");
178 const sampleFlyer = await (await get(`/listings/${sample}/flyer`)).text();
179 expect(sampleFlyer).toContain('<p class="flyer-open brand-fill">Come by the open house: Sunday, March 7, 1–3 pm.</p>');
180 const editForm = await (await get(`${mine}/facts`)).text();
181 for (const name of ["open1_date", "open1_start", "open1_end", "open2_date"])
182 expect(editForm).toContain(`name="${name}"`);
183 expect(await (await get(`${mine}/flyer`)).text()).not.toContain("flyer-open");
184 const approveOpen = new FormData();
185 approveOpen.set("approved", "1");
186 const beforeOpen = await (await get(mine)).text();
187 approveOpen.set("seen", /name="seen" value="([^"]+)"/.exec(beforeOpen)![1]!.replaceAll("&amp;", "&"));
188 approveOpen.set("text", textareaOf(beforeOpen));
189 await post(`${mine}/assets/mls/approve`, approveOpen);
190 expect(await (await get(mine)).text()).toContain("1 of 6 approved");
191 const hold = new FormData();
192 for (const [k, v] of drop.entries()) hold.set(k, v);
193 hold.set("tone", "bold");
194 hold.set("open1_date", "2027-03-07");
195 hold.set("open1_start", "13:00");
196 hold.set("open1_end", "15:00");
197 expect((await post(`${mine}/facts`, hold)).status).toBe(303);
198 const afterOpen = await (await get(mine)).text();
199 expect(afterOpen).toContain("0 of 6 approved");
200 expect(await (await get(`${mine}/flyer`)).text()).toContain("OPEN HOUSE: Sunday, March 7, 1–3 pm.");
201 expect(await (await get(`${mine}/email`)).text()).toContain("OPEN HOUSE: Sunday, March 7, 1–3 pm.");
202 const bad = new FormData();
203 for (const [k, v] of hold.entries()) bad.set(k, v);
204 bad.set("open1_end", "12:00");
205 expect((await post(`${mine}/facts`, bad)).headers.get("location")).toContain("/facts");
206 const clear = new FormData();
207 for (const [k, v] of drop.entries()) clear.set(k, v);
208 for (const k of ["open1_date", "open1_start", "open1_end"]) clear.set(k, "");
209 expect((await post(`${mine}/facts`, clear)).status).toBe(303);
210 expect(await (await get(`${mine}/flyer`)).text()).not.toContain("flyer-open");
211
212 // 8. Print the flyer.
176213 expect(afterTone).toMatch(control("Print flyer"));
177214 const flyer = await get(`${mine}/flyer`);
178215 expect(flyer.status).toBe(200);
static/app.css +21 −1
@@ -499,6 +499,14 @@ h3 {
499499 font-size: var(--text-xl);
500500 font-weight: 600;
501501 }
502 .listing-open {
503 margin: var(--space-1) 0 0;
504 font-weight: 600;
505 }
506 .open-grid {
507 grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
508 margin-bottom: var(--space-2);
509 }
502510 .listing-tools {
503511 display: flex;
504512 flex-wrap: wrap;
@@ -668,6 +676,12 @@ h3 {
668676 font-weight: 600;
669677 line-height: 1;
670678 }
679 .flyer-open {
680 margin: 0;
681 padding: var(--space-2) var(--space-5);
682 font-weight: 700;
683 text-align: center;
684 }
671685 .flyer-facts {
672686 list-style: none;
673687 margin: 0;
@@ -745,8 +759,14 @@ h3 {
745759 padding-left: var(--space-4);
746760 padding-right: var(--space-4);
747761 }
762 .flyer-facts,
763 .flyer-open {
764 padding-left: var(--space-4);
765 padding-right: var(--space-4);
766 }
748767 .flyer-facts {
749 padding: var(--space-3) var(--space-4);
768 padding-top: var(--space-3);
769 padding-bottom: var(--space-3);
750770 }
751771 .flyer-agent {
752772 margin: 0 var(--space-4);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs