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

See each listing's days on market and its price history on the listing, the seller report and the weekly seller update #90

Merged opened this

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

Merged

mason/listing-launch:mason/87-see-each-listing-s-days-on-market-and-it → main 2 commits fork 3 threads · 3 unresolved

  • Merged. mason/87-see-each-listing-s-days-on-market-and-it was merged into main as f240fb0.

Review

Sign in to review this change.
18 files changed +437 −11
README.md +13
@@ -304,6 +304,19 @@ flag-with-note — and a person approves every word. `/fair-housing` says what
304304 it looks for, class by class, in words you can read to a client; every finding
305305 links to it. It is built from the rules themselves, so it is never out of date.
306306
307 6. **Days on market and price history.** Every listing page and the seller
308 report show "N days on market" and a short table of its prices (the list
309 price, each price change and the sold price, each with its date and the
310 change from the one before); the home list shows "N days" beside each
311 active listing, and the weekly seller update includes both. The terminal shows
312 the same: the day count in the listing list, and the day line and price
313 history at the top of an opened listing. Days run from
314 the day the listing first went active (Coming soon does not count) to
315 today, and stop at Under contract, Sold, Withdrawn or Expired; Back on
316 market resumes the count from the original list date, as MLS does. Dates,
317 not timestamps, so a listing launched today reads "0 days". The history is
318 recorded when the price is changed on Edit facts or the sold price is set.
319
307320 ## For whoever runs it
308321
309322 This is an app: the forge runs it. Production follows the default branch;
docs/design/areas/market.md added +7
@@ -0,0 +1,7 @@
1 # Days on market and price history
2
3 New component classes, built from existing tokens:
4
5 - `market-days` — "N days on market", a muted line under the price on the listing page and the seller report.
6 - `market-days-pill` — the same count as a small `pill` beside an active listing on the home list.
7 - `price-history` — the short table of dates, prices and changes under it.
src/actions.ts +27 −5
@@ -50,7 +50,8 @@ import {
5050 } from "./photos.ts";
5151 import { headshotFrom } from "./agent.ts";
5252 import { sellerUpdate } from "./update.ts";
53 import { validShowing, SHOWINGS_MAX, type Showing } from "./showings.ts";
53 import { type PriceEntry, type PriceKind } from "./market.ts";
54 import { dayOf, validShowing, SHOWINGS_MAX, type Showing } from "./showings.ts";
5455 import { validVisitor, VISITORS_MAX, type Visitor } from "./visitors.ts";
5556 import type { Who } from "./who.ts";
5657
@@ -354,9 +355,20 @@ function launched(db: Database, id: number): Launched | undefined {
354355 "SELECT kind, body, edited, updated_at, approved_by_id, approved_by_name, approved_at, approved_via FROM asset WHERE listing_id = ?1",
355356 )
356357 .all(id);
357 return { ...fromRow(row), assets: assetsOf(assets) };
358 return { ...fromRow(row), assets: assetsOf(assets), history: priceEntries(db, id) };
358359 }
359360
361 /** Every price a listing has had, oldest first. */
362 function priceEntries(db: Database, id: number): PriceEntry[] {
363 return db
364 .query<PriceEntry, [number]>("SELECT date, price, kind FROM price_history WHERE listing_id = ?1 ORDER BY date, id")
365 .all(id);
366 }
367
368 const recordPrice = (db: Database, id: number, price: number, kind: PriceKind): void => {
369 db.query("INSERT INTO price_history (listing_id, date, price, kind) VALUES (?1, ?2, ?3, ?4)").run(id, dayOf(new Date()), price, kind);
370 };
371
360372 /**
361373 * Write assets fresh from the facts, as generated (not edited): every kind by
362374 * default, or only `only`. A kind the listing has no words for now (a status
@@ -492,6 +504,8 @@ export function insertListing(
492504 JSON.stringify(photos),
493505 headshot,
494506 )!;
507 db.query("UPDATE listing SET listed_on = ?1 WHERE id = ?2").run(dayOf(new Date()), row.id);
508 recordPrice(db, row.id, input.price, "list");
495509 writeAssets(db, fromRow(row));
496510 return launched(db, row.id)!;
497511 })();
@@ -505,11 +519,18 @@ export function markStatus(
505519 ): Launched {
506520 return db.transaction(() => {
507521 const before = db.query<{ status: Status }, [number]>("SELECT status FROM listing WHERE id = ?1").get(id)!;
522 const today = dayOf(new Date());
523 // The count starts the first day it is active, stops when it leaves the market and resumes when it returns.
508524 const row = db
509 .query<ListingRow, [string, number | null, number]>(
510 "UPDATE listing SET status = ?1, sold_price = ?2, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?3 RETURNING *",
525 .query<ListingRow, [string, number | null, string, number]>(
526 `UPDATE listing SET status = ?1, sold_price = ?2, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
527 listed_on = CASE WHEN ?1 = 'active' THEN coalesce(listed_on, ?3) ELSE listed_on END,
528 market_ended_on = CASE WHEN ?1 IN ('under_contract', 'sold', 'withdrawn', 'expired') THEN coalesce(market_ended_on, ?3) ELSE NULL END
529 WHERE id = ?4 RETURNING *`,
511530 )
512 .get(stand.status, stand.sold_price, id)!;
531 .get(stand.status, stand.sold_price, today, id)!;
532 db.query("DELETE FROM price_history WHERE listing_id = ?1 AND kind = 'sold'").run(id);
533 if (stand.status === "sold" && stand.sold_price !== null) recordPrice(db, id, stand.sold_price, "sold");
513534 // Leaving or re-entering the market changes the open house lines in the words: the assets nobody edited are written again.
514535 // The reminders come and go with the status too: sold or off the market has none.
515536 const remind = remindable(before.status) !== remindable(stand.status);
@@ -1124,6 +1145,7 @@ function changeFacts(
11241145 // A cut remembers the price it came from; a raise to or above it forgets.
11251146 const was = v.price < now.price ? (now.was_price ?? now.price) : now.was_price !== null && v.price < now.was_price ? now.was_price : null;
11261147 db.query("UPDATE listing SET was_price = ?1 WHERE id = ?2").run(was, now.id);
1148 if (v.price !== now.price) recordPrice(db, now.id, v.price, "price");
11271149 touch(db, now.id);
11281150 // The sign-in sheet belongs to the open house: with it gone, so are the names.
11291151 if (v.open_houses.length === 0) db.query("DELETE FROM visitor WHERE listing_id = ?1").run(now.id);
src/comingsoon.test.ts +8 −2
@@ -160,9 +160,15 @@ test("the migration keeps every listing, photo name and approval", () => {
160160 expect(() => db.query("UPDATE listing SET status = 'coming_soon' WHERE id = 1").run()).toThrow();
161161
162162 expect(migrate(db)).toBe(migrations.length);
163 // Every row as it was, plus the photo descriptions column a later migration adds, empty.
163 // Every row as it was, plus the photo descriptions column a later migration adds, empty, and the market dates
164 // the last one backfills: listed the day it was created, off the market (if it is) the day it was last touched.
164165 expect(db.query("SELECT * FROM listing ORDER BY id").all()).toEqual(
165 (before as object[]).map((row) => ({ ...row, photo_alt: "{}" })),
166 (before as { created_at: string; updated_at: string; status: string }[]).map((row) => ({
167 ...row,
168 photo_alt: "{}",
169 listed_on: row.created_at.slice(0, 10),
170 market_ended_on: row.status === "active" ? null : row.updated_at.slice(0, 10),
171 })),
166172 );
167173 expect(db.query("SELECT * FROM asset ORDER BY listing_id, kind").all()).toEqual(assetsBefore);
168174 expect((db.query("PRAGMA foreign_key_check").all() as unknown[]).length).toBe(0);
src/db.ts +22
@@ -236,6 +236,28 @@ export const migrations: string[] = [
236236 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
237237 );
238238 CREATE INDEX IF NOT EXISTS showing_listing ON showing (listing_id, date, id)`,
239 // Time on the market (src/market.ts): the day a listing first went active and the day it left the market, as
240 // dates. A listing already there went active the day it was launched (unless it is Coming soon) and, if it is
241 // off the market, left it the day it was last touched. Its price history: one entry from the price it launched
242 // at (the price before it was lowered, where there is one), one for the price now, one for the sold price.
243 `ALTER TABLE listing ADD COLUMN listed_on TEXT;
244 ALTER TABLE listing ADD COLUMN market_ended_on TEXT;
245 UPDATE listing SET listed_on = substr(created_at, 1, 10) WHERE status != 'coming_soon';
246 UPDATE listing SET market_ended_on = substr(updated_at, 1, 10) WHERE status IN ('under_contract', 'sold', 'withdrawn', 'expired');
247 CREATE TABLE IF NOT EXISTS price_history (
248 id INTEGER PRIMARY KEY,
249 listing_id INTEGER NOT NULL REFERENCES listing (id) ON DELETE CASCADE,
250 date TEXT NOT NULL CHECK (length(date) = 10),
251 price INTEGER NOT NULL,
252 kind TEXT NOT NULL CHECK (kind IN ('list', 'price', 'sold'))
253 );
254 CREATE INDEX IF NOT EXISTS price_history_listing ON price_history (listing_id, date, id);
255 INSERT INTO price_history (listing_id, date, price, kind)
256 SELECT id, substr(created_at, 1, 10), coalesce(was_price, price), 'list' FROM listing;
257 INSERT INTO price_history (listing_id, date, price, kind)
258 SELECT id, substr(updated_at, 1, 10), price, 'price' FROM listing WHERE was_price IS NOT NULL;
259 INSERT INTO price_history (listing_id, date, price, kind)
260 SELECT id, substr(updated_at, 1, 10), sold_price, 'sold' FROM listing WHERE status = 'sold' AND sold_price IS NOT NULL`,
239261 ];
240262
241263 export function openDb(dir = dataDir): Database {
src/listing.ts +9
@@ -19,6 +19,7 @@ import {
1919 type Status,
2020 type Tone,
2121 } from "./generate.ts";
22 import type { PriceEntry } from "./market.ts";
2223
2324 /** The agent's colours, each a token pair in design/tokens.json (`color-brand-<name>`). */
2425 export const PALETTES = ["navy", "forest", "burgundy", "charcoal", "teal"] as const;
@@ -46,6 +47,10 @@ export interface Listing extends ListingInput {
4647 readonly sold_price: number | null;
4748 /** Whole dollars: what the price was before it was lowered; null when it has not been. */
4849 readonly was_price: number | null;
50 /** The day it first went active, as a date; null while it has only been Coming soon. */
51 readonly listed_on: string | null;
52 /** The day it left the market (under contract, sold, withdrawn, expired); null while on it. */
53 readonly market_ended_on: string | null;
4954 readonly id: number;
5055 readonly author_id: string;
5156 readonly author_name: string;
@@ -87,6 +92,8 @@ export type Caller = Door | "api" | "mcp";
8792 /** A listing with its five assets, each checked. */
8893 export interface Launched extends Listing {
8994 readonly assets: readonly Asset[];
95 /** Every price it has had, oldest first (src/market.ts). */
96 readonly history: readonly PriceEntry[];
9097 }
9198
9299 export const agentOf = (l: ListingInput): Agent => ({
@@ -115,6 +122,8 @@ export interface ListingRow {
115122 readonly status: Status;
116123 readonly sold_price: number | null;
117124 readonly was_price: number | null;
125 readonly listed_on: string | null;
126 readonly market_ended_on: string | null;
118127 readonly agent_name: string;
119128 readonly brokerage: string;
120129 readonly license: string;
src/market.test.ts added +171
@@ -0,0 +1,171 @@
1 /**
2 * Days on market and the price history: the count across Coming soon, Back on
3 * market and Sold with a fixed clock; the history after two price changes; the
4 * pages, the report and the seller update carry both, escaped.
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 { getListing, launchListing, listListings, setStatus, updateFacts } from "./actions.ts";
11 import { openDb } from "./db.ts";
12 import { daysLine, daysOnMarket, historyLines, priceHistory } from "./market.ts";
13 import { draftOf, home } from "./pages/home.ts";
14 import { listingPage } from "./pages/listing.ts";
15 import { reportPage } from "./pages/report.ts";
16 import { reportText } from "./report.ts";
17 import { SAMPLE, seed } from "./sample.ts";
18 import { dayOf } from "./showings.ts";
19 import { sellerUpdate } from "./update.ts";
20 import type { Who } from "./who.ts";
21
22 const me: Who = { role: "user", id: "u1", name: "me" };
23
24 test("days count from the day it went active; Coming soon does not count; today is 0", () => {
25 expect(daysOnMarket({ listed_on: null, market_ended_on: null }, "2026-11-03")).toBeNull();
26 expect(daysLine({ listed_on: null, market_ended_on: null }, "2026-11-03")).toBeNull();
27 expect(daysOnMarket({ listed_on: "2026-11-03", market_ended_on: null }, "2026-11-03")).toBe(0);
28 expect(daysLine({ listed_on: "2026-11-02", market_ended_on: null }, "2026-11-03")).toBe("1 day on market");
29 expect(daysLine({ listed_on: "2026-10-04", market_ended_on: null }, "2026-11-03")).toBe("30 days on market");
30 // A clock before the list date never reads negative.
31 expect(daysOnMarket({ listed_on: "2026-11-05", market_ended_on: null }, "2026-11-03")).toBe(0);
32 });
33
34 test("the count stops when it leaves the market", () => {
35 const l = { listed_on: "2026-10-01", market_ended_on: "2026-10-11" };
36 expect(daysOnMarket(l, "2026-11-03")).toBe(10);
37 expect(daysOnMarket(l, "2027-01-01")).toBe(10);
38 });
39
40 test("price history carries each change from the one before", () => {
41 const rows = priceHistory([
42 { date: "2026-10-01", price: 700000, kind: "list" },
43 { date: "2026-10-15", price: 680000, kind: "price" },
44 { date: "2026-10-30", price: 690000, kind: "price" },
45 { date: "2026-11-02", price: 701000, kind: "sold" },
46 ]);
47 expect(rows.map((r) => r.change)).toEqual([null, -20000, 10000, 11000]);
48 expect(historyLines(rows)).toEqual([
49 "- 2026-10-01: Listed $700,000",
50 "- 2026-10-15: Price change $680,000 (−$20,000)",
51 "- 2026-10-30: Price change $690,000 (+$10,000)",
52 "- 2026-11-02: Sold $701,000 (+$11,000)",
53 ]);
54 });
55
56 const launch = (fields: Record<string, unknown> = {}) => {
57 const db = openDb(mkdtempSync(join(tmpdir(), "app-market-")));
58 const made = launchListing(db, me, { fields: { ...SAMPLE, ...fields } });
59 if (!made.ok) throw new Error(made.message);
60 return { db, id: made.value.id };
61 };
62 const load = (db: ReturnType<typeof openDb>, id: number) => {
63 const found = getListing(db, me, { id });
64 if (!found.ok) throw new Error(found.message);
65 return found.value;
66 };
67 const back = (db: ReturnType<typeof openDb>, id: number, from: string, ended: string | null) =>
68 db.query("UPDATE listing SET listed_on = ?1, market_ended_on = ?2 WHERE id = ?3").run(from, ended, id);
69
70 test("a launched listing is active today with its list price as the first entry", () => {
71 const { db, id } = launch();
72 const l = load(db, id);
73 expect(l.listed_on).toBe(dayOf(new Date()));
74 expect(l.market_ended_on).toBeNull();
75 expect(l.history).toEqual([{ date: dayOf(new Date()), price: SAMPLE.price, kind: "list" }]);
76 });
77
78 test("Coming soon → Active starts the count; Under contract stops it; Back on market resumes it from the list date", () => {
79 const { db, id } = launch();
80 const today = dayOf(new Date());
81 // Coming soon has no count; the original list date is kept across it.
82 db.query("UPDATE listing SET listed_on = NULL WHERE id = ?1").run(id);
83 expect(setStatus(db, me, { id, status: "coming_soon" }).ok).toBe(true);
84 expect(load(db, id).listed_on).toBeNull();
85 expect(setStatus(db, me, { id, status: "active" }).ok).toBe(true);
86 expect(load(db, id).listed_on).toBe(today);
87 // Under contract ends it ...
88 back(db, id, "2026-10-01", null);
89 expect(setStatus(db, me, { id, status: "under_contract" }).ok).toBe(true);
90 expect(load(db, id).market_ended_on).toBe(today);
91 // ... and Back on market clears the end and keeps the original date.
92 expect(setStatus(db, me, { id, status: "active" }).ok).toBe(true);
93 const again = load(db, id);
94 expect(again.listed_on).toBe("2026-10-01");
95 expect(again.market_ended_on).toBeNull();
96 });
97
98 test("Sold, Withdrawn and Expired end the count; Sold records the sold price and leaving Sold takes it away", () => {
99 for (const status of ["withdrawn", "expired"]) {
100 const { db, id } = launch();
101 setStatus(db, me, { id, status });
102 expect(load(db, id).market_ended_on).toBe(dayOf(new Date()));
103 }
104 const { db, id } = launch();
105 setStatus(db, me, { id, status: "sold", sold_price: 701000 });
106 const sold = load(db, id);
107 expect(sold.market_ended_on).toBe(dayOf(new Date()));
108 expect(sold.history.map((h) => [h.kind, h.price])).toEqual([["list", SAMPLE.price], ["sold", 701000]]);
109 setStatus(db, me, { id, status: "active" });
110 expect(load(db, id).history.map((h) => h.kind)).toEqual(["list"]);
111 });
112
113 test("the history after two price changes", () => {
114 const { db, id } = launch();
115 expect(updateFacts(db, me, { id, fields: { price: 669000 } }).ok).toBe(true);
116 expect(updateFacts(db, me, { id, fields: { price: 679000 } }).ok).toBe(true);
117 // Saving without a new price records nothing.
118 expect(updateFacts(db, me, { id, fields: { beds: 4 } }).ok).toBe(true);
119 const rows = priceHistory(load(db, id).history);
120 expect(rows.map((r) => [r.kind, r.price, r.change])).toEqual([
121 ["list", SAMPLE.price, null],
122 ["price", 669000, 669000 - SAMPLE.price],
123 ["price", 679000, 10000],
124 ]);
125 });
126
127 test("the listing page, the home list, the report and the seller update carry both", () => {
128 const { db, id } = launch({ address: "9 <b>Oak</b> Road, Portland, OR 97214" });
129 updateFacts(db, me, { id, fields: { price: 669000 } });
130 back(db, id, dayOf(new Date(Date.now() - 12 * 86_400_000)), null);
131 const l = load(db, id);
132
133 const page = listingPage({ who: me, listing: l, editable: true });
134 expect(page).toContain("12 days on market");
135 expect(page).toContain("−$20,000");
136 expect(page).toContain("Listed $689,000");
137
138 const report = reportPage({ who: me, listing: l });
139 expect(report).toContain("12 days on market");
140 expect(report).toContain("Price change $669,000");
141 expect(report).not.toContain("<b>Oak</b>");
142
143 const text = reportText(l);
144 expect(text).toContain("12 days on market.");
145 expect(text).toContain("(−$20,000)");
146
147 const update = sellerUpdate(l, new Date());
148 expect(update).toContain("12 days on market.");
149 expect(update).toContain("Price history:");
150 expect(update).toContain("Price change $669,000 (−$20,000)");
151
152 const list = listListings(db, me);
153 if (!list.ok) throw new Error(list.message);
154 const html = home({ who: me, listings: list.value, draft: draftOf(SAMPLE as never, false) });
155 expect(html).toContain("12 days");
156 });
157
158 test("the samples open with believable numbers: Alder Lane has a price improvement in its history", () => {
159 const db = openDb(mkdtempSync(join(tmpdir(), "app-market-sample-")));
160 seed(db);
161 const alder = db.query<{ id: number }, []>("SELECT id FROM listing WHERE address LIKE '412 Alder Lane%'").get()!;
162 const l = getListing(db, { role: "admin", id: "a", name: "a" }, { id: alder.id });
163 if (!l.ok) throw new Error(l.message);
164 expect(daysOnMarket(l.value, dayOf(new Date()))).toBe(38);
165 expect(priceHistory(l.value.history).map((r) => r.change)).toEqual([null, -20000]);
166 const sold = db.query<{ id: number }, []>("SELECT id FROM listing WHERE status = 'sold'").get()!;
167 const s = getListing(db, { role: "admin", id: "a", name: "a" }, { id: sold.id });
168 if (!s.ok) throw new Error(s.message);
169 expect(daysOnMarket(s.value, dayOf(new Date()))).toBe(63);
170 expect(s.value.history.at(-1)).toMatchObject({ kind: "sold", price: 752000 });
171 });
src/market.ts added +75
@@ -0,0 +1,75 @@
1 /**
2 * Time on the market and the price's history. Dates, never timestamps, so a
3 * listing launched today reads "0 days" in every time zone. Pure: plain data
4 * in, plain data out; the rows live in `listing` and `price_history`
5 * (src/db.ts) and are written by src/actions.ts.
6 *
7 * Days on market run from the day the listing first went active (Coming soon
8 * does not count) to today, or to the day it left the market (Under contract,
9 * Sold, Withdrawn, Expired). Back on market clears that end day and the count
10 * resumes from the original list date, as MLS does.
11 */
12
13 import { money } from "./generate.ts";
14
15 const DAY = 86_400_000;
16
17 export interface MarketDates {
18 /** The day it first went active; null while it has only been Coming soon. */
19 readonly listed_on: string | null;
20 /** The day it left the market; null while it is on it. */
21 readonly market_ended_on: string | null;
22 }
23
24 /** Whole days on market as of `today`, or null when the listing has not gone active. */
25 export function daysOnMarket(l: MarketDates, today: string): number | null {
26 if (l.listed_on === null) return null;
27 const from = Date.parse(`${l.listed_on}T00:00:00Z`);
28 const to = Date.parse(`${l.market_ended_on ?? today}T00:00:00Z`);
29 return Math.max(0, Math.round((to - from) / DAY));
30 }
31
32 /** "0 days", "1 day". */
33 export const dayCount = (n: number): string => `${n} ${n === 1 ? "day" : "days"}`;
34
35 /** "0 days on market", "1 day on market"; null when there is no count. */
36 export function daysLine(l: MarketDates, today: string): string | null {
37 const n = daysOnMarket(l, today);
38 return n === null ? null : `${dayCount(n)} on market`;
39 }
40
41 export type PriceKind = "list" | "price" | "sold";
42
43 export interface PriceEntry {
44 readonly date: string;
45 readonly price: number;
46 readonly kind: PriceKind;
47 }
48
49 export interface PriceRow extends PriceEntry {
50 /** The change from the entry before, in whole dollars; null for the first. */
51 readonly change: number | null;
52 }
53
54 const KIND_LABELS: Readonly<Record<PriceKind, string>> = { list: "Listed", price: "Price change", sold: "Sold" };
55
56 /** Entries oldest first, each with its change from the one before. */
57 export function priceHistory(entries: readonly PriceEntry[]): PriceRow[] {
58 return entries.map((e, i) => ({ ...e, change: i === 0 ? null : e.price - entries[i - 1]!.price }));
59 }
60
61 /** "−$20,000", "+$5,000", "—" for the first entry; the sign is a real minus. */
62 export const changeText = (change: number | null): string =>
63 change === null || change === 0 ? "—" : `${change < 0 ? "−" : "+"}${money(Math.abs(change))}`;
64
65 /** One line of plain text per entry: "2026-10-01 Listed $689,000". */
66 export const historyLines = (rows: readonly PriceRow[]): string[] =>
67 rows.map((r) => `- ${r.date}: ${KIND_LABELS[r.kind]} ${money(r.price)}${r.change === null || r.change === 0 ? "" : ` (${changeText(r.change)})`}`);
68
69 export const kindLabel = (k: PriceKind): string => KIND_LABELS[k];
70
71 /** The day count (when there is one) and the price history as plain lines, for the seller update and the report. */
72 export const marketLines = (l: MarketDates & { history: readonly PriceEntry[] }, today: string): string[] => {
73 const days = daysLine(l, today);
74 return [...(days === null ? [] : [`${days}.`, ""]), "Price history:", ...historyLines(priceHistory(l.history))];
75 };
src/pages/home.ts +9 −1
@@ -21,6 +21,8 @@ import { PHOTOS_MAX } from "../photos.ts";
2121 import { SAMPLE } from "../sample.ts";
2222 import type { Who } from "../who.ts";
2323 import { ago, layout, toast } from "./layout.ts";
24 import { dayCount, daysOnMarket, type MarketDates } from "../market.ts";
25 import { dayOf } from "../showings.ts";
2426 import { approvalPill, photoUrl, statusPill } from "./listing.ts";
2527
2628 const TONE_SAYS: Readonly<Record<Tone, string>> = {
@@ -236,7 +238,7 @@ function list(listings: readonly ListingSummary[], wanted: Find): string {
236238 <span class="card-body">
237239 <b>${escape(l.address)}</b>
238240 <span>${money(l.price)} · ${l.beds} bd · ${l.baths} ba · ${count(l.sqft)} sq ft</span>
239 <span class="row card-meta">${approvalPill(l.approvals.filter((a) => a.approved).length, l.approvals.length)}${statusPill(l)}${status(l.flags)}${l.showings === 0 ? "" : `<span class="pill">${l.showings} ${l.showings === 1 ? "showing" : "showings"}</span>`}<small class="muted">${escape(l.author_name)} · ${ago(l.updated_at)}</small></span>
241 <span class="row card-meta">${approvalPill(l.approvals.filter((a) => a.approved).length, l.approvals.length)}${statusPill(l)}${l.status === "active" ? daysPill(l) : ""}${status(l.flags)}${l.showings === 0 ? "" : `<span class="pill">${l.showings} ${l.showings === 1 ? "showing" : "showings"}</span>`}<small class="muted">${escape(l.author_name)} · ${ago(l.updated_at)}</small></span>
240242 </span>
241243 </a>
242244 </li>`;
@@ -244,6 +246,12 @@ function list(listings: readonly ListingSummary[], wanted: Find): string {
244246 .join("")}</ul>`;
245247 }
246248
249 /** How long an active listing has been on the market, as a pill; nothing before it went active. */
outdated

This doc comment belongs to status below, not daysPill. Move it back.

250 const daysPill = (l: MarketDates): string => {
251 const n = daysOnMarket(l, dayOf(new Date()));
252 return n === null ? "" : `<span class="pill market-days-pill">${escape(dayCount(n))}</span>`;
253 };
254
247255 /** Green when nothing is flagged; amber with how many otherwise. */
248256 export const status = (flags: number): string =>
249257 flags === 0
src/pages/listing.ts +3
@@ -26,6 +26,7 @@ import { isSample } from "../photos.ts";
2626 import type { Who } from "../who.ts";
2727 import { dayOf } from "../showings.ts";
2828 import { ago, layout, toast } from "./layout.ts";
29 import { daysLineHtml, priceHistoryTable } from "./market.ts";
2930 import { showingsSection } from "./showings.ts";
3031
3132 /** Where a listing's photo is served: a sample from static/, an upload from its listing. */
@@ -239,6 +240,8 @@ export function listingPage(input: {
239240 <div>
240241 <h2>${escape(l.address)}</h2>
241242 <p class="listing-price">${money(l.price)}</p>
243 ${daysLineHtml(l, dayOf(new Date()))}
244 ${priceHistoryTable(l.history)}
242245 <p class="muted">${escape(factsLine(l))}</p>
243246 ${whenLine === null ? "" : `<p class="listing-open">${escape(whenLine)}</p>`}
244247 <p class="muted"><small>${escape(l.agent_name)} · ${escape(l.brokerage)} · updated ${ago(l.updated_at)}</small></p>
src/pages/market.ts added +24
@@ -0,0 +1,24 @@
1 /**
2 * Days on market and the price history as the pages show them: a line under
3 * the price and a short table. Plain data in (src/market.ts), escaped HTML out.
4 */
5
6 import { money } from "../generate.ts";
7 import { escape } from "../http.ts";
8 import { changeText, daysLine, kindLabel, priceHistory, type MarketDates, type PriceEntry } from "../market.ts";
9
10 /** "12 days on market", or "" where the listing has not gone active. */

Same name as the numeric daysOnMarket in src/market.ts; rename to avoid confusion.

11 export const daysLineHtml = (l: MarketDates, today: string): string => {
12 const line = daysLine(l, today);
13 return line === null ? "" : `<p class="market-days">${escape(line)}</p>`;
14 };
15
16 /** The price history, oldest first: date, what happened, price, change. */
17 export const priceHistoryTable = (history: readonly PriceEntry[]): string =>
18 `<table class="price-history">
19 <caption class="sr">Price history</caption>
20 <thead><tr><th scope="col">Date</th><th scope="col">Price</th><th scope="col">Change</th></tr></thead>
21 <tbody>${priceHistory(history)
22 .map((r) => `<tr><td>${escape(r.date)}</td><td>${escape(kindLabel(r.kind))} ${escape(money(r.price))}</td><td>${escape(changeText(r.change))}</td></tr>`)
23 .join("")}</tbody>
24 </table>`;
src/pages/report.ts +4
@@ -11,6 +11,8 @@ import { propertyPath, type Launched } from "../listing.ts";
1111 import { DRAFT_MARK, reportRows, type ReportRow } from "../report.ts";
1212 import type { Who } from "../who.ts";
1313 import { layout } from "./layout.ts";
14 import { dayOf } from "../showings.ts";
15 import { daysLineHtml, priceHistoryTable } from "./market.ts";
1416 import { photoUrl } from "./listing.ts";
1517
1618 const row = (r: ReportRow): string =>
@@ -40,6 +42,8 @@ export function reportPage(input: { who: Who; listing: Launched; origin?: string
4042 <p class="muted">Launch report · ${escape(l.agent_name)}, ${escape(l.brokerage)}</p>
4143 <h2>${escape(l.address)}</h2>
4244 <p class="report-price brand">${money(l.price)} · ${escape(l.status === "active" ? "Active" : statusLine(l))}</p>
45 ${daysLineHtml(l, dayOf(new Date()))}
46 ${priceHistoryTable(l.history)}
4347 ${hero === undefined ? "" : `<img class="report-hero" src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="1200" height="800">`}
4448 <p>Property page: <a href="${escape(propertyPath(l))}">${escape(link)}</a></p>
4549 <p class="muted">${signed} of ${rows.length} assets approved by a person.</p>
src/report.ts +4
@@ -7,6 +7,8 @@
77 */
88
99 import { money, statusLine } from "./generate.ts";
10 import { marketLines } from "./market.ts";
11 import { dayOf } from "./showings.ts";
1012 import { publicOrigin } from "./env.ts";
1113 import { propertyPath, type Approval, type Asset, type Launched } from "./listing.ts";
1214
@@ -48,6 +50,8 @@ export function reportText(l: Launched, origin = publicOrigin): string {
4850 `SELLER REPORT — ${oneLine(l.address)}`,
4951 `${money(l.price)} · ${oneLine(l.status === "active" ? "Active" : statusLine(l))}`,
5052 `Agent: ${oneLine(l.agent_name)} · ${oneLine(l.brokerage)}`,
53 ...marketLines(l, dayOf(new Date())),
outdated

.slice(1) plus a manual "" to cancel marketLines' leading blank line is a hack; have marketLines not lead with a blank.

54 "",
5155 `Property page: ${propertyPath(l, origin)}`,
5256 `${signed} of ${rows.length} assets approved by a person.`,
5357 "",
src/sample.ts +22
@@ -112,12 +112,15 @@ interface Sample {
112112 readonly alt: readonly string[];
113113 /** Where the sample stands; absent means active. */
114114 readonly sold_at?: number;
115 /** Its time on the market: days ago it went active, the price it listed at, each cut as days ago and the price it went to, and days ago it left the market. */
116 readonly market: { readonly listed: number; readonly list_price: number; readonly cuts: readonly (readonly [number, number])[]; readonly ended?: number };
115117 /** The showings the agent logged: days ago, the buyer's agent, how interested, what they said. */
116118 readonly showings: readonly (readonly [number, string, Interest, string])[];
117119 }
118120
119121 const SAMPLES: readonly Sample[] = [
120122 { name: "sample", listing: { ...SAMPLE, open_houses: [SAMPLE_OPEN_HOUSE] }, photos: SAMPLE_PHOTOS, headshot: SAMPLE_HEADSHOT, alt: ["Front of 412 Alder Lane with a covered porch", "Back garden with raised beds and a patio"],
123 market: { listed: 38, list_price: 709000, cuts: [[17, 689000]] },
121124 showings: [
122125 [9, "Priya Nair, Larkspur Realty", "interested", "Loved the kitchen and the oak floors; wants a second look with her clients."],
123126 [5, "Tom Okafor, Eastside Homes", "maybe", "Garden is lovely; the one bathroom upstairs is tight for them."],
@@ -130,6 +133,7 @@ const SAMPLES: readonly Sample[] = [
130133 photos: ["sample-condo-front.svg", "sample-condo-living.svg"],
131134 headshot: "sample-agent-2.svg",
132135 alt: ["Building entrance with a glass lobby", "Living room with floor-to-ceiling windows"],
136 market: { listed: 75, list_price: 765000, cuts: [[40, 745000]], ended: 12 },
133137 showings: [
134138 [30, "Lena Brandt, Cascade Row", "interested", "Corner views won them over; asked about the HOA dues."],
135139 [21, "Marcus Hale, Puget Realty", "maybe", "Likes the rooftop; wants a second parking space."],
@@ -144,6 +148,7 @@ const SAMPLES: readonly Sample[] = [
144148 photos: ["sample-house-front.svg", "sample-house-yard.svg"],
145149 headshot: "sample-agent-3.svg",
146150 alt: ["Brick front with a two-car garage", "Fenced yard with a mature oak"],
151 market: { listed: 21, list_price: 559000, cuts: [] },
147152 showings: [
148153 [6, "Ruth Castellano, Treasure Valley Homes", "maybe", "Big yard and the bonus room; concerned about the busy street."],
149154 [3, "Ian Moreau, Gem State Homes", "not_interested", "Wanted a smaller lot and a single-level plan."],
@@ -212,5 +217,22 @@ export function seed(db: Database): void {
212217 .get(s.listing.address);
213218 if (row !== null) markStatus(db, row.id, { status: "sold", sold_price: s.sold_at });
214219 }
220 // Each sample's market dates and price history are set once, last, so the numbers read as a listing with a past.
221 for (const s of SAMPLES) {
222 const first = db.query("INSERT OR IGNORE INTO seeded (name) VALUES (?1)").run(`${s.name}-market`);
223 if (first.changes === 0) continue;
224 const row = db
225 .query<{ id: number; sold_price: number | null }, [string]>("SELECT id, sold_price FROM listing WHERE author_id = 'sample' AND address = ?1")
226 .get(s.listing.address);
227 if (row === null) continue;
228 const ago = (n: number): string => dayOf(new Date(Date.now() - n * 86_400_000));
229 const { listed, list_price, cuts, ended } = s.market;
230 db.query("UPDATE listing SET listed_on = ?1, market_ended_on = ?2 WHERE id = ?3").run(ago(listed), ended === undefined ? null : ago(ended), row.id);
231 db.query("DELETE FROM price_history WHERE listing_id = ?1").run(row.id);
232 const put = db.query("INSERT INTO price_history (listing_id, date, price, kind) VALUES (?1, ?2, ?3, ?4)");
233 put.run(row.id, ago(listed), list_price, "list");
234 for (const [days, price] of cuts) put.run(row.id, ago(days), price, "price");
235 if (ended !== undefined && row.sold_price !== null) put.run(row.id, ago(ended), row.sold_price, "sold");
236 }
215237 })();
216238 }
src/tty.test.ts +14
@@ -465,3 +465,17 @@ test("S lists, logs and removes showings, for the agent only, and the page hears
465465 expect(plain(t.screen())).not.toContain("Showing: date");
466466 }
467467 });
468
469 test("the terminal shows the day count in the list, and the day line and price history on an open listing", () => {
470 const { db, id } = fresh();
471 db.query("UPDATE listing SET listed_on = date('now', '-12 days') WHERE id = ?1").run(id);
472 db.query("UPDATE price_history SET date = date('now', '-12 days') WHERE listing_id = ?1").run(id);
473 db.query("INSERT INTO price_history (listing_id, date, price, kind) VALUES (?1, date('now', '-3 days'), ?2, 'price')").run(id, SAMPLE.price - 20000);
474 const tty = session(db, me);
475 expect(plain(tty.screen())).toContain("· 12 days");
476 tty.press("\r");
477 const screen = plain(tty.screen());
478 expect(screen).toContain("12 days on market");
479 expect(screen).toContain(`Listed $${SAMPLE.price.toLocaleString("en-US")}`);
480 expect(screen).toContain("(−$20,000)");
481 });
src/tty.ts +6 −3
@@ -26,7 +26,8 @@
2626 */
2727
2828 import type { Database } from "bun:sqlite";
29 import { INTEREST_LABELS } from "./showings.ts";
29 import { dayOf, INTEREST_LABELS } from "./showings.ts";
30 import { dayCount, daysLine, daysOnMarket, historyLines, priceHistory } from "./market.ts";
3031 import { FILTERS, parseFind, type Find } from "./find.ts";
3132 import {
3233 addNote,
@@ -203,7 +204,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
203204 }
204205 const signed = l.assets.filter((a) => a.approval !== null).length;
205206 const whenLine = openHouseLine(l);
206 const head = [bold(`${l.address} · ${money(l.price)}`), dim(`${l.tone} tone · ${approvalLabel(signed, l.assets.length)}${l.status === "active" ? "" : ` · ${statusToLine(l)}`}`), ...(whenLine === null ? [] : [whenLine]), dim(propertyLine(l, publicOrigin)), ""];
207 const days = daysLine(l, dayOf(new Date()));
208 const head = [bold(`${l.address} · ${money(l.price)}`), dim(`${l.tone} tone · ${approvalLabel(signed, l.assets.length)}${l.status === "active" ? "" : ` · ${statusToLine(l)}`}`), ...(whenLine === null ? [] : [whenLine]), ...(days === null ? [] : [dim(days)]), ...historyLines(priceHistory(l.history)).map(dim), dim(propertyLine(l, publicOrigin)), ""];
207209 // Scroll so the asset under the cursor starts the page.
208210 const body = l.assets.slice(cursor).flatMap((a, i) => [
209211 `${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}`)}`,
@@ -243,7 +245,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
243245 const done = l.approvals.filter((a) => a.approved).length;
244246 const ready = approvalLabel(done, l.approvals.length);
245247 const flag = l.flags === 0 ? green("clear") : amber(`${l.flags} to review`);
246 const text = `${l.address} · ${money(l.price)}${l.status === "active" ? "" : ` · ${statusToLine(l)}`}`;
248 const n = l.status === "active" ? daysOnMarket(l, dayOf(new Date())) : null;
249 const text = `${l.address} · ${money(l.price)}${l.status === "active" ? "" : ` · ${statusToLine(l)}`}${n === null ? "" : ` · ${dayCount(n)}`}`;
247250 lines.push(
248251 `${i === cursor ? "▸" : " "} ${i === cursor ? bold(text) : text} ${done === l.approvals.length ? green(ready) : dim(ready)} ${flag} ${dim(l.author_name)}`,
249252 );
src/update.ts +3
@@ -10,6 +10,7 @@
1010
1111 import { publicOrigin } from "./env.ts";
1212 import { isOffMarket, money, statusLine, street } from "./generate.ts";
13 import { marketLines } from "./market.ts";
1314 import { propertyPath, type Launched } from "./listing.ts";
1415 import { approvedLine, oneLine } from "./report.ts";
1516 import { addDays, dayName, scheduleRows } from "./schedule.ts";
@@ -38,6 +39,8 @@ export function sellerUpdate(l: Launched, now: Date, origin = publicOrigin): str
3839 "",
3940 `Here is your update for the week of ${today} on ${oneLine(l.address)}. Its status is ${oneLine(statusLine(l))}, at ${money(l.price)}.`,
4041 "",
42 ...marketLines(l, today),
43 "",
4144 "What went out this week:",
4245 ...(sent.length === 0 ? ["- Nothing new was approved this week."] : sent),
4346 "",
static/app.css +16
@@ -1288,3 +1288,19 @@ main:focus {
12881288 text-underline-offset: 0.15em;
12891289 cursor: pointer;
12901290 }
1291
1292 /* Days on market and the price history: a muted line and a short table under the price. */
1293 .market-days {
1294 margin: 0 0 var(--space-2);
1295 color: var(--color-muted);
1296 }
1297 .price-history {
1298 border-collapse: collapse;
1299 margin: 0 0 var(--space-3);
1300 }
1301 .price-history th,
1302 .price-history td {
1303 text-align: left;
1304 padding: var(--space-1) var(--space-3) var(--space-1) 0;
1305 border-bottom: 1px solid var(--color-line);
1306 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs