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
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/87-see-each-listing-s-days-on-market-and-it → main 2 commits fork 3 threads · 3 unresolved
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 | ||
| 304 | 304 | it looks for, class by class, in words you can read to a client; every finding |
| 305 | 305 | links to it. It is built from the rules themselves, so it is never out of date. |
| 306 | 306 | |
| 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 | ||
| 307 | 320 | ## For whoever runs it |
| 308 | 321 | |
| 309 | 322 | 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 { | ||
| 50 | 50 | } from "./photos.ts"; |
| 51 | 51 | import { headshotFrom } from "./agent.ts"; |
| 52 | 52 | 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"; | |
| 54 | 55 | import { validVisitor, VISITORS_MAX, type Visitor } from "./visitors.ts"; |
| 55 | 56 | import type { Who } from "./who.ts"; |
| 56 | 57 | |
| @@ -354,9 +355,20 @@ function launched(db: Database, id: number): Launched | undefined { | ||
| 354 | 355 | "SELECT kind, body, edited, updated_at, approved_by_id, approved_by_name, approved_at, approved_via FROM asset WHERE listing_id = ?1", |
| 355 | 356 | ) |
| 356 | 357 | .all(id); |
| 357 | return { ...fromRow(row), assets: assetsOf(assets) }; | |
| 358 | return { ...fromRow(row), assets: assetsOf(assets), history: priceEntries(db, id) }; | |
| 358 | 359 | } |
| 359 | 360 | |
| 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 | ||
| 360 | 372 | /** |
| 361 | 373 | * Write assets fresh from the facts, as generated (not edited): every kind by |
| 362 | 374 | * default, or only `only`. A kind the listing has no words for now (a status |
| @@ -492,6 +504,8 @@ export function insertListing( | ||
| 492 | 504 | JSON.stringify(photos), |
| 493 | 505 | headshot, |
| 494 | 506 | )!; |
| 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"); | |
| 495 | 509 | writeAssets(db, fromRow(row)); |
| 496 | 510 | return launched(db, row.id)!; |
| 497 | 511 | })(); |
| @@ -505,11 +519,18 @@ export function markStatus( | ||
| 505 | 519 | ): Launched { |
| 506 | 520 | return db.transaction(() => { |
| 507 | 521 | 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. | |
| 508 | 524 | 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 *`, | |
| 511 | 530 | ) |
| 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"); | |
| 513 | 534 | // Leaving or re-entering the market changes the open house lines in the words: the assets nobody edited are written again. |
| 514 | 535 | // The reminders come and go with the status too: sold or off the market has none. |
| 515 | 536 | const remind = remindable(before.status) !== remindable(stand.status); |
| @@ -1124,6 +1145,7 @@ function changeFacts( | ||
| 1124 | 1145 | // A cut remembers the price it came from; a raise to or above it forgets. |
| 1125 | 1146 | const was = v.price < now.price ? (now.was_price ?? now.price) : now.was_price !== null && v.price < now.was_price ? now.was_price : null; |
| 1126 | 1147 | 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"); | |
| 1127 | 1149 | touch(db, now.id); |
| 1128 | 1150 | // The sign-in sheet belongs to the open house: with it gone, so are the names. |
| 1129 | 1151 | 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", () => { | ||
| 160 | 160 | expect(() => db.query("UPDATE listing SET status = 'coming_soon' WHERE id = 1").run()).toThrow(); |
| 161 | 161 | |
| 162 | 162 | 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. | |
| 164 | 165 | 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 | })), | |
| 166 | 172 | ); |
| 167 | 173 | expect(db.query("SELECT * FROM asset ORDER BY listing_id, kind").all()).toEqual(assetsBefore); |
| 168 | 174 | 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[] = [ | ||
| 236 | 236 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) |
| 237 | 237 | ); |
| 238 | 238 | 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`, | |
| 239 | 261 | ]; |
| 240 | 262 | |
| 241 | 263 | export function openDb(dir = dataDir): Database { |
src/listing.ts +9
| @@ -19,6 +19,7 @@ import { | ||
| 19 | 19 | type Status, |
| 20 | 20 | type Tone, |
| 21 | 21 | } from "./generate.ts"; |
| 22 | import type { PriceEntry } from "./market.ts"; | |
| 22 | 23 | |
| 23 | 24 | /** The agent's colours, each a token pair in design/tokens.json (`color-brand-<name>`). */ |
| 24 | 25 | export const PALETTES = ["navy", "forest", "burgundy", "charcoal", "teal"] as const; |
| @@ -46,6 +47,10 @@ export interface Listing extends ListingInput { | ||
| 46 | 47 | readonly sold_price: number | null; |
| 47 | 48 | /** Whole dollars: what the price was before it was lowered; null when it has not been. */ |
| 48 | 49 | 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; | |
| 49 | 54 | readonly id: number; |
| 50 | 55 | readonly author_id: string; |
| 51 | 56 | readonly author_name: string; |
| @@ -87,6 +92,8 @@ export type Caller = Door | "api" | "mcp"; | ||
| 87 | 92 | /** A listing with its five assets, each checked. */ |
| 88 | 93 | export interface Launched extends Listing { |
| 89 | 94 | readonly assets: readonly Asset[]; |
| 95 | /** Every price it has had, oldest first (src/market.ts). */ | |
| 96 | readonly history: readonly PriceEntry[]; | |
| 90 | 97 | } |
| 91 | 98 | |
| 92 | 99 | export const agentOf = (l: ListingInput): Agent => ({ |
| @@ -115,6 +122,8 @@ export interface ListingRow { | ||
| 115 | 122 | readonly status: Status; |
| 116 | 123 | readonly sold_price: number | null; |
| 117 | 124 | readonly was_price: number | null; |
| 125 | readonly listed_on: string | null; | |
| 126 | readonly market_ended_on: string | null; | |
| 118 | 127 | readonly agent_name: string; |
| 119 | 128 | readonly brokerage: string; |
| 120 | 129 | 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"; | ||
| 21 | 21 | import { SAMPLE } from "../sample.ts"; |
| 22 | 22 | import type { Who } from "../who.ts"; |
| 23 | 23 | import { ago, layout, toast } from "./layout.ts"; |
| 24 | import { dayCount, daysOnMarket, type MarketDates } from "../market.ts"; | |
| 25 | import { dayOf } from "../showings.ts"; | |
| 24 | 26 | import { approvalPill, photoUrl, statusPill } from "./listing.ts"; |
| 25 | 27 | |
| 26 | 28 | const TONE_SAYS: Readonly<Record<Tone, string>> = { |
| @@ -236,7 +238,7 @@ function list(listings: readonly ListingSummary[], wanted: Find): string { | ||
| 236 | 238 | <span class="card-body"> |
| 237 | 239 | <b>${escape(l.address)}</b> |
| 238 | 240 | <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> | |
| 240 | 242 | </span> |
| 241 | 243 | </a> |
| 242 | 244 | </li>`; |
| @@ -244,6 +246,12 @@ function list(listings: readonly ListingSummary[], wanted: Find): string { | ||
| 244 | 246 | .join("")}</ul>`; |
| 245 | 247 | } |
| 246 | 248 | |
| 249 | /** How long an active listing has been on the market, as a pill; nothing before it went active. */ | |
| 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 | ||
| 247 | 255 | /** Green when nothing is flagged; amber with how many otherwise. */ |
| 248 | 256 | export const status = (flags: number): string => |
| 249 | 257 | flags === 0 |
src/pages/listing.ts +3
| @@ -26,6 +26,7 @@ import { isSample } from "../photos.ts"; | ||
| 26 | 26 | import type { Who } from "../who.ts"; |
| 27 | 27 | import { dayOf } from "../showings.ts"; |
| 28 | 28 | import { ago, layout, toast } from "./layout.ts"; |
| 29 | import { daysLineHtml, priceHistoryTable } from "./market.ts"; | |
| 29 | 30 | import { showingsSection } from "./showings.ts"; |
| 30 | 31 | |
| 31 | 32 | /** Where a listing's photo is served: a sample from static/, an upload from its listing. */ |
| @@ -239,6 +240,8 @@ export function listingPage(input: { | ||
| 239 | 240 | <div> |
| 240 | 241 | <h2>${escape(l.address)}</h2> |
| 241 | 242 | <p class="listing-price">${money(l.price)}</p> |
| 243 | ${daysLineHtml(l, dayOf(new Date()))} | |
| 244 | ${priceHistoryTable(l.history)} | |
| 242 | 245 | <p class="muted">${escape(factsLine(l))}</p> |
| 243 | 246 | ${whenLine === null ? "" : `<p class="listing-open">${escape(whenLine)}</p>`} |
| 244 | 247 | <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"; | ||
| 11 | 11 | import { DRAFT_MARK, reportRows, type ReportRow } from "../report.ts"; |
| 12 | 12 | import type { Who } from "../who.ts"; |
| 13 | 13 | import { layout } from "./layout.ts"; |
| 14 | import { dayOf } from "../showings.ts"; | |
| 15 | import { daysLineHtml, priceHistoryTable } from "./market.ts"; | |
| 14 | 16 | import { photoUrl } from "./listing.ts"; |
| 15 | 17 | |
| 16 | 18 | const row = (r: ReportRow): string => |
| @@ -40,6 +42,8 @@ export function reportPage(input: { who: Who; listing: Launched; origin?: string | ||
| 40 | 42 | <p class="muted">Launch report · ${escape(l.agent_name)}, ${escape(l.brokerage)}</p> |
| 41 | 43 | <h2>${escape(l.address)}</h2> |
| 42 | 44 | <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)} | |
| 43 | 47 | ${hero === undefined ? "" : `<img class="report-hero" src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="1200" height="800">`} |
| 44 | 48 | <p>Property page: <a href="${escape(propertyPath(l))}">${escape(link)}</a></p> |
| 45 | 49 | <p class="muted">${signed} of ${rows.length} assets approved by a person.</p> |
src/report.ts +4
| @@ -7,6 +7,8 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import { money, statusLine } from "./generate.ts"; |
| 10 | import { marketLines } from "./market.ts"; | |
| 11 | import { dayOf } from "./showings.ts"; | |
| 10 | 12 | import { publicOrigin } from "./env.ts"; |
| 11 | 13 | import { propertyPath, type Approval, type Asset, type Launched } from "./listing.ts"; |
| 12 | 14 | |
| @@ -48,6 +50,8 @@ export function reportText(l: Launched, origin = publicOrigin): string { | ||
| 48 | 50 | `SELLER REPORT — ${oneLine(l.address)}`, |
| 49 | 51 | `${money(l.price)} · ${oneLine(l.status === "active" ? "Active" : statusLine(l))}`, |
| 50 | 52 | `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 | "", | |
| 51 | 55 | `Property page: ${propertyPath(l, origin)}`, |
| 52 | 56 | `${signed} of ${rows.length} assets approved by a person.`, |
| 53 | 57 | "", |
src/sample.ts +22
| @@ -112,12 +112,15 @@ interface Sample { | ||
| 112 | 112 | readonly alt: readonly string[]; |
| 113 | 113 | /** Where the sample stands; absent means active. */ |
| 114 | 114 | 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 }; | |
| 115 | 117 | /** The showings the agent logged: days ago, the buyer's agent, how interested, what they said. */ |
| 116 | 118 | readonly showings: readonly (readonly [number, string, Interest, string])[]; |
| 117 | 119 | } |
| 118 | 120 | |
| 119 | 121 | const SAMPLES: readonly Sample[] = [ |
| 120 | 122 | { 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]] }, | |
| 121 | 124 | showings: [ |
| 122 | 125 | [9, "Priya Nair, Larkspur Realty", "interested", "Loved the kitchen and the oak floors; wants a second look with her clients."], |
| 123 | 126 | [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[] = [ | ||
| 130 | 133 | photos: ["sample-condo-front.svg", "sample-condo-living.svg"], |
| 131 | 134 | headshot: "sample-agent-2.svg", |
| 132 | 135 | 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 }, | |
| 133 | 137 | showings: [ |
| 134 | 138 | [30, "Lena Brandt, Cascade Row", "interested", "Corner views won them over; asked about the HOA dues."], |
| 135 | 139 | [21, "Marcus Hale, Puget Realty", "maybe", "Likes the rooftop; wants a second parking space."], |
| @@ -144,6 +148,7 @@ const SAMPLES: readonly Sample[] = [ | ||
| 144 | 148 | photos: ["sample-house-front.svg", "sample-house-yard.svg"], |
| 145 | 149 | headshot: "sample-agent-3.svg", |
| 146 | 150 | alt: ["Brick front with a two-car garage", "Fenced yard with a mature oak"], |
| 151 | market: { listed: 21, list_price: 559000, cuts: [] }, | |
| 147 | 152 | showings: [ |
| 148 | 153 | [6, "Ruth Castellano, Treasure Valley Homes", "maybe", "Big yard and the bonus room; concerned about the busy street."], |
| 149 | 154 | [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 { | ||
| 212 | 217 | .get(s.listing.address); |
| 213 | 218 | if (row !== null) markStatus(db, row.id, { status: "sold", sold_price: s.sold_at }); |
| 214 | 219 | } |
| 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 | } | |
| 215 | 237 | })(); |
| 216 | 238 | } |
src/tty.test.ts +14
| @@ -465,3 +465,17 @@ test("S lists, logs and removes showings, for the agent only, and the page hears | ||
| 465 | 465 | expect(plain(t.screen())).not.toContain("Showing: date"); |
| 466 | 466 | } |
| 467 | 467 | }); |
| 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 @@ | ||
| 26 | 26 | */ |
| 27 | 27 | |
| 28 | 28 | 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"; | |
| 30 | 31 | import { FILTERS, parseFind, type Find } from "./find.ts"; |
| 31 | 32 | import { |
| 32 | 33 | addNote, |
| @@ -203,7 +204,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 203 | 204 | } |
| 204 | 205 | const signed = l.assets.filter((a) => a.approval !== null).length; |
| 205 | 206 | 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)), ""]; | |
| 207 | 209 | // Scroll so the asset under the cursor starts the page. |
| 208 | 210 | const body = l.assets.slice(cursor).flatMap((a, i) => [ |
| 209 | 211 | `${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 } | ||
| 243 | 245 | const done = l.approvals.filter((a) => a.approved).length; |
| 244 | 246 | const ready = approvalLabel(done, l.approvals.length); |
| 245 | 247 | 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)}`}`; | |
| 247 | 250 | lines.push( |
| 248 | 251 | `${i === cursor ? "▸" : " "} ${i === cursor ? bold(text) : text} ${done === l.approvals.length ? green(ready) : dim(ready)} ${flag} ${dim(l.author_name)}`, |
| 249 | 252 | ); |
src/update.ts +3
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | |
| 11 | 11 | import { publicOrigin } from "./env.ts"; |
| 12 | 12 | import { isOffMarket, money, statusLine, street } from "./generate.ts"; |
| 13 | import { marketLines } from "./market.ts"; | |
| 13 | 14 | import { propertyPath, type Launched } from "./listing.ts"; |
| 14 | 15 | import { approvedLine, oneLine } from "./report.ts"; |
| 15 | 16 | import { addDays, dayName, scheduleRows } from "./schedule.ts"; |
| @@ -38,6 +39,8 @@ export function sellerUpdate(l: Launched, now: Date, origin = publicOrigin): str | ||
| 38 | 39 | "", |
| 39 | 40 | `Here is your update for the week of ${today} on ${oneLine(l.address)}. Its status is ${oneLine(statusLine(l))}, at ${money(l.price)}.`, |
| 40 | 41 | "", |
| 42 | ...marketLines(l, today), | |
| 43 | "", | |
| 41 | 44 | "What went out this week:", |
| 42 | 45 | ...(sent.length === 0 ? ["- Nothing new was approved this week."] : sent), |
| 43 | 46 | "", |
static/app.css +16
| @@ -1288,3 +1288,19 @@ main:focus { | ||
| 1288 | 1288 | text-underline-offset: 0.15em; |
| 1289 | 1289 | cursor: pointer; |
| 1290 | 1290 | } |
| 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(); | |
| 1295 | color: var(); | |
| 1296 | } | |
| 1297 | .price-history { | |
| 1298 | border-collapse: collapse; | |
| 1299 | margin: 0 0 var(); | |
| 1300 | } | |
| 1301 | .price-history th, | |
| 1302 | .price-history td { | |
| 1303 | text-align: left; | |
| 1304 | padding: var() var() var() 0; | |
| 1305 | border-bottom: 1px solid var(); | |
| 1306 | } |
This doc comment belongs to
statusbelow, not daysPill. Move it back.