Public: anyone can read this; the crew can change it.
Closed deals show days from contract to closing, and the year's total adds the average #295
Check · Review · Land · Live done — Landed and live in production.
mason/deal-clock:mason/293-closed-deals-show-days-from-contract-to → main 2 commits fork
5 files
changed +116
−5
README.md +6 −2
| @@ -148,8 +148,12 @@ as `2.5` or `2.5%`). The deal's page then reads "Commission 2.5% · $11,250", | ||
| 148 | 148 | and the book line under the clock ends with the commission pending on open |
| 149 | 149 | deals and the commission earned on deals closed this year. Commission is yours |
| 150 | 150 | alone: it never appears on the client timeline, a handout or a reminder. |
| 151 | The Closed list shows each closed deal's price, and **Print broker file** on a | |
| 152 | closed deal makes the one page for the brokerage's transaction file: price, | |
| 151 | The Closed list shows each closed deal's price and "Closed in N days" (the | |
| 152 | contract date to the day its Closing date was marked done; deals that fell | |
| 153 | through are listed apart and left out). Under the book line, the year's pace | |
| 154 | reads "Average 34 days to close · fastest 21 days · slowest 52 days" over deals | |
| 155 | closed this year, or says in words that none has closed yet. **Print broker | |
| 156 | file** on a closed deal makes the one page for the brokerage's transaction file: price, | |
| 153 | 157 | earnest money, commission, every party, every date with the day it was done, |
| 154 | 158 | waived or moved, and every message you approved or declined. |
| 155 | 159 |
docs/TECHNICAL.md +2
| @@ -173,6 +173,8 @@ deal (`b` goes back). Its print rules in `static/app.css` apply to that page onl | ||
| 173 | 173 | |
| 174 | 174 | `GET /api/book` (the `deal` list cell) gives the totals the clock's book line shows, as numbers: `open`, `under_contract`, `closing_this_month`, `closed_this_year`, `closed_volume`, `without_price` (`src/book.ts`, pure). Fell-through deals count in none; a closed deal counts on its closing date. |
| 175 | 175 | |
| 176 | The clock also shows the year's pace under the book line, and each Closed deal's "Closed in N days": days from the contract date to the day the Closing date was marked done, averaged over deals closed this year with the fastest and slowest (`src/pace.ts`, pure). Fell-through and open deals have no pace. | |
| 177 | ||
| 176 | 178 | A deal is **closed** when its Closing date is marked done (derived, no column; `closed` on `GET /api/deals` and `list_deals`). It leaves the clock for the Closed card, sends no reminders (`/tick`, `reminders_today`, the terminal `r` list), and the terminal date list shows its dates only after `h`. Reopening the Closing date reopens the deal. |
| 177 | 179 | |
| 178 | 180 | **One clock per house.** `planFor` (`src/deals.ts`) refuses to open a deal at an address that already has an open one of the same author, comparing after trimming, collapsing spaces, ignoring case and a trailing period (`sameAddress`). A closed deal (see above) does not count, and the sample deals are another author's. The refusal is a `conflict` Result naming the deal (`existing`): the API and MCP answer 409 `{error: "conflict", message, existing_id}`; the New deal form re-renders with a link to that deal, everything typed kept, and an "Open a second deal anyway" checkbox (`open_anyway`); the terminal asks `yes` to go on. Send `openAnyway: true` (API/MCP: `open_anyway`) to open the second deal. The date preview shows the same notice. `insertDeal` is unguarded, so Restore the sample deals is unaffected. No new contract cell. |
src/pace.test.ts added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { closedInLine, daysToClose, paceLine, paceOf, type Paced } from "./pace.ts"; | |
| 3 | import { clock } from "./pages/clock.ts"; | |
| 4 | import { openDb } from "./db.ts"; | |
| 5 | import { restore } from "./sample.ts"; | |
| 6 | import { mkdtempSync } from "node:fs"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { listDeals } from "./deals.ts"; | |
| 10 | import { today, DEFAULT_ZONE } from "./dates.ts"; | |
| 11 | ||
| 12 | const d = (contract_date: string, closedOn: string, o: Partial<Paced> = {}): Paced => ({ contract_date, closedOn, closed: true, fell_through: false, ...o }); | |
| 13 | ||
| 14 | test("average is rounded, with the fastest and slowest", () => { | |
| 15 | const p = paceOf([d("2026-01-01", "2026-01-11"), d("2026-02-01", "2026-03-04"), d("2026-03-01", "2026-03-12")], "2026-11-10"); | |
| 16 | expect(p).toEqual({ count: 3, average: 17, fastest: 10, slowest: 31 }); // (10+31+11)/3 = 17.33 | |
| 17 | expect(paceOf([d("2026-01-01", "2026-01-02"), d("2026-01-01", "2026-01-04")], "2026-11-10").average).toBe(2); // 2.0 | |
| 18 | expect(paceOf([d("2026-01-01", "2026-01-02"), d("2026-01-01", "2026-01-03")], "2026-11-10").average).toBe(2); // 1.5 rounds up | |
| 19 | }); | |
| 20 | ||
| 21 | test("fell-through, open and other-year deals are excluded", () => { | |
| 22 | const p = paceOf([d("2026-01-01", "2026-01-11"), d("2026-01-01", "2026-06-01", { fell_through: true }), d("2026-01-01", "2026-06-01", { closed: false }), d("2025-01-01", "2025-12-30")], "2026-11-10"); | |
| 23 | expect(p.count).toBe(1); | |
| 24 | expect(daysToClose(d("2026-01-01", "2026-06-01", { fell_through: true }))).toBeNull(); | |
| 25 | }); | |
| 26 | ||
| 27 | test("an empty year says so in words", () => { | |
| 28 | const line = paceLine(paceOf([], "2026-11-10")); | |
| 29 | expect(line).toContain("No deals closed this year"); | |
| 30 | expect(line).not.toMatch(/NaN|undefined/); | |
| 31 | expect(paceLine({ count: 1, average: 1, fastest: 1, slowest: 1 })).toBe("Average 1 day to close · fastest 1 day · slowest 1 day"); | |
| 32 | expect(closedInLine(0)).toBe("Closed in 0 days"); | |
| 33 | }); | |
| 34 | ||
| 35 | test("the clock shows Closed in N days and the average on the sample book", () => { | |
| 36 | const db = openDb(mkdtempSync(join(tmpdir(), "app-pace-"))); | |
| 37 | restore(db); | |
| 38 | const who = { role: "user", id: "u1", name: "alice" } as const; | |
| 39 | const deals = listDeals(db, who); | |
| 40 | if (!deals.ok) throw new Error(deals.message); | |
| 41 | const html = clock({ deals: deals.value, who, on: today(DEFAULT_ZONE) } as never); | |
| 42 | expect(html).toMatch(/Closed in \d+ days?/); | |
| 43 | expect(html).toMatch(/Average \d+ days? to close · fastest \d+ days? · slowest \d+ days?/); | |
| 44 | expect(html).not.toMatch(/NaN|undefined/); | |
| 45 | }); |
src/pace.ts added +52
| @@ -0,0 +1,52 @@ | ||
| 1 | /** | |
| 2 | * How fast deals close: days from the contract date to the day the Closing | |
| 3 | * date was marked done. Plain data in, plain data out. A deal that fell | |
| 4 | * through, or is not closed, has no pace. | |
| 5 | */ | |
| 6 | ||
| 7 | import { daysBetween } from "./dates.ts"; | |
| 8 | ||
| 9 | /** What pace needs of a deal: its contract date, and the day it closed when it did. */ | |
| 10 | export type Paced = { contract_date: string; fell_through: boolean; closed: boolean; closedOn: string }; | |
| 11 | ||
| 12 | export type Pace = { | |
| 13 | /** Deals closed in the year, with a pace. */ | |
| 14 | count: number; | |
| 15 | /** Mean days to close, rounded to a whole day; 0 when count is 0. */ | |
| 16 | average: number; | |
| 17 | fastest: number; | |
| 18 | slowest: number; | |
| 19 | }; | |
| 20 | ||
| 21 | /** Whole days from contract to closing; null for a deal that is open or fell through. Never negative. */ | |
| 22 | export function daysToClose(deal: Paced): number | null { | |
| 23 | if (!deal.closed || deal.fell_through) return null; | |
| 24 | return Math.max(0, daysBetween(deal.contract_date, deal.closedOn)); | |
| 25 | } | |
| 26 | ||
| 27 | /** The pace of deals closed in the year of `on` (YYYY-MM-DD). */ | |
| 28 | export function paceOf(deals: readonly Paced[], on: string): Pace { | |
| 29 | const days: number[] = []; | |
| 30 | for (const deal of deals) { | |
| 31 | const n = daysToClose(deal); | |
| 32 | if (n !== null && deal.closedOn.slice(0, 4) === on.slice(0, 4)) days.push(n); | |
| 33 | } | |
| 34 | if (days.length === 0) return { count: 0, average: 0, fastest: 0, slowest: 0 }; | |
| 35 | return { | |
| 36 | count: days.length, | |
| 37 | average: Math.round(days.reduce((a, b) => a + b, 0) / days.length), | |
| 38 | fastest: Math.min(...days), | |
| 39 | slowest: Math.max(...days), | |
| 40 | }; | |
| 41 | } | |
| 42 | ||
| 43 | const daysWord = (n: number): string => `${n} ${n === 1 ? "day" : "days"}`; | |
| 44 | ||
| 45 | /** "Closed in 34 days". */ | |
| 46 | export const closedInLine = (n: number): string => `Closed in ${daysWord(n)}`; | |
| 47 | ||
| 48 | /** "Average 34 days to close · fastest 21 days · slowest 52 days", or words when nothing closed this year. */ | |
| 49 | export function paceLine(p: Pace): string { | |
| 50 | if (p.count === 0) return "No deals closed this year yet, so no average days to close."; | |
| 51 | return `Average ${daysWord(p.average)} to close · fastest ${daysWord(p.fastest)} · slowest ${daysWord(p.slowest)}`; | |
| 52 | } |
src/pages/clock.ts +11 −3
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { addDays, daysBetween, isWeekend, short, spoken, urgency, urgencyOf } from "../dates.ts"; |
| 9 | import { fellOn, type DealView } from "../deals.ts"; | |
| 9 | import { closedOn, fellOn, type DealView } from "../deals.ts"; | |
| 10 | 10 | import { isOpen, KIND } from "../deadlines.ts"; |
| 11 | 11 | import { escape } from "../http.ts"; |
| 12 | 12 | import { agent as defaults } from "../env.ts"; |
| @@ -23,6 +23,7 @@ import { onSide, type SideFilter } from "../side.ts"; | ||
| 23 | 23 | import { dealSideLabel, sideFilterSelect, sideLabel } from "./side.ts"; |
| 24 | 24 | import { memoizer, print } from "../memo.ts"; |
| 25 | 25 | import { bookLine, tally } from "../book.ts"; |
| 26 | import { closedInLine, daysToClose, paceLine, paceOf } from "../pace.ts"; | |
| 26 | 27 | import { dollars } from "../money.ts"; |
| 27 | 28 | import { progressLine } from "./progress.ts"; |
| 28 | 29 | |
| @@ -51,6 +52,13 @@ function rows(deal: DealView, start: string): { row: number; x: number; i: numbe | ||
| 51 | 52 | }); |
| 52 | 53 | } |
| 53 | 54 | |
| 55 | const pacing = (deal: DealView) => ({ contract_date: deal.contract_date, fell_through: deal.fell_through, closed: deal.closed, closedOn: closedOn(deal) }); | |
| 56 | /** " · Closed in N days" for a closed deal, else nothing. */ | |
| 57 | const paced = (deal: DealView): string => { | |
| 58 | const n = daysToClose(pacing(deal)); | |
| 59 | return n === null ? "" : ` · ${escape(closedInLine(n))}`; | |
| 60 | }; | |
| 61 | ||
| 54 | 62 | const at = (days: number): string => `calc(${days} * var(--size-day))`; |
| 55 | 63 | |
| 56 | 64 | const lanes = memoizer<string>(1000); |
| @@ -220,7 +228,7 @@ function render(input: { | ||
| 220 | 228 | : `<section id="closed" class="card" data-live="deal.changed"> |
| 221 | 229 | <h2>Closed <span class="muted">· ${closed.length + fell.length}</span></h2> |
| 222 | 230 | ${who.role === "anon" ? "" : `<p class="small"><a href="/deals/csv?closed=1" download>Download spreadsheet with closed deals</a></p>`} |
| 223 | <ul class="closed">${closed.map((deal) => `<li><a href="/deals/${deal.id}">${escape(deal.address)}</a> <span class="muted">closed ${escape(spoken(deal.closing, on))}${deal.sale_price === null ? "" : ` · ${dollars(deal.sale_price)}`}</span> ${sideLabel(deal.side)}</li>`).join("")}${fell.map((deal) => `<li><a href="/deals/${deal.id}">${escape(deal.address)}</a> <b>Fell through</b> <span class="muted">${escape(spoken(fellOn(deal)))} · ${escape(deal.fell_reason ?? "")}</span> ${sideLabel(deal.side)}</li>`).join("")}</ul> | |
| 231 | <ul class="closed">${closed.map((deal) => `<li><a href="/deals/${deal.id}">${escape(deal.address)}</a> <span class="muted">closed ${escape(spoken(deal.closing, on))}${deal.sale_price === null ? "" : ` · ${dollars(deal.sale_price)}`}${paced(deal)}</span> ${sideLabel(deal.side)}</li>`).join("")}${fell.map((deal) => `<li><a href="/deals/${deal.id}">${escape(deal.address)}</a> <b>Fell through</b> <span class="muted">${escape(spoken(fellOn(deal)))} · ${escape(deal.fell_reason ?? "")}</span> ${sideLabel(deal.side)}</li>`).join("")}</ul> | |
| 224 | 232 | </section>`; |
| 225 | 233 | const restoreCard = |
| 226 | 234 | !mayRestore(who).ok |
| @@ -238,7 +246,7 @@ function render(input: { | ||
| 238 | 246 | <div> |
| 239 | 247 | <h1>The clock</h1> |
| 240 | 248 | <p class="summary"><span>${escape(spoken(on))}</span> · <span>${deals.length} ${deals.length === 1 ? "deal" : "deals"}</span>${overdue > 0 ? ` · <b class="u-overdue">${overdue} overdue</b>` : ""}${soon > 0 ? ` · <b class="u-soon">${soon} due within 3 days</b>` : ""}</p> |
| 241 | ${input.deals.length === 0 ? "" : `<p class="summary muted">${escape(bookLine(tally(input.deals, on), on))}</p>`} | |
| 249 | ${input.deals.length === 0 ? "" : `<p class="summary muted">${escape(bookLine(tally(input.deals, on), on))}</p><p class="summary muted">${escape(paceLine(paceOf(input.deals.map(pacing), on)))}</p>`} | |
| 242 | 250 | </div> |
| 243 | 251 | ${who.role === "anon" ? "" : `<span class="actions"><a class="button button-quiet" href="/deals/ics">Add all to calendar</a> <a class="button button-quiet" href="/deals/csv" download>Download spreadsheet</a> <a class="button" href="/deals/new">New deal</a></span>`} |
| 244 | 252 | </section> |
new above
offline