Public: anyone can read this; the crew can change it.
Closed deals from earlier years fold into one line per year so a long book stays quick to read #321
Check · Review · Land · Live — Its check is red.
mason/deal-clock:mason/312-closed-deals-from-earlier-years-fold-int → main 2 commits fork
7 files
changed +128
−2
README.md +1 −1
| @@ -200,7 +200,7 @@ deals and the commission earned on deals closed this year. Commission is yours | ||
| 200 | 200 | alone: it never appears on the client timeline, a handout or a reminder. |
| 201 | 201 | The Closed list shows each closed deal's price and "Closed in N days" (the |
| 202 | 202 | contract date to the day its Closing date was marked done; deals that fell |
| 203 | through are listed apart and left out). Under the book line, the year's pace | |
| 203 | through are listed apart and left out). Closed and fell-through deals from earlier years fold into one line per year ("2025 · 14 closed · $5,120,000 · commission $128,000"); tap a year to list its deals, and Find opens every year that has a match. Under the book line, the year's pace | |
| 204 | 204 | reads "Average 34 days to close · fastest 21 days · slowest 52 days" over deals |
| 205 | 205 | closed this year, or says in words that none has closed yet. **Print broker |
| 206 | 206 | file** on a closed deal makes the one page for the brokerage's transaction file: price, |
docs/TECHNICAL.md +2
| @@ -182,6 +182,8 @@ deal (`b` goes back). Its print rules in `static/app.css` apply to that page onl | ||
| 182 | 182 | |
| 183 | 183 | 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. |
| 184 | 184 | |
| 185 | Closed and fell-through deals from earlier years fold into one `<details>` per year on the clock (`src/years.ts`, pure): a deal belongs to the year of `closedOn` (or `fellOn`), in the agent's time zone. A narrowed clock (Find, side, closing) opens every year it still lists. | |
| 186 | ||
| 185 | 187 | 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. |
| 186 | 188 | |
| 187 | 189 | **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. |
docs/design/areas/clock.md +2
| @@ -7,3 +7,5 @@ New classes: | ||
| 7 | 7 | - `.progress` — the paragraph that holds both at the top of a deal's page. |
| 8 | 8 | |
| 9 | 9 | - `.closing-clash` — one amber line (`--color-soon`) under a closing on This week, the print sheet and the deal page: "Also closing at 2:30 pm: 2210 Juniper Court", the address linked to its deal. |
| 10 | ||
| 11 | - `.closed-year` — one `<details>` per earlier year in the Closed list, its summary a single line ("2025 · 14 closed · $5,120,000 · commission $128,000") with a 2.75rem tap height; opened, it holds the same `.closed` list as this year's deals. |
src/pages/clock.ts +17 −1
| @@ -28,6 +28,7 @@ import { memoizer, print } from "../memo.ts"; | ||
| 28 | 28 | import { bookLine, tally } from "../book.ts"; |
| 29 | 29 | import { closedInLine, daysToClose, paceLine, paceOf } from "../pace.ts"; |
| 30 | 30 | import { dollars } from "../money.ts"; |
| 31 | import { byYear, yearLine } from "../years.ts"; | |
| 31 | 32 | import { progressLine } from "./progress.ts"; |
| 32 | 33 | |
| 33 | 34 | /** The days the clock shows: a week behind today (further, if something open is older), to a few days past the last closing. */ |
| @@ -149,6 +150,16 @@ function reminderRows(deal: DealView, on: string, agent: Agent): { count: number | ||
| 149 | 150 | }); |
| 150 | 151 | } |
| 151 | 152 | |
| 153 | /** The Closed list's rows: closed deals with their price and pace, then the ones that fell through. */ | |
| 154 | const closedList = (deals: readonly (DealView & { fell: boolean })[], on: string): string => | |
| 155 | `<ul class="closed">${deals | |
| 156 | .map((deal) => | |
| 157 | deal.fell | |
| 158 | ? `<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>` | |
| 159 | : `<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>`, | |
| 160 | ) | |
| 161 | .join("")}</ul>`; | |
| 162 | ||
| 152 | 163 | const pages = memoizer<string>(3); |
| 153 | 164 | |
| 154 | 165 | /** The clock, rendered once while nothing it shows has changed. */ |
| @@ -228,13 +239,18 @@ function render(input: { | ||
| 228 | 239 | <ol class="lanes">${deals.map((deal) => lane(deal, start, on)).join("")}</ol> |
| 229 | 240 | </div> |
| 230 | 241 | </div>`; |
| 242 | const narrowed = q !== "" || side !== "" || closing !== ""; | |
| 243 | const { current, earlier } = byYear( | |
| 244 | [...closed.map((deal) => ({ day: closedOn(deal), fell: false, ...deal })), ...fell.map((deal) => ({ day: fellOn(deal), fell: true, ...deal }))], | |
| 245 | on, | |
| 246 | ); | |
| 231 | 247 | const closedCard = |
| 232 | 248 | closed.length === 0 && fell.length === 0 |
| 233 | 249 | ? "" |
| 234 | 250 | : `<section id="closed" class="card" data-live="deal.changed"> |
| 235 | 251 | <h2>Closed <span class="muted">· ${closed.length + fell.length}</span></h2> |
| 236 | 252 | ${who.role === "anon" ? "" : `<p class="small"><a href="/deals/csv?closed=1" download>Download spreadsheet with closed deals</a></p>`} |
| 237 | <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> | |
| 253 | ${current.length === 0 ? "" : closedList(current, on)}${earlier.map((y) => `<details class="closed-year"${narrowed ? " open" : ""}><summary>${escape(yearLine(y))}</summary>${closedList(y.deals, on)}</details>`).join("")} | |
| 238 | 254 | </section>`; |
| 239 | 255 | const restoreCard = |
| 240 | 256 | !mayRestore(who).ok |
src/years.test.ts added +36
| @@ -0,0 +1,36 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { byYear, yearLine, type Settled } from "./years.ts"; | |
| 3 | import { clock } from "./pages/clock.ts"; | |
| 4 | ||
| 5 | const d = (day: string, price: number | null = null, rate: number | null = null, fell = false): Settled => ({ day, fell, sale_price: price, commission_rate: rate }); | |
| 6 | ||
| 7 | test("this year lists as is; earlier years fold, newest first, at the year boundary", () => { | |
| 8 | const deals = [d("2026-01-01"), d("2025-12-31", 400000, 250), d("2025-03-01", 600000, 300), d("2024-06-01"), d("2025-05-05", null, null, true)]; | |
| 9 | const { current, earlier } = byYear(deals, "2026-11-11"); | |
| 10 | expect(current).toEqual([deals[0]!]); | |
| 11 | expect(earlier.map((y) => y.year)).toEqual(["2025", "2024"]); | |
| 12 | expect(earlier[0]!.deals.length).toBe(3); | |
| 13 | expect(yearLine(earlier[0]!)).toBe("2025 · 2 closed · $1,000,000 · commission $28,000 · 1 fell through"); | |
| 14 | expect(yearLine(earlier[1]!)).toBe("2024 · 1 closed"); | |
| 15 | }); | |
| 16 | ||
| 17 | test("figures appear only for deals that carry them", () => { | |
| 18 | const { earlier } = byYear([d("2023-02-02", 500000)], "2026-01-01"); | |
| 19 | expect(yearLine(earlier[0]!)).toBe("2023 · 1 closed · $500,000"); | |
| 20 | }); | |
| 21 | ||
| 22 | const deal = (id: number, address: string, closing: string) => ({ | |
| 23 | id, address, closing, closed: true, fell_through: false, fell_at: null, fell_reason: null, sale_price: 300000, commission_rate: null, | |
| 24 | contract_date: "2024-01-01", side: "buyer", zone: "UTC", deadlines: [{ kind: "closing", done_at: `${closing}T12:00:00Z`, due: closing }], conflicts: [], closingConflicts: [], parties: [], sent: [], notes: [], | |
| 25 | }) as never; | |
| 26 | ||
| 27 | test("the clock folds earlier years, and Find opens a year with a match", () => { | |
| 28 | const deals = [deal(1, "1 New St", "2026-02-02"), deal(2, "2 Old Rd", "2024-05-05")]; | |
| 29 | const page = (q: string) => clock({ who: { role: "user", name: "a", id: "u" } as never, deals, on: "2026-11-11", q }); | |
| 30 | const plain = page(""); | |
| 31 | expect(plain).toContain("1 New St"); | |
| 32 | expect(plain).toMatch(/<details class="closed-year">\s*<summary>2024 · 1 closed · \$300,000<\/summary>/); | |
| 33 | const found = page("Old"); | |
| 34 | expect(found).toMatch(/<details class="closed-year" open>/); | |
| 35 | expect(found).toContain("2 Old Rd"); | |
| 36 | }); |
src/years.ts added +59
| @@ -0,0 +1,59 @@ | ||
| 1 | /** | |
| 2 | * Closed deals by year: this year's list as it is, each earlier year folded | |
| 3 | * into one line. Plain data in, plain data out. A deal belongs to the year of | |
| 4 | * the day its Closing date was marked done (a fell-through deal, the day it | |
| 5 | * fell), both already in the agent's time zone. | |
| 6 | */ | |
| 7 | ||
| 8 | import { commission, dollars } from "./money.ts"; | |
| 9 | ||
| 10 | /** What folding needs of a closed or fell-through deal. */ | |
| 11 | export type Settled = { day: string; fell: boolean; sale_price: number | null; commission_rate: number | null }; | |
| 12 | ||
| 13 | export type Year<T> = { | |
| 14 | year: string; | |
| 15 | deals: T[]; | |
| 16 | closed: number; | |
| 17 | fell: number; | |
| 18 | /** Prices of the closed deals added; null when none carries a price. */ | |
| 19 | volume: number | null; | |
| 20 | /** Commissions of the closed deals added; null when none carries price and rate. */ | |
| 21 | commission: number | null; | |
| 22 | }; | |
| 23 | ||
| 24 | /** Deals from the year of `on` (in the order given), and earlier years newest first, each in the order given. */ | |
| 25 | export function byYear<T extends Settled>(deals: readonly T[], on: string): { current: T[]; earlier: Year<T>[] } { | |
| 26 | const now = on.slice(0, 4); | |
| 27 | const current: T[] = []; | |
| 28 | const years = new Map<string, Year<T>>(); | |
| 29 | for (const deal of deals) { | |
| 30 | const year = deal.day.slice(0, 4); | |
| 31 | if (year >= now) { | |
| 32 | current.push(deal); | |
| 33 | continue; | |
| 34 | } | |
| 35 | let y = years.get(year); | |
| 36 | if (y === undefined) years.set(year, (y = { year, deals: [], closed: 0, fell: 0, volume: null, commission: null })); | |
| 37 | y.deals.push(deal); | |
| 38 | if (deal.fell) { | |
| 39 | y.fell++; | |
| 40 | continue; | |
| 41 | } | |
| 42 | y.closed++; | |
| 43 | if (deal.sale_price !== null) { | |
| 44 | y.volume = (y.volume ?? 0) + deal.sale_price; | |
| 45 | if (deal.commission_rate !== null) y.commission = (y.commission ?? 0) + commission(deal.sale_price, deal.commission_rate); | |
| 46 | } | |
| 47 | } | |
| 48 | return { current, earlier: [...years.values()].sort((a, b) => b.year.localeCompare(a.year)) }; | |
| 49 | } | |
| 50 | ||
| 51 | /** "2025 · 14 closed · $5,120,000 · commission $128,000 · 2 fell through". */ | |
| 52 | export function yearLine(y: Year<unknown>): string { | |
| 53 | const parts = [y.year]; | |
| 54 | if (y.closed > 0 || y.fell === 0) parts.push(`${y.closed} closed`); | |
| 55 | if (y.volume !== null) parts.push(dollars(y.volume)); | |
| 56 | if (y.commission !== null) parts.push(`commission ${dollars(y.commission)}`); | |
| 57 | if (y.fell > 0) parts.push(`${y.fell} fell through`); | |
| 58 | return parts.join(" · "); | |
| 59 | } |
static/app.css +11
| @@ -1121,6 +1121,17 @@ a.marker { | ||
| 1121 | 1121 | } |
| 1122 | 1122 | } |
| 1123 | 1123 | |
| 1124 | .closed-year { | |
| 1125 | margin-top: var(); | |
| 1126 | } | |
| 1127 | .closed-year summary { | |
| 1128 | cursor: pointer; | |
| 1129 | min-height: 2.75rem; | |
| 1130 | display: flex; | |
| 1131 | align-items: center; | |
| 1132 | font-weight: 600; | |
| 1133 | } | |
| 1134 | ||
| 1124 | 1135 | /* The broker file: plain figures and lists on the screen; on paper, black on white with no tools. */ |
| 1125 | 1136 | .file-facts { |
| 1126 | 1137 | display: grid; |