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

The weekly seller update says how many showings the home had this week and what buyers' agents said #110

opened this

Check · Review · Land · Live — Its check is red. Next: builder, its check is red.

Ready to land

mason/listing-launch:mason/103-the-weekly-seller-update-says-how-many-s → main 1 commit fork

  • Ready to merge — no conflicts with main. 1 commit will land on main.

    Check faileddeploy fedd1ac

    Website spec: 3 findings, 4 the edge owns (report only)
    • / no <link rel="canonical"> — canonical
    • / missing og:image — open-graph
    • / a 13144-byte body was sent without br or gzip though Accept-Encoding: br, gzip — https://specification.website/ (the edge owns this)
    • / http://127.0.0.1:46467/tokens.css?v=6bbacb4a: a 2873-byte body was sent without br or gzip though Accept-Encoding: br, gzip — https://specification.website/ (the edge owns this)
    • / http://127.0.0.1:46467/static/app.css?v=322f800e: a 28365-byte body was sent without br or gzip though Accept-Encoding: br, gzip — https://specification.website/ (the edge owns this)
    • / no Cross-Origin-Opener-Policy — coop (the edge owns this)
    • / no ETag on the HTML — caching
    You need write access to merge.

Review

Sign in to review this change.
4 files changed +130 −9
README.md +5 −1
@@ -154,7 +154,11 @@ to make a listing of your own.
154154 weekly email to the seller, written in the agent's name from what the app
155155 knows — the address and status, every asset approved in the last seven
156156 days (who and when), what the launch week has in the next seven days, the
157 open house dates and the property page link. Edit it, read the Fair Housing
157 open house dates and the property page link, and a short showings
158 paragraph: how many showings this week, how many buyers were Interested or
159 Offer likely, and what their agents said, never naming them ("No showings this
160 week" when you logged none). Logging or removing a showing rewrites an unedited
161 draft and clears its approval. Edit it, read the Fair Housing
158162 check, then press **Approve** like any asset; until then it wears "Draft — not
159163 approved". It follows approvals while it is an unedited draft, and editing
160164 the facts or the status clears its approval. An assistant cannot approve it.
src/actions.ts +34 −6
@@ -49,7 +49,7 @@ import {
4949 write as writePhoto,
5050 } from "./photos.ts";
5151 import { headshotFrom } from "./agent.ts";
52 import { sellerUpdate } from "./update.ts";
52 import { sellerUpdate, withoutShowings } from "./update.ts";
5353 import { type PriceEntry, type PriceKind } from "./market.ts";
5454 import { dayOf, validShowing, SHOWINGS_MAX, type Showing } from "./showings.ts";
5555 import { validVisitor, VISITORS_MAX, type Visitor } from "./visitors.ts";
@@ -405,7 +405,7 @@ function writeAssets(
405405 }
406406 }
407407 if (only.includes("email_update")) {
408 const body = sellerUpdate(launched(db, listing.id)!, new Date());
408 const body = sellerUpdate(launched(db, listing.id)!, new Date(), undefined, loggedShowings(db, listing.id));
409409 if (by !== undefined) keepWords(db, listing.id, "email_update", body, by.who, by.why);
410410 put.run(listing.id, "email_update", body);
411411 }
@@ -422,13 +422,34 @@ export function redraftUpdate(db: Database, id: number): void {
422422 )
423423 .get(id);
424424 if (update === null) return;
425 const body = sellerUpdate(launched(db, id)!, new Date());
425 const body = sellerUpdate(launched(db, id)!, new Date(), undefined, loggedShowings(db, id));
426426 if (body !== update.body)
427427 db.query(
428428 "UPDATE asset SET body = ?2, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE listing_id = ?1 AND kind = 'email_update'",
429429 ).run(id, body);
430430 }
431431
432 /** The showings the agent logged, for the seller update's words alone. */
433 const loggedShowings = (db: Database, id: number): Showing[] =>
434 db.query<Showing, [number]>("SELECT id, date, agent, interest, feedback FROM showing WHERE listing_id = ?1 ORDER BY date DESC, id DESC").all(id);
435
436 /**
437 * A showing logged or removed changes what the seller is told: an unedited
438 * update is written again and loses its approval; one a person edited keeps its words.
439 */
440 function showingsChanged(db: Database, id: number): void {
441 const update = db
442 .query<{ body: string }, [number]>("SELECT body FROM asset WHERE listing_id = ?1 AND kind = 'email_update' AND edited = 0")
443 .get(id);
444 if (update === null) return;
445 const body = sellerUpdate(launched(db, id)!, new Date(), undefined, loggedShowings(db, id));
446 if (body === update.body) return;
447 db.query(
448 `UPDATE asset SET body = ?2, approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL,
449 updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE listing_id = ?1 AND kind = 'email_update'`,
450 ).run(id, body);
451 }
452
432453 /** A listing launched before the seller update existed gets its draft, once; the ids drafted. */
433454 export function draftMissingUpdates(db: Database): number[] {
434455 const ids = db
@@ -440,7 +461,7 @@ export function draftMissingUpdates(db: Database): number[] {
440461 for (const id of ids)
441462 db.query("INSERT INTO asset (listing_id, kind, body, edited) VALUES (?1, 'email_update', ?2, 0)").run(
442463 id,
443 sellerUpdate(launched(db, id)!, new Date()),
464 sellerUpdate(launched(db, id)!, new Date(), undefined, loggedShowings(db, id)),
444465 );
445466 return ids;
446467 }
@@ -663,11 +684,16 @@ export const getListing = (
663684 db: Database,
664685 who: Who,
665686 input: { id: number },
666 ): Result<Launched> =>
667 listingFor(db, who, "read", input.id, {
687 ): Result<Launched> => {
688 const found = listingFor(db, who, "read", input.id, {
668689 signIn: "Sign in to see this listing.",
669690 no: "You may not see this listing.",
670691 });
692 // The seller update speaks for the showings the agent logged: readers see it without that paragraph.
693 if (!found.ok || canEditListing(who, found.value)) return found;
694 const assets = found.value.assets.map((a) => (a.kind === "email_update" ? { ...a, text: withoutShowings(a.text), check: checkWords(withoutShowings(a.text)) } : a));
695 return ok({ ...found.value, assets });
696 };
671697
672698 /**
673699 * A listing as its property page shows it: whoever may read it, once its MLS
@@ -1437,6 +1463,7 @@ function putShowing(db: Database, who: Who, input: ShowingInput): Result<{ id: n
14371463 "INSERT INTO showing (listing_id, date, agent, interest, feedback) VALUES (?1, ?2, ?3, ?4, ?5) RETURNING id",
14381464 )
14391465 .get(found.value.id, v.date, v.agent, v.interest, v.feedback)!;
1466 showingsChanged(db, found.value.id);
14401467 emit(db, "listing.changed", { id: found.value.id });
14411468 return ok({ id: found.value.id, showing: row.id });
14421469 }
@@ -1452,6 +1479,7 @@ function dropShowing(db: Database, who: Who, input: { id: number; showing: numbe
14521479 found.value.id,
14531480 );
14541481 if (gone.changes === 0) return fail("not_found", "No such showing.");
1482 showingsChanged(db, found.value.id);
14551483 emit(db, "listing.changed", { id: found.value.id });
14561484 return ok({ id: found.value.id });
14571485 }
src/update.test.ts +65 −1
@@ -8,7 +8,7 @@ import { expect, test } from "bun:test";
88 import { mkdtempSync } from "node:fs";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
11 import { approveAsset, editAsset, getListing, launchListing, restoreAsset, setStatus, updateFacts } from "./actions.ts";
11 import { addShowing, approveAsset, editAsset, removeShowing, getListing, launchListing, restoreAsset, setStatus, updateFacts } from "./actions.ts";
1212 import { openDb } from "./db.ts";
1313 import { check } from "./fairhousing.ts";
1414 import { rpc } from "./mcp.ts";
@@ -174,3 +174,67 @@ test("editing or restoring an approved asset drops it from an unedited draft", (
174174 expect(listed()).not.toContain("- MLS description:");
175175
176176 });
177
178 const today = () => new Date().toISOString().slice(0, 10);
179
180 test("the update counts the week's showings and the keen ones, and says each line without the buyer's agent", () => {
181 const { db, id } = launch();
182 expect(update(db, id).text).toContain("No showings this week");
183 const log = (date: string, agent: string, interest: string, feedback: string) =>
184 expect(addShowing(db, me, { id, date, agent, interest, feedback }).ok).toBe(true);
185 log(today(), "Dana Whitcombe", "offer_likely", "Loved the kitchen");
186 log(day(-2), "Priya Natarajan", "interested", "Wants a second look");
187 log(day(-3), "Omar Feld", "not_interested", "Too far from work");
188 log(day(-12), "Old Agent", "interested", "Back in the spring");
189 const text = update(db, id).text;
190 expect(text).toContain("3 showings this week, 2 of them Interested or Offer likely.");
191 expect(text).toContain("- Loved the kitchen");
192 expect(text).toContain("- Too far from work");
193 expect(text).not.toContain("Back in the spring");
194 for (const name of ["Dana", "Whitcombe", "Priya", "Omar", "Old Agent"]) expect(text).not.toContain(name);
195 expect(update(db, id).check.clear).toBe(true);
196 });
197
198 test("a showing older than seven days alone leaves the week empty", () => {
199 const { db, id } = launch();
200 addShowing(db, me, { id, date: day(-8), agent: "A", interest: "interested", feedback: "Old news" });
201 const text = update(db, id).text;
202 expect(text).toContain("No showings this week");
203 expect(text).not.toContain("Old news");
204 });
205
206 test("logging or removing a showing rewrites an unedited update and clears its approval", () => {
207 const { db, id } = launch();
208 expect(approve(db, id, "email_update").ok).toBe(true);
209 expect(update(db, id).approval).not.toBeNull();
210 const made = addShowing(db, me, { id, agent: "A", interest: "maybe", feedback: "Nice yard" });
211 if (!made.ok) throw new Error(made.message);
212 expect(update(db, id).approval).toBeNull();
213 expect(update(db, id).text).toContain("1 showing this week, 0 of them");
214 expect(approve(db, id, "email_update").ok).toBe(true);
215 expect(removeShowing(db, me, { id, showing: made.value.showing }).ok).toBe(true);
216 expect(update(db, id).approval).toBeNull();
217 expect(update(db, id).text).toContain("No showings this week");
218 });
219
220 test("showings stay off every public surface", () => {
221 const { db, id } = launch();
222 addShowing(db, me, { id, agent: "Dana Whitcombe", interest: "interested", feedback: "Secret remark" });
223 const l = read(db, id);
224 for (const a of l.assets.filter((x) => x.kind !== "email_update")) {
225 expect(a.text).not.toContain("Secret remark");
226 expect(a.text).not.toContain("Whitcombe");
227 }
228 });
229
230 test("a reader of the listing gets the update without the showings paragraph, the agent keeps it", () => {
231 const { db, id } = launch();
232 addShowing(db, me, { id, agent: "Dana Whitcombe", interest: "interested", feedback: "Secret remark" });
233 expect(update(db, id).text).toContain("Secret remark");
234 const seen = getListing(db, { role: "anon" }, { id });
235 if (!seen.ok) throw new Error(seen.message);
236 const text = seen.value.assets.find((a) => a.kind === "email_update")!.text;
237 expect(text).not.toContain("Secret remark");
238 expect(text).not.toContain("showing");
239 expect(text).toContain("What went out this week:");
240 });
src/update.ts +26 −1
@@ -14,11 +14,34 @@ import { marketLines } from "./market.ts";
1414 import { propertyPath, type Launched } from "./listing.ts";
1515 import { approvedLine, oneLine } from "./report.ts";
1616 import { addDays, dayName, scheduleRows } from "./schedule.ts";
17 import { INTEREST_LABELS, type Showing } from "./showings.ts";
1718
1819 /** The days a week looks back over and forward to. */
1920 const WEEK = 7;
2021
21 export function sellerUpdate(l: Launched, now: Date, origin = publicOrigin): string {
22 /**
23 * The showings paragraph: the last seven days' count, how many buyers were
24 * Interested or Offer likely, and each feedback line as written. The buyer's
25 * agent is never named; the rows are the agent's alone, only these words go out.
26 */
27 export function showingLines(showings: readonly Showing[], today: string): string[] {
28 const from = addDays(today, 1 - WEEK);
29 const week = showings.filter((s) => s.date >= from && s.date <= today);
30 if (week.length === 0) return ["No showings this week."];
31 const keen = week.filter((s) => s.interest === "interested" || s.interest === "offer_likely").length;
32 const said = week.filter((s) => oneLine(s.feedback) !== "").map((s) => `- ${oneLine(s.feedback)} (${INTEREST_LABELS[s.interest]})`);
33 return [
34 `${week.length} ${week.length === 1 ? "showing" : "showings"} this week, ${keen} of them Interested or Offer likely.`,
35 ...(said.length === 0 ? [] : ["What buyers' agents said:", ...said]),
36 ];
37 }
38
39 const SHOWINGS_PARAGRAPH = /^(?:No showings this week\.|\d+ showings? this week, [^\n]*)\n(?:What buyers' agents said:\n(?:- [^\n]*\n)*)?\n/m;
40
41 /** The update as a reader of the listing may have it: the showings paragraph is the agent's alone. */
42 export const withoutShowings = (text: string): string => text.replace(SHOWINGS_PARAGRAPH, "");
43
44 export function sellerUpdate(l: Launched, now: Date, origin = publicOrigin, showings: readonly Showing[] = []): string {
2245 const today = now.toISOString().slice(0, 10);
2346 const since = new Date(now.getTime() - WEEK * 86_400_000).toISOString();
2447 const sent = l.assets
@@ -41,6 +64,8 @@ export function sellerUpdate(l: Launched, now: Date, origin = publicOrigin): str
4164 "",
4265 ...marketLines(l, today),
4366 "",
67 ...showingLines(showings, today),
68 "",
4469 "What went out this week:",
4570 ...(sent.length === 0 ? ["- Nothing new was approved this week."] : sent),
4671 "",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs