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

Hand the seller a one-page launch report: every asset, who approved it and when, and the property page link, ready to print #60

Merged opened this

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

Merged

mason/listing-launch:mason/58-hand-the-seller-a-one-page-launch-report → main 2 commits fork 2 threads · 2 unresolved

  • Merged. mason/58-hand-the-seller-a-one-page-launch-report was merged into main as 2c746a1.

Review

Sign in to review this change.
11 files changed +270 −23
README.md +5
@@ -90,6 +90,11 @@ to make a listing of your own.
9090 `FORGE_APP_URL`, never whatever address the visitor typed; the terminal and the
9191 launch kit print the same URL. The first sample's MLS text is approved so
9292 the preview shows a page.
93 13. **Print the seller report.** Press **Seller report** beside the flyer and
94 the kit: one page with the address, price, status, photo, the property page
95 link and a row per asset, each with "Approved by … on …" and its words, or
96 "Draft — not approved yet" with no words. Print it at the end of the meeting. In the terminal, `R` on an open
97 listing shows the same report, approved words only.
9398
9499 `src/walkthrough.test.ts` walks these steps on every `bun test`, so this
95100 list cannot drift from the app.
src/hostile.test.ts +1 −1
@@ -90,7 +90,7 @@ const expectInert = (html: string) => {
9090 expect(html).not.toMatch(/<textarea[^>]*>(?:(?!<\/textarea>)[^])*<\/textarea>[^]*?<script>alert/);
9191 };
9292
93 for (const path of ["/", "/listings/:id", "/listings/:id/flyer", "/listings/:id/email", "/homes/:id"]) {
93 for (const path of ["/", "/listings/:id", "/listings/:id/flyer", "/listings/:id/report", "/listings/:id/email", "/homes/:id"]) {
9494 test(`${path} escapes every typed word`, async () => {
9595 const r = await call(path.replace(":id", String(id)));
9696 expect(r.status).toBe(200);
src/kit.ts +3 −15
@@ -7,29 +7,17 @@
77
88 import { factItems, money, openHouseLine, statusLine } from "./generate.ts";
99 import { publicOrigin } from "./env.ts";
10 import { approvedLine, cleanText, oneLine } from "./report.ts";
1011 import { propertyLine, type Asset, type Launched } from "./listing.ts";
1112
12 // Control characters (keeping newline where a block may have lines) and the bidirectional overrides.
13 const CONTROLS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f‎‏‪-‮⁦-⁩]/g;
14
15 /** One line of plain text: no control characters, runs of space made one. */
16 const oneLine = (v: string): string => v.replace(CONTROLS, " ").replace(/\s+/g, " ").trim();
17
1813 /** Lines of plain text, indented so no asset can fake a heading of the file. */
1914 const block = (v: string): string =>
20 v
21 .replace(/\r\n?/g, "\n")
22 .replace(/\t/g, " ")
23 .replace(CONTROLS, " ")
24 .trim()
15 cleanText(v)
2516 .split("\n")
2617 .map((l) => ` ${l.trimEnd()}`.trimEnd())
2718 .join("\n");
2819
29 const stamp = (a: Asset): string =>
30 a.approval === null
31 ? "DRAFT — not approved"
32 : `Approved by ${oneLine(a.approval.by_name)} on ${oneLine(a.approval.at).slice(0, 10)}`;
20 const stamp = (a: Asset): string => approvedLine(a.approval) ?? "DRAFT — not approved";
3321
3422 const asset = (a: Asset): string =>
3523 [
src/pages/listing.ts +1
@@ -243,6 +243,7 @@ export function listingPage(input: {
243243 ${editable ? statusForm(l) : ""}
244244 ${editable ? `<a class="button" href="/listings/${l.id}/facts">Edit facts</a>` : ""}
245245 <a class="button" href="/listings/${l.id}/flyer">Print flyer</a>
246 <a class="button button-quiet" href="/listings/${l.id}/report">Seller report</a>
246247 <a class="button button-quiet" href="/listings/${l.id}/kit.txt" download>Download launch kit</a>
247248 <a class="button button-quiet" href="/?from=${l.id}">Start a new one from this</a>
248249 </div>
src/pages/report.ts added +54
@@ -0,0 +1,54 @@
1 /**
2 * The seller report at `/listings/:id/report`: one printable page handing the
3 * seller what will go out — the listing's facts, the property page link, and a
4 * row per asset with its kind and either its approved words or a Draft mark.
5 * Draft words are never printed; the rule is the property page's and the kit's.
6 */
7
8 import { money, statusLine } from "../generate.ts";
9 import { escape } from "../http.ts";
10 import { propertyPath, type Launched } from "../listing.ts";
11 import { DRAFT_MARK, reportRows, type ReportRow } from "../report.ts";
12 import type { Who } from "../who.ts";
13 import { layout } from "./layout.ts";
14 import { photoUrl } from "./listing.ts";
15
outdated

The approved-or-draft rule and the "Approved by … on …" wording are copied from the kit. Put them in one shared module that the page, the kit and the terminal all use.

16 const row = (r: ReportRow): string =>
17 `<li class="report-row${r.approved === null ? " report-draft" : ""}">
18 <h3>${escape(r.label)}</h3>
19 ${
20 r.approved === null || r.words === null
21 ? `<p class="report-mark"><b>${DRAFT_MARK}</b></p>`
22 : `<p class="report-mark">${escape(r.approved)}</p>
23 <p class="report-words">${escape(r.words).replace(/\n/g, "<br>")}</p>`
24 }
25 </li>`;
26
27 export function reportPage(input: { who: Who; listing: Launched; origin?: string }): string {
28 const { who, listing: l } = input;
29 const hero = l.photos[0];
30 const link = propertyPath(l, input.origin);
31 const rows = reportRows(l);
32 const signed = rows.filter((r) => r.approved !== null).length;
33 return layout(
34 `<div class="print-bar row">
35 <a href="/listings/${l.id}">← Back to the listing</a>
36 <button type="button" class="button" data-print hidden>Print</button>
37 </div>
38 <article class="report palette-${escape(l.palette)}">
39 <header class="report-head">
40 <p class="muted">Launch report · ${escape(l.agent_name)}, ${escape(l.brokerage)}</p>
41 <h2>${escape(l.address)}</h2>
42 <p class="report-price brand">${money(l.price)} · ${escape(l.status === "active" ? "Active" : statusLine(l))}</p>
43 ${hero === undefined ? "" : `<img class="report-hero" src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="1200" height="800">`}
44 <p>Property page: <a href="${escape(propertyPath(l))}">${escape(link)}</a></p>
45 <p class="muted">${signed} of ${rows.length} assets approved by a person.</p>
46 </header>
47 <ol class="report-rows">
48 ${rows.map(row).join("\n ")}
49 </ol>
50 </article>`,
51 who,
52 { path: `/listings/${l.id}/report`, heading: `Launch report: ${l.address}`, kind: "print" },
53 );
54 }
src/report.test.ts added +54
@@ -0,0 +1,54 @@
1 /** The seller report: every asset with who approved it, drafts marked and never worded. */
2 import { afterAll, expect, test } from "bun:test";
3 import { mkdtempSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { serve } from "./server.ts";
7 import { sign } from "./who.ts";
8
9 const KEY = "a-key-only-the-forge-holds";
10 const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "report-")), identityKey: KEY });
11 const base = `http://${server.hostname}:${server.port}`;
12 afterAll(() => server.stop(true));
13
14 const as = (id: string) => {
15 const identity = `${id}|${id}|user|${Date.now() + 600_000}`;
16 return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
17 };
18 const call = (path: string, who?: string, init: RequestInit = {}) =>
19 fetch(base + path, { redirect: "manual", ...init, headers: who === undefined ? {} : as(who) });
20
21 const rows = (html: string) => [...html.matchAll(/<li class="report-row[^"]*">/g)].length;
22
23 test("the Alder Lane sample reports six approved rows by its sample agent", async () => {
24 const ids = [...(await (await call("/", "pat")).text()).matchAll(/href="\/listings\/(\d+)"/g)].map((m) => m[1]);
25 let id = "";
26 for (const i of new Set(ids)) if ((await (await call(`/listings/${i}/report`)).text()).includes("412 Alder Lane")) id = i;
27 const r = await call(`/listings/${id}/report`);
28 expect(r.status).toBe(200);
29 const html = await r.text();
30 expect(rows(html)).toBe(6);
31 expect(html.match(/Approved by [^<]*\(sample\) on \d{4}-\d{2}-\d{2}/g)?.length).toBe(6);
32 expect(html).not.toContain("Draft — not approved yet");
33 expect(html).toContain(`/homes/${id}`);
34 expect(html).toContain("412 Alder Lane");
35 const listing = await (await call(`/listings/${id}`)).text();
36 expect(listing).toContain(`/listings/${id}/report`);
37 });
38
39 test("a fresh listing's rows are marked Draft and carry no words", async () => {
40 const form = new FormData();
41 for (const [k, v] of Object.entries({
42 address: "9 Report Rd, Portland, OR 97201", beds: 3, baths: 2, sqft: 1800, year: 1999, price: 500000,
43 tone: "classic", palette: "teal", agent_name: "Pat Agent", brokerage: "Harbor Realty", license: "OR-1",
44 agent_phone: "+1 (503) 555-0100", agent_email: "[email protected]", lot: "0.2 acre", features: "Sunny kitchen\nQuiet street\nNew roof\nBig deck\nOak floors",
45 }))
46 form.set(k, String(v));
47 const made = await call("/listings", "pat", { method: "POST", body: form });
48 const id = /\/listings\/(\d+)/.exec(made.headers.get("location")!)![1];
49 const html = await (await call(`/listings/${id}/report`, "pat")).text();
50 expect(rows(html)).toBeGreaterThanOrEqual(6);
51 expect(html.match(/Draft — not approved yet/g)?.length).toBe(rows(html));
52 expect(html).not.toContain("Approved by");
53 expect(html).not.toContain("Sunny kitchen");
54 });
src/report.ts added +60
@@ -0,0 +1,60 @@
1 /**
2 * The seller report as data and as text: what will go out for a listing, each
3 * asset with who approved it and when, or marked Draft with no words. The page
4 * (`pages/report.ts`), the launch kit and the terminal all take their
5 * approved-or-draft rule and their "Approved by … on …" wording from here.
6 * Pure: no database, no permission; whoever may read the listing may read this.
7 */
8
9 import { money, statusLine } from "./generate.ts";
10 import { publicOrigin } from "./env.ts";
11 import { propertyPath, type Approval, type Asset, type Launched } from "./listing.ts";
12
13 // Control characters (keeping newline where a block may have lines) and the bidirectional overrides.
14 const CONTROLS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f‎‏‪-‮⁦-⁩]/g;
15
16 /** One line of plain text: no control characters, runs of space made one. */
17 export const oneLine = (v: string): string => v.replace(CONTROLS, " ").replace(/\s+/g, " ").trim();
18
19 /** Plain text with its newlines kept and its control characters removed. */
20 export const cleanText = (v: string): string => v.replace(/\r\n?/g, "\n").replace(/\t/g, " ").replace(CONTROLS, " ").trim();
21
22 /** "Approved by Pat on 2026-11-02", or null while the words are a draft. */
23 export const approvedLine = (approval: Approval | null): string | null =>
24 approval === null ? null : `Approved by ${oneLine(approval.by_name)} on ${oneLine(approval.at).slice(0, 10)}`;
25
26 export const DRAFT_MARK = "Draft — not approved yet";
27
28 export interface ReportRow {
29 readonly label: string;
30 /** The approval line, or null for a draft. */
31 readonly approved: string | null;
32 /** The words, only once approved. */
33 readonly words: string | null;
34 }
35
36 export const reportRows = (l: Launched): ReportRow[] =>
37 l.assets.map((a: Asset) => {
38 const approved = approvedLine(a.approval);
39 return { label: a.label, approved, words: approved === null ? null : a.text };
40 });
41
42 /** The report as plain text, for the terminal. */
43 export function reportText(l: Launched, origin = publicOrigin): string {
44 const rows = reportRows(l);
45 const signed = rows.filter((r) => r.approved !== null).length;
46 return `${[
47 `SELLER REPORT — ${oneLine(l.address)}`,
48 `${money(l.price)} · ${oneLine(l.status === "active" ? "Active" : statusLine(l))}`,
49 `Agent: ${oneLine(l.agent_name)} · ${oneLine(l.brokerage)}`,
50 `Property page: ${propertyPath(l, origin)}`,
51 `${signed} of ${rows.length} assets approved by a person.`,
52 "",
53 ...rows.flatMap((r) => [
54 `== ${r.label} ==`,
55 r.approved ?? DRAFT_MARK,
56 ...(r.words === null ? [] : ["", cleanText(r.words).split("\n").map((x) => ` ${x.trimEnd()}`.trimEnd()).join("\n")]),
57 "",
58 ]),
59 ].join("\n").trimEnd()}\n`;
60 }
src/routes.ts +9
@@ -71,6 +71,7 @@ import { emailPage } from "./pages/email.ts";
7171 import { publicOrigin } from "./env.ts";
7272 import { propertyPage } from "./pages/property.ts";
7373 import { isPost, lengthLine } from "./postlength.ts";
74 import { reportPage } from "./pages/report.ts";
7475 import { approval, asideOf, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts";
7576 import { notesPage } from "./pages/notes.ts";
7677 import { pathOf, PHOTOS_MAX, PHOTOS_TOTAL, typeOf } from "./photos.ts";
@@ -551,6 +552,14 @@ export const routes: readonly Route[] = [
551552 flyerPage({ who: c.who, listing, origin: publicOrigin }),
552553 ),
553554 },
555 {

This is a new door with no terminal counterpart in src/tty.ts. Add one, and add a test for it.

556 method: "GET",
557 path: "/listings/:id/report",
558 handle: (c) =>
559 page(c.who, getListing(c.db, c.who, { id: Number(c.params.id) }), (listing) =>
560 reportPage({ who: c.who, listing, origin: publicOrigin }),
561 ),
562 },
554563 {
555564 method: "GET",
556565 path: "/listings/:id/kit.txt",
src/tty.test.ts +32
@@ -271,3 +271,35 @@ test("s then `coming soon` sets the status, adds the teaser post and email, and
271271 expect(l.value.assets.filter((a) => a.kind === "social_status" || a.kind === "email_status")).toHaveLength(2);
272272 expect(plain(tty.screen())).toContain("Status saved: coming soon");
273273 });
274
275 test("R shows the seller report: a draft is marked and never worded, an approved asset carries its words and signer", () => {
276 const { db, id } = fresh();
277 const tty = session(db, me);
278 tty.press("\r");
279 tty.press("R");
280 let seen = plain(tty.screen());
281 expect(seen).toContain("SELLER REPORT");
282 expect(seen).toContain("Draft — not approved yet");
283 expect(seen).not.toContain("Approved by");
284 const listing = getListing(db, me, { id });
285 const words = listing.ok ? listing.value.assets[0]!.text.split("\n")[0]!.trim().slice(0, 40) : "";
286 expect(words.length).toBeGreaterThan(10);
287 expect(seen).not.toContain(words);
288 expect(seen).toContain("esc back to the assets");
289 tty.press("\x1b");
290 tty.press("a"); // approve the MLS description
291 tty.press("R");
292 seen = plain(tty.screen());
293 expect(seen).toContain("Approved by me on");
294 expect(seen).toContain(words);
295 expect(seen).toContain("1 of 6 assets approved");
296 expect(seen).toContain("Draft — not approved yet");
297 expect(seen).toContain(`/homes/${id}`);
298 });
299
300 test("the terminal's help line names the seller report", () => {
301 const { db } = fresh();
302 const tty = session(db, me);
303 tty.press("\r");
304 expect(plain(tty.screen())).toContain("R seller report");
305 });
src/tty.ts +16 −7
@@ -9,7 +9,8 @@
99 * the next tone, a approves the asset or withdraws it, v puts back its
1010 * earlier words, esc goes back; x
1111 * removes a listing; p previews the email as the client will read it; k shows
12 * the launch kit, the file the page offers to download.
12 * the launch kit, the file the page offers to download; R shows the seller
13 * report, approved words only, as the page prints it.
1314 * - notes: a adds, d removes.
1415 *
1516 * j/k move, q leaves. Launching, with its photos, is the page's. Plain ANSI,
@@ -40,6 +41,7 @@ import {
4041 } from "./actions.ts";
4142 import { publicOrigin, ssh, title, where } from "./env.ts";
4243 import { kit } from "./kit.ts";
44 import { reportText } from "./report.ts";
4345 import { approvalLabel, propertyLine } from "./listing.ts";
4446 import { isPost, lengthLine, meantForX, overXNote } from "./postlength.ts";
4547 import { emailParts } from "./pages/email.ts";
@@ -95,6 +97,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
9597 let previewing = false;
9698 /** The launch kit, as the file would read. */
9799 let kitting = false;
100 /** The seller report, as the page prints it. */
101 let reporting = false;
98102 /** The asset whose earlier versions are listed, 1 to 5 puts one back. */
99103 let versioning: Kind | undefined;
100104 let cursor = 0;
@@ -126,6 +130,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
126130 if (!found.ok) return [found.message];
127131 const l = found.value;
128132 if (kitting) return kit(l).split("\n");
133 if (reporting) return reportText(l).split("\n");
129134 if (versioning !== undefined) {
130135 const earlier = listVersions(db, me, { id: l.id });
131136 const list = (earlier.ok ? earlier.value[versioning] : undefined) ?? [];
@@ -172,12 +177,12 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
172177 if (reading !== undefined) {
173178 lines.push(...readLines());
174179 const found = open();
175 if (previewing || kitting) help = "esc back to the assets · q quit";
180 if (previewing || kitting || reporting) help = "esc back to the assets · q quit";
176181 else if (versioning !== undefined) help = "1-5 put that version back · esc back to the assets · q quit";
177182 else help =
178183 found?.ok === true && canEditListing(me, found.value)
179 ? "j/↓↑ asset · e edit · o open house · r price · s status · a approve/withdraw · u use suggested wording · v list earlier versions and put one back · t next tone · p preview email · k launch kit · esc back · q quit"
180 : "j/↓↑ asset · p preview email · k launch kit · esc back · q quit";
184 ? "j/↓↑ asset · e edit · o open house · r price · s status · a approve/withdraw · u use suggested wording · v list earlier versions and put one back · t next tone · p preview email · k launch kit · R seller report · esc back · q quit"
185 : "j/↓↑ asset · p preview email · k launch kit · R seller report · esc back · q quit";
181186 } else if (view === "listings") {
182187 const rows = listings();
183188 lines.push(dim(`Showing ${found.status.replace(/[-_]/g, " ")}${found.q === "" ? "" : ` matching "${found.q}"`}`));
@@ -279,7 +284,11 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
279284 previewing = true;
280285 return undefined;
281286 }
282 if (key === "k" && !kitting && !previewing && found?.ok === true) {
287 if (key === "R" && !kitting && !reporting && !previewing && found?.ok === true) {
288 reporting = true;
289 return undefined;
290 }
291 if (key === "k" && !kitting && !reporting && !previewing && found?.ok === true) {
283292 kitting = true;
284293 return undefined;
285294 }
@@ -299,14 +308,14 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
299308 return undefined;
300309 }
301310 if (key === "\x1b") {
302 if (previewing || kitting) previewing = kitting = false;
311 if (previewing || kitting || reporting) previewing = kitting = reporting = false;
303312 else {
304313 reading = undefined;
305314 cursor = 0;
306315 }
307316 return undefined;
308317 }
309 if (previewing || kitting) return undefined;
318 if (previewing || kitting || reporting) return undefined;
310319 if (down) cursor = Math.min(cursor + 1, Math.max(0, assets.length - 1));
311320 if (up) cursor = Math.max(cursor - 1, 0);
312321 if (found?.ok === true && (key === "e" || key === "t" || key === "a" || key === "u" || key === "o" || key === "s" || key === "r" || key === "v")) {
static/app.css +35
@@ -1079,3 +1079,38 @@ main:focus {
10791079 border-radius: 50%;
10801080 object-fit: cover;
10811081 }
1082
1083 /* The seller report: rows that do not split across pages, no controls on paper. */
1084 .report {
1085 max-width: 48rem;
1086 margin: 0 auto;
1087 }
1088 .report-hero {
1089 display: block;
1090 width: 100%;
1091 height: auto;
1092 aspect-ratio: 3 / 2;
1093 object-fit: cover;
1094 }
1095 .report-rows {
1096 list-style: none;
1097 margin: var(--space-4) 0 0;
1098 padding: 0;
1099 }
1100 .report-row {
1101 padding: var(--space-3) 0;
1102 border-top: 1px solid currentColor;
1103 break-inside: avoid;
1104 }
1105 .report-words {
1106 white-space: normal;
1107 }
1108 @media print {
1109 .report {
1110 max-width: none;
1111 margin: 0.6in;
1112 }
1113 .report-hero {
1114 max-height: 3in;
1115 }
1116 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs