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

Merged opened this

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

Merged

mason/deal-clock:mason/293-closed-deals-show-days-from-contract-to → main 2 commits fork

  • Merged. mason/293-closed-deals-show-days-from-contract-to was merged into main as 6b38028.

Review

Sign in to review this change.
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",
148148 and the book line under the clock ends with the commission pending on open
149149 deals and the commission earned on deals closed this year. Commission is yours
150150 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,
153157 earnest money, commission, every party, every date with the day it was done,
154158 waived or moved, and every message you approved or declined.
155159
docs/TECHNICAL.md +2
@@ -173,6 +173,8 @@ deal (`b` goes back). Its print rules in `static/app.css` apply to that page onl
173173
174174 `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.
175175
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
176178 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.
177179
178180 **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 @@
66 */
77
88 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";
1010 import { isOpen, KIND } from "../deadlines.ts";
1111 import { escape } from "../http.ts";
1212 import { agent as defaults } from "../env.ts";
@@ -23,6 +23,7 @@ import { onSide, type SideFilter } from "../side.ts";
2323 import { dealSideLabel, sideFilterSelect, sideLabel } from "./side.ts";
2424 import { memoizer, print } from "../memo.ts";
2525 import { bookLine, tally } from "../book.ts";
26 import { closedInLine, daysToClose, paceLine, paceOf } from "../pace.ts";
2627 import { dollars } from "../money.ts";
2728 import { progressLine } from "./progress.ts";
2829
@@ -51,6 +52,13 @@ function rows(deal: DealView, start: string): { row: number; x: number; i: numbe
5152 });
5253 }
5354
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
5462 const at = (days: number): string => `calc(${days} * var(--size-day))`;
5563
5664 const lanes = memoizer<string>(1000);
@@ -220,7 +228,7 @@ function render(input: {
220228 : `<section id="closed" class="card" data-live="deal.changed">
221229 <h2>Closed <span class="muted">· ${closed.length + fell.length}</span></h2>
222230 ${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>
224232 </section>`;
225233 const restoreCard =
226234 !mayRestore(who).ok
@@ -238,7 +246,7 @@ function render(input: {
238246 <div>
239247 <h1>The clock</h1>
240248 <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>`}
242250 </div>
243251 ${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>`}
244252 </section>

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs