Public: anyone can read this; the crew can change it.
The samples include deals closed earlier this year, so the book line and the Closed list show a year of sales and commission earned instead of $0 #165
Check · Review · Land · Live done — Landed and live in production.
mason/deal-clock:mason/158-the-samples-include-deals-closed-earlier → main 3 commits fork
16 files
changed +211
−38
README.md +4 −5
| @@ -9,8 +9,7 @@ is in [docs/TECHNICAL.md](docs/TECHNICAL.md). | ||
| 9 | 9 | |
| 10 | 10 | ## Try it on the sample deals |
| 11 | 11 | |
| 12 | Four sample deals are already in, so the first screen is never empty. Sign in | |
| 13 | first (see below). You land on **the clock**: each deal is a lane, time runs | |
| 12 | Seven sample deals are already in, so the first screen is never empty. Four are open. Three closed earlier this year: 35 Mill Pond Road, 602 Cedar Heights Blvd and 14 Saltmarsh Lane. The closed ones are off the clock, This week, reminders and the calendar. They sit in the Closed list with their prices, and they fill the book line under the clock's heading, which reads like "4 open · $1,693,400 under contract · 2 close in November · 3 closed this year, $1,375,000 · $45,328 commission pending · $37,395 earned this year". Sign in first (see below). You land on **the clock**: each deal is a lane, time runs | |
| 14 | 13 | left to right, and today is the dark line down the middle. Every date in a |
| 15 | 14 | contract is a marker on its lane: red is overdue, amber is due within three |
| 16 | 15 | days, teal is coming up, grey is done. The samples stay current: each time |
| @@ -177,7 +176,7 @@ Signed out, `/` shows what deal-clock does, three short points, a still picture | ||
| 177 | 176 | terminal open the deal and press A; over MCP, the `update_address` tool. |
| 178 | 177 | **Price and earnest money.** A deal may carry a sale price and an earnest |
| 179 | 178 | money amount, in whole dollars (digits only, up to 100,000,000): set them |
| 180 | when you open the deal, or tap Edit price and earnest money on its page. Under the clock's heading one line adds up your book ("6 open · $2,410,000 under contract · 2 close in November · 3 closed this year, $1,180,000"; once any deal has a commission rate it ends "· $31,000 commission pending · $9,000 earned this year" (open deals with a price and a rate; deals closed this year; the sample deals show it; the figure is on this line only, never on This week or the printed sheet); deals without a price add no dollars and are noted, deals that fell through count in none), and each Closed line shows its price. | |
| 179 | when you open the deal, or tap Edit price and earnest money on its page. Under the clock's heading one line adds up your book ("6 open · $2,410,000 under contract · 2 close in November · 3 closed this year, $1,180,000"; once any deal has a commission rate it ends "· $31,000 commission pending · $9,000 earned this year" (open deals with a price and a rate; deals closed this year; the three closed sample deals show it; the figure is on this line only, never on This week or the printed sheet); deals without a price add no dollars and are noted, deals that fell through count in none), and each Closed line shows its price. | |
| 181 | 180 | The deal's page and the client timeline show them as `$425,000`, a deal |
| 182 | 181 | with none shows nothing, and the earnest money reminder says the amount |
| 183 | 182 | ("Your $12,750 earnest money deposit is due Nov 7"). The sample deals carry |
| @@ -213,8 +212,8 @@ Signed out, `/` shows what deal-clock does, three short points, a still picture | ||
| 213 | 212 | In the terminal, `n` asks the side last (Buyer's agent, Listing agent or Both; enter is Buyer's agent, any other word is refused), the clock, This week and the deal show the label, and Y on a deal changes it. A side that is not buyer, listing or both is refused with 422 on the page, the API and the terminal alike. `list_deals` takes `side` and `set_side` changes it; `GET /api/deals?side=listing` does the same. |
| 214 | 213 | Each date also carries a short working note only you see, such as "Appraisal ordered, AMC says Thursday": write it in the date's sheet (Save note), or press T on a date in the terminal; it shows under the date, in This week and on the printed week, and never on a client page or in a message. |
| 215 | 214 | 15. **Put the samples back.** After the walk-through, an admin taps Sample |
| 216 | deals below the clock, then Restore the sample deals and confirms: the four | |
| 217 | samples return as they were, dated from today, and the deals you opened are | |
| 215 | deals below the clock, then Restore the sample deals and confirms: the seven | |
| 216 | samples (the three closed ones too) return as they were, dated from today, and the deals you opened are | |
| 218 | 217 | left alone. Only an admin sees it. In the terminal press R; over MCP, the `restore_samples` tool. |
| 219 | 218 | 16. **Put it on your phone.** In your phone's browser, choose Add to Home Screen: |
| 220 | 219 | the deal-clock mark sits beside the app's name, and it opens full screen |
docs/TECHNICAL.md +2 −2
| @@ -178,7 +178,7 @@ tools at `POST /mcp` (`list_deals`, `get_deal`, `client_timeline`, `reminders_to | ||
| 178 | 178 | `remove_date` — custom dates only — `remove_deal` — its author or an admin — |
| 179 | 179 | `mark_done` — with `done: false` to |
| 180 | 180 | reopen a date — `waive_date` — with `waived: false` to un-waive it — `move_date`, `set_reminder_lead`, `amend_deal` (several dates, one reason), `send_reminder`, `restore_samples` — admin only: puts the |
| 181 | four sample deals back and leaves every other deal alone). `send_reminder` takes an | |
| 181 | seven sample deals back and leaves every other deal alone). `send_reminder` takes an | |
| 182 | 182 | optional `subject` and `body` to send edited words; without them the draft |
| 183 | 183 | goes as it is. |
| 184 | 184 | |
| @@ -232,7 +232,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 232 | 232 | | `src/reminders.ts`, `src/ics.ts` | reminder drafts and `/tick`; the calendar file | |
| 233 | 233 | | `src/csv.ts` | the spreadsheet file: rows shaped to CSV, formula guard | |
| 234 | 234 | | `src/outbox.ts` | a sent reminder's mailto:/sms: link | |
| 235 | | `src/sample.ts` | the four sample deals a new database starts with | | |
| 235 | | `src/sample.ts` | the seven sample deals a new database starts with | | |
| 236 | 236 | | `src/result.ts` | what an action answers, and the audit habit they share | |
| 237 | 237 | | `src/actions.ts` | the audit trail, read | |
| 238 | 238 | | `contract.json` | who may do what: roles, and the least role each verb needs | |
src/actions.ts +1 −1
| @@ -91,7 +91,7 @@ export const mayRestore = (who: Who): Result<null> => | ||
| 91 | 91 | : refuse(who, "Sign in to restore the sample deals.", "Only an admin may restore the sample deals."); |
| 92 | 92 | |
| 93 | 93 | /** |
| 94 | * Put the four sample deals back as the README describes them, anchored to | |
| 94 | * Put the seven sample deals back as the README describes them, anchored to | |
| 95 | 95 | * today, for the next demo. Only deals the sample identity wrote are removed; |
| 96 | 96 | * every deal a person made is left as it is. Audited as a delete: the |
| 97 | 97 | * samples are removed and written again. |
src/book.closed.test.ts +1 −1
| @@ -56,7 +56,7 @@ test("the terminal's clock view opens with the same book line", () => { | ||
| 56 | 56 | const t = terminal(openDb(dir), alice, { title: "deal-clock", where: "test", agent: { name: "Dana", brokerage: "HP", phone: "555", email: "[email protected]" } }); |
| 57 | 57 | const screen = t.screen().map((l) => l.replace(/\x1b\[[0-9;]*m/g, "")); |
| 58 | 58 | expect(screen).toContain(want); |
| 59 | expect(want).toContain("2 closed this year, $612,000 · "); | |
| 59 | expect(want).toContain("5 closed this year, $1,987,000 · "); | |
| 60 | 60 | expect(want).toContain("(1 without a price)"); |
| 61 | 61 | const guest = terminal(openDb(dir), { role: "anon" }, { title: "deal-clock", where: "test", agent: { name: "Dana", brokerage: "HP", phone: "555", email: "[email protected]" } }); |
| 62 | 62 | expect(guest.screen().map((l) => l.replace(/\x1b\[[0-9;]*m/g, "")).some((l) => l.includes("under contract"))).toBe(false); |
src/db.test.ts +1 −1
| @@ -31,7 +31,7 @@ test("the sample deals are written once, however many processes open a new file" | ||
| 31 | 31 | openDb(dir); |
| 32 | 32 | const count = (db: typeof a) => |
| 33 | 33 | db.query<{ n: number }, []>("SELECT count(*) AS n FROM deal WHERE author_id = 'sample'").get()!.n; |
| 34 | expect([count(a), count(b)]).toEqual([4, 4]); | |
| 34 | expect([count(a), count(b)]).toEqual([7, 7]); | |
| 35 | 35 | }); |
| 36 | 36 | |
| 37 | 37 | test("sample deals boot looking like today, and a changed one is left alone", () => { |
src/deals.test.ts +2 −2
| @@ -64,7 +64,7 @@ test("a new database carries four sample deals: one overdue, one closing this we | ||
| 64 | 64 | const all = listDeals(db, bob); |
| 65 | 65 | if (!all.ok) throw new Error(all.message); |
| 66 | 66 | const samples = all.value.filter((d) => d.author_id === "sample"); |
| 67 | expect(samples.length).toBe(4); | |
| 67 | expect(samples.length).toBe(7); | |
| 68 | 68 | const on = today(); |
| 69 | 69 | expect( |
| 70 | 70 | samples.some((d) => d.deadlines.some((x) => x.done_at === null && x.due < on)), |
| @@ -289,7 +289,7 @@ test("the book: every open date of every open deal, no done dates, no closed dea | ||
| 289 | 289 | const all = listDeals(db, bob); |
| 290 | 290 | if (!all.ok) throw new Error(all.message); |
| 291 | 291 | const deals = all.value.filter((d) => d.author_id === "sample"); |
| 292 | expect(deals.length).toBe(4); | |
| 292 | expect(deals.length).toBe(7); | |
| 293 | 293 | const stamp = new Date("2026-10-12T08:00:00Z"); |
| 294 | 294 | const count = (ics: string) => ics.split("BEGIN:VEVENT").length - 1; |
| 295 | 295 | const openDates = (list: readonly DealView[]) => |
src/earnest.test.ts +1 −1
| @@ -66,7 +66,7 @@ test("the restored samples carry the deposit: done on three, ahead on Juniper Co | ||
| 66 | 66 | "SELECT d.address, k.due, k.done_at FROM deadline k JOIN deal d ON d.id = k.deal_id WHERE k.kind = 'earnest' ORDER BY d.id", |
| 67 | 67 | ) |
| 68 | 68 | .all(); |
| 69 | expect(rows.length).toBe(4); | |
| 69 | expect(rows.length).toBe(7); | |
| 70 | 70 | const by = (a: string) => rows.find((r) => r.address.includes(a))!; |
| 71 | 71 | expect(by("Alder Lane").done_at).not.toBeNull(); |
| 72 | 72 | expect(by("Juniper").done_at).toBeNull(); |
src/figures.test.ts +1 −1
| @@ -160,7 +160,7 @@ test("the sample deals carry plausible figures, and restoring them brings them b | ||
| 160 | 160 | const rows = db.query<{ address: string; sale_price: number | null; earnest_amount: number | null }, [string]>("SELECT address, sale_price, earnest_amount FROM deal WHERE author_id = ?1 ORDER BY id").all("sample"); |
| 161 | 161 | return rows; |
| 162 | 162 | }; |
| 163 | expect(figures().length).toBe(4); | |
| 163 | expect(figures().length).toBe(7); | |
| 164 | 164 | for (const r of figures()) { |
| 165 | 165 | expect(r.sale_price).toBeGreaterThan(100_000); |
| 166 | 166 | expect(r.earnest_amount).toBeGreaterThan(0); |
src/mcp.ts +1 −1
| @@ -524,7 +524,7 @@ export const tools: readonly Tool[] = [ | ||
| 524 | 524 | }, |
| 525 | 525 | { |
| 526 | 526 | name: "restore_samples", |
| 527 | description: "Put the four sample deals back as the README describes them, anchored to today; deals people made are untouched.", | |
| 527 | description: "Put the seven sample deals back as the README describes them, anchored to today; deals people made are untouched.", | |
| 528 | 528 | inputSchema: { type: "object", properties: {} }, |
| 529 | 529 | run: (db, who) => restoreSamples(db, who), |
| 530 | 530 | }, |
src/pages/clock.ts +1 −1
| @@ -221,7 +221,7 @@ function render(input: { | ||
| 221 | 221 | ? "" |
| 222 | 222 | : `<section id="samples" class="card"> |
| 223 | 223 | <h2>Sample deals</h2> |
| 224 | <p class="muted small">After a demo, put the four sample deals back as the README describes them. Deals you opened are not touched.</p> | |
| 224 | <p class="muted small">After a demo, put the sample deals back as the README describes them. Deals you opened are not touched.</p> | |
| 225 | 225 | <details class="confirm"> |
| 226 | 226 | <summary class="button button-quiet">Restore the sample deals</summary> |
| 227 | 227 | <p>Any change made to the sample deals is lost. Your own deals are kept.</p> |
src/profile.test.ts +1 −1
| @@ -123,7 +123,7 @@ test("the Profile page is prefilled, linked from the header, and saving changes | ||
| 123 | 123 | expect(header).toContain('value="[email protected]"'); |
| 124 | 124 | expect(await page("/")).toContain("<b>Lee Park</b>"); |
| 125 | 125 | expect(await (await fetch(base + "/")).text()).toContain("<b>Lee Park</b>"); |
| 126 | const deals = (await (await fetch(`${base}/api/deals`, { headers: root })).json()) as { id: number }[]; | |
| 126 | const deals = ((await (await fetch(`${base}/api/deals`, { headers: root })).json()) as { id: number; closed: boolean }[]).filter((d) => !d.closed); | |
| 127 | 127 | expect(await page(`/deals/${deals[0]!.id}/timeline`)).toContain("prepared by Lee Park, Bay Homes"); |
| 128 | 128 | expect(await page(`/deals/${deals[0]!.id}`)).toContain("Lee Park, Bay Homes"); |
| 129 | 129 | const cleared = await form("/profile/defaults", {}); |
src/restore.test.ts +3 −3
| @@ -40,7 +40,7 @@ test("restoring brings the README walk-through back and leaves a person's deal b | ||
| 40 | 40 | expect(date(sample("Harbor View"), "inspection").done_at).not.toBeNull(); |
| 41 | 41 | const restored = restoreSamples(db, root); |
| 42 | 42 | expect(restored.ok).toBe(true); |
| 43 | expect(samples().length).toBe(4); | |
| 43 | expect(samples().length).toBe(7); | |
| 44 | 44 | expect(JSON.stringify(all().find((d) => d.id === mine.value.id))).toBe(before); |
| 45 | 45 | |
| 46 | 46 | const harbor = date(sample("Harbor View"), "inspection"); |
| @@ -60,7 +60,7 @@ test("restoring twice leaves what once did, and the seeded guard is not tripped" | ||
| 60 | 60 | const once = JSON.stringify(shape()); |
| 61 | 61 | restoreSamples(db, root); |
| 62 | 62 | expect(JSON.stringify(shape())).toBe(once); |
| 63 | expect(samples().length).toBe(4); | |
| 63 | expect(samples().length).toBe(7); | |
| 64 | 64 | expect(db.query("SELECT count(*) AS n FROM app_meta WHERE key = 'seeded'").get()).toEqual({ n: 1 }); |
| 65 | 65 | }); |
| 66 | 66 | |
| @@ -91,6 +91,6 @@ test("a sample whose address was corrected is still that sample: it moves with t | ||
| 91 | 91 | expect(moved.address).toBe("9 Orchard Row, Unit 2"); |
| 92 | 92 | expect(moved.closing).toBe(addDays(closing, 5)); |
| 93 | 93 | restoreSamples(db, root); |
| 94 | expect(samples().length).toBe(4); | |
| 94 | expect(samples().length).toBe(7); | |
| 95 | 95 | expect(samples().filter((d) => d.address.includes("Orchard Row")).map((d) => d.address)).toEqual(["9 Orchard Row"]); |
| 96 | 96 | }); |
src/sample.test.ts +108 −2
| @@ -3,10 +3,16 @@ import { mkdtempSync } from "node:fs"; | ||
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { openDb } from "./db.ts"; |
| 6 | import { listDeals } from "./deals.ts"; | |
| 6 | import { tally, bookLine } from "./book.ts"; | |
| 7 | import { book } from "./ics.ts"; | |
| 8 | import { dollars } from "./money.ts"; | |
| 9 | import { clock } from "./pages/clock.ts"; | |
| 10 | import { people } from "./people.ts"; | |
| 11 | import { thisWeek } from "./week.ts"; | |
| 12 | import { addDays, today } from "./dates.ts"; | |
| 13 | import { createDeal, listDeals, markDone } from "./deals.ts"; | |
| 7 | 14 | import { drafts, unsent } from "./reminders.ts"; |
| 8 | 15 | import type { Who } from "./who.ts"; |
| 9 | import { addDays } from "./dates.ts"; | |
| 10 | 16 | import { isUntouched, refresh, restore, shiftSample, type SampleDates } from "./sample.ts"; |
| 11 | 17 | |
| 12 | 18 | const dates: SampleDates = { |
| @@ -62,6 +68,62 @@ test("the sample deals hold an overdue date with its follow-up waiting", () => { | ||
| 62 | 68 | expect(late.length).toBeGreaterThan(0); |
| 63 | 69 | }); |
| 64 | 70 | |
| 71 | const agent = { name: "Dana", brokerage: "HP", phone: "555", email: "[email protected]" }; | |
| 72 | const closedSamples = (db: ReturnType<typeof openDb>) => { | |
| 73 | const listed = listDeals(db, { role: "user", id: "u1", name: "alice" }); | |
| 74 | if (!listed.ok) throw new Error(listed.message); | |
| 75 | return listed.value.filter((d) => d.author_id === "sample" && d.closed); | |
| 76 | }; | |
| 77 | ||
| 78 | test("the samples include three deals closed earlier this year: priced, rated and in the book", () => { | |
| 79 | const db = openDb(mkdtempSync(join(tmpdir(), "app-sample-closed-"))); | |
| 80 | const listed = listDeals(db, { role: "user", id: "u1", name: "alice" }); | |
| 81 | if (!listed.ok) throw new Error(listed.message); | |
| 82 | const closed = closedSamples(db); | |
| 83 | expect(closed.length).toBe(3); | |
| 84 | for (const d of closed) { | |
| 85 | expect(d.sale_price).not.toBeNull(); | |
| 86 | expect(d.commission_rate).not.toBeNull(); | |
| 87 | expect(d.closing < today()).toBe(true); | |
| 88 | expect(d.closing.slice(0, 4)).toBe(today().slice(0, 4)); | |
| 89 | expect(d.deadlines.every((x) => x.done_at !== null || x.waived_at !== null)).toBe(true); | |
| 90 | } | |
| 91 | // No client party carries a contact, so not even a check-in is drafted. | |
| 92 | expect(closed.flatMap((d) => drafts(d, agent))).toEqual([]); | |
| 93 | const book = tally(listed.value, today()); | |
| 94 | expect(book.closedThisYear).toBe(3); | |
| 95 | expect(book.closedVolume).toBe(closed.reduce((n, d) => n + (d.sale_price ?? 0), 0)); | |
| 96 | expect(book.commissionEarned).toBeGreaterThan(0); | |
| 97 | // The open samples are the four the clock shows; the closed ones add to neither count. | |
| 98 | expect(book.open).toBe(4); | |
| 99 | expect(bookLine(book, today())).toContain("3 closed this year, $1,375,000"); | |
| 100 | }); | |
| 101 | ||
| 102 | test("a database seeded on 1 January still reads its closed samples as this year", () => { | |
| 103 | const db = openDb(mkdtempSync(join(tmpdir(), "app-sample-jan-"))); | |
| 104 | restore(db, "2027-01-05"); | |
| 105 | const listed = listDeals(db, { role: "user", id: "u1", name: "alice" }); | |
| 106 | if (!listed.ok) throw new Error(listed.message); | |
| 107 | const book = tally(listed.value, "2027-01-05"); | |
| 108 | expect(book.closedThisYear).toBe(3); | |
| 109 | expect(book.commissionEarned).toBeGreaterThan(0); | |
| 110 | }); | |
| 111 | ||
| 112 | test("restoring the samples brings the closed ones back and leaves a real deal alone", () => { | |
| 113 | const db = openDb(mkdtempSync(join(tmpdir(), "app-sample-restore-closed-"))); | |
| 114 | const alice: Who = { role: "user", id: "u1", name: "alice" }; | |
| 115 | const mine = createDeal(db, alice, { openAnyway: true, address: "12 Elm St", buyer: "A", seller: "S", contractDate: today(), closingDate: addDays(today(), 40) }); | |
| 116 | if (!mine.ok) throw new Error(mine.message); | |
| 117 | const closing = closedSamples(db)[0]!.deadlines.find((d) => d.kind === "closing")!; | |
| 118 | markDone(db, alice, { id: closing.id, done: false }); | |
| 119 | expect(closedSamples(db).length).toBe(2); | |
| 120 | restore(db); | |
| 121 | expect(closedSamples(db).length).toBe(3); | |
| 122 | const after = listDeals(db, alice); | |
| 123 | if (!after.ok) throw new Error(after.message); | |
| 124 | expect(after.value.filter((d) => d.author_id !== "sample").map((d) => d.address)).toEqual(["12 Elm St"]); | |
| 125 | }); | |
| 126 | ||
| 65 | 127 | function lateDone(db: ReturnType<typeof openDb>, on = new Date().toISOString().slice(0, 10)): string[] { |
| 66 | 128 | return db |
| 67 | 129 | .query<{ kind: string; due: string; done_at: string }, []>("SELECT kind, due, done_at FROM deadline WHERE done_at IS NOT NULL AND deal_id IN (SELECT id FROM deal WHERE author_id = 'sample')") |
| @@ -95,3 +157,47 @@ test("a real deal's finished date is left alone by a refresh", () => { | ||
| 95 | 157 | const kept = db.query<{ done_at: string }, [number]>("SELECT done_at FROM deadline WHERE deal_id = ?1 AND done_at IS NOT NULL").all(real); |
| 96 | 158 | expect(kept.every((d) => d.done_at === stamp)).toBe(true); |
| 97 | 159 | }); |
| 160 | ||
| 161 | test("the closed samples are off the clock, This week, People and the calendar, and listed under Closed with their price", () => { | |
| 162 | const db = openDb(mkdtempSync(join(tmpdir(), "app-sample-off-"))); | |
| 163 | const alice: Who = { role: "user", id: "u1", name: "alice" }; | |
| 164 | const listed = listDeals(db, alice); | |
| 165 | if (!listed.ok) throw new Error(listed.message); | |
| 166 | const closed = listed.value.filter((d) => d.author_id === "sample" && d.closed); | |
| 167 | expect(closed.length).toBe(3); | |
| 168 | const html = clock({ who: alice, deals: listed.value, on: today() }); | |
| 169 | const cut = html.indexOf('id="closed"'); | |
| 170 | expect(cut).toBeGreaterThan(0); | |
| 171 | const card = html.slice(cut, html.indexOf("</ul>", cut)); | |
| 172 | const rest = html.slice(0, cut) + html.slice(html.indexOf("</ul>", cut)); | |
| 173 | for (const d of closed) { | |
| 174 | expect(card).toContain(`${d.address}</a>`); | |
| 175 | expect(card).toContain(dollars(d.sale_price!)); | |
| 176 | expect(rest).not.toContain(d.address); | |
| 177 | expect(thisWeek(listed.value, today()).some((i) => i.deal.id === d.id)).toBe(false); | |
| 178 | expect(people(listed.value, today()).some((p) => p.owes.some((o) => o.address === d.address))).toBe(false); | |
| 179 | expect(book(listed.value, "clock.example")).not.toContain(d.address); | |
| 180 | } | |
| 181 | // Left open, the same deal would show up in the lanes: the assertions above are not vacuous. | |
| 182 | const open = listed.value.filter((d) => d.author_id === "sample" && !d.closed); | |
| 183 | expect(clock({ who: alice, deals: open, on: today() })).toContain(open[0]!.address); | |
| 184 | }); | |
| 185 | ||
| 186 | test("refresh keeps the closed samples in the past and in this year, across a New Year too", () => { | |
| 187 | const db = openDb(mkdtempSync(join(tmpdir(), "app-sample-keep-"))); | |
| 188 | restore(db, "2026-12-20"); | |
| 189 | const alice: Who = { role: "user", id: "u1", name: "alice" }; | |
| 190 | const closings = (on: string) => { | |
| 191 | const listed = listDeals(db, alice); | |
| 192 | if (!listed.ok) throw new Error(listed.message); | |
| 193 | const c = listed.value.filter((d) => d.author_id === "sample" && d.closed); | |
| 194 | expect(c.length).toBe(3); | |
| 195 | expect(tally(listed.value, on).closedThisYear).toBe(3); | |
| 196 | return c.map((d) => d.closing); | |
| 197 | }; | |
| 198 | for (const c of closings("2026-12-20")) expect(c < "2026-12-20" && c.startsWith("2026")).toBe(true); | |
| 199 | refresh(db, "2026-12-30"); | |
| 200 | for (const c of closings("2026-12-30")) expect(c < "2026-12-30" && c.startsWith("2026")).toBe(true); | |
| 201 | refresh(db, "2027-01-10"); | |
| 202 | for (const c of closings("2027-01-10")) expect(c <= "2027-01-10" && c.startsWith("2027")).toBe(true); | |
| 203 | }); |
src/sample.ts +75 −9
| @@ -1,8 +1,10 @@ | ||
| 1 | 1 | /** |
| 2 | * Four sample deals, written once into a new database so the first screen | |
| 2 | * Seven sample deals, written once into a new database so the first screen | |
| 3 | 3 | * shows the job: one with a date gone overdue, one closing this week, one |
| 4 | * with a moved date, and one just signed. Dates are set relative to the | |
| 5 | * day the database is created, so a fresh preview always looks like today. | |
| 4 | * with a moved date, and one just signed; then three closed earlier this | |
| 5 | * year, so the book line and the Closed list show a year of sales. Dates are | |
| 6 | * set relative to the day the database is created, so a fresh preview always | |
| 7 | * looks like today. | |
| 6 | 8 | */ |
| 7 | 9 | |
| 8 | 10 | import type { Database } from "bun:sqlite"; |
| @@ -124,8 +126,64 @@ const SAMPLES: readonly Sample[] = [ | ||
| 124 | 126 | ], |
| 125 | 127 | waived: ["appraisal"], |
| 126 | 128 | }, |
| 129 | // Closed earlier this year: every date done or waived, so they are off the clock. | |
| 130 | { | |
| 131 | address: "35 Mill Pond Road", | |
| 132 | buyer: "Hannah & Luis Ortega", | |
| 133 | seller: "Fiona Calloway", | |
| 134 | contract: -64, | |
| 135 | closing: -21, | |
| 136 | price: 465_000, | |
| 137 | rate: 250, | |
| 138 | earnest: 9_300, | |
| 139 | parties: [ | |
| 140 | ["lender", "Priya Nair, Coastline Mortgage", "[email protected]"], | |
| 141 | ["title", "Marcus Lee, Bayside Title", "[email protected]"], | |
| 142 | ], | |
| 143 | done: ["earnest", "inspection", "appraisal", "financing", "title", "walkthrough", "closing"], | |
| 144 | }, | |
| 145 | { | |
| 146 | address: "602 Cedar Heights Blvd", | |
| 147 | side: "listing", | |
| 148 | buyer: "Ines Park", | |
| 149 | seller: "Douglas & Marian Hale", | |
| 150 | contract: -52, | |
| 151 | closing: -9, | |
| 152 | price: 612_000, | |
| 153 | rate: 275, | |
| 154 | earnest: 12_000, | |
| 155 | parties: [ | |
| 156 | ["lender", "Owen Brandt, First Keel Bank", "[email protected]"], | |
| 157 | ["title", "Northgate Title & Escrow", "[email protected]"], | |
| 158 | ["other_agent", "Ben Adeyemi, Lantern Realty", "[email protected]"], | |
| 159 | ], | |
| 160 | done: ["earnest", "inspection", "appraisal", "financing", "title", "walkthrough", "closing"], | |
| 161 | }, | |
| 162 | { | |
| 163 | address: "14 Saltmarsh Lane", | |
| 164 | buyer: "Terrence Boyd", | |
| 165 | seller: "Naomi Fisher", | |
| 166 | contract: -240, | |
| 167 | closing: -200, | |
| 168 | price: 298_000, | |
| 169 | rate: 300, | |
| 170 | earnest: 6_000, | |
| 171 | parties: [ | |
| 172 | ["lender", "Priya Nair, Coastline Mortgage", "[email protected]"], | |
| 173 | ["title", "Bayside Title", "[email protected]"], | |
| 174 | ["other_agent", "Alicia Moreno, Moreno & Co.", "[email protected]"], | |
| 175 | ], | |
| 176 | done: ["earnest", "inspection", "appraisal", "financing", "title", "walkthrough", "closing"], | |
| 177 | }, | |
| 127 | 178 | ]; |
| 128 | 179 | |
| 180 | /** The sample's dates in days from `on`; a closing in the past is held inside the year `on` is in, so it still counts as closed this year in January. */ | |
| 181 | function placed(s: Sample, on: string): { contract: number; closing: number } { | |
| 182 | if (s.closing >= 0) return s; | |
| 183 | const closing = -Math.min(-s.closing, daysBetween(`${on.slice(0, 4)}-01-01`, on)); | |
| 184 | return { contract: closing - (s.closing - s.contract), closing }; | |
| 185 | } | |
| 186 | ||
| 129 | 187 | /** The time of day a sample's finished dates carry: what tells them from a person's click. */ |
| 130 | 188 | const DONE_TIME = "T17:00:00.000Z"; |
| 131 | 189 | |
| @@ -234,7 +292,7 @@ export function refresh(db: Database, on = today()): void { | ||
| 234 | 292 | moveAuthors: moves.map((m) => m.by_name), |
| 235 | 293 | }; |
| 236 | 294 | if (!isUntouched(state, anchor)) continue; |
| 237 | const shifted = shiftSample( | |
| 295 | let shifted = shiftSample( | |
| 238 | 296 | { |
| 239 | 297 | contractDate: deal.contract_date, |
| 240 | 298 | deadlines: deadlines.map((d) => ({ id: d.id, due: d.due, doneAt: d.done_at, waivedAt: d.waived_at })), |
| @@ -243,6 +301,13 @@ export function refresh(db: Database, on = today()): void { | ||
| 243 | 301 | anchor, |
| 244 | 302 | on, |
| 245 | 303 | ); |
| 304 | // A sample closed earlier this year stays in the year: crossing New Year, it is held at 1 January. | |
| 305 | const closingRow = deadlines.find((d) => d.kind === "closing"); | |
| 306 | if (closingRow?.done_at != null) { | |
| 307 | const closingNow = shifted.deadlines.find((d) => d.id === closingRow.id)!.due; | |
| 308 | const newYear = `${on.slice(0, 4)}-01-01`; | |
| 309 | if (closingNow < newYear) shifted = shiftSample(shifted, closingNow, newYear); | |
| 310 | } | |
| 246 | 311 | // A database seeded before the fix stamped every finished date the day before the anchor: restamp it against its own due day. |
| 247 | 312 | const legacy = `${addDays(anchor, -1)}${DONE_TIME}`; |
| 248 | 313 | const restamped = shifted.deadlines.map((d, i) => (deadlines[i]!.done_at === legacy ? { ...d, doneAt: doneStamp(d.due, on) } : d)); |
| @@ -258,15 +323,16 @@ export function refresh(db: Database, on = today()): void { | ||
| 258 | 323 | }).immediate(); |
| 259 | 324 | } |
| 260 | 325 | |
| 261 | /** Write the four samples, anchored to `on`. The caller holds the write transaction. */ | |
| 326 | /** Write the samples, anchored to `on`. The caller holds the write transaction. */ | |
| 262 | 327 | function writeSamples(db: Database, on: string): void { |
| 263 | 328 | for (const s of SAMPLES) { |
| 329 | const at = placed(s, on); | |
| 264 | 330 | const deal = insertDeal(db, SAMPLE, { |
| 265 | 331 | address: s.address, |
| 266 | 332 | buyer: s.buyer, |
| 267 | 333 | seller: s.seller, |
| 268 | contractDate: addDays(on, s.contract), | |
| 269 | closingDate: addDays(on, s.closing), | |
| 334 | contractDate: addDays(on, at.contract), | |
| 335 | closingDate: addDays(on, at.closing), | |
| 270 | 336 | lengths: { ...BRIEF_LENGTHS, ...s.lengths }, |
| 271 | 337 | businessDays: false, |
| 272 | 338 | salePrice: s.price, |
| @@ -322,8 +388,8 @@ export function seed(db: Database, on = today()): void { | ||
| 322 | 388 | /** |
| 323 | 389 | * Put the samples back as `seed` wrote them: every deal authored by the sample |
| 324 | 390 | * identity (and nothing else) is removed with its dates, moves, parties and |
| 325 | * reminders, and the four are written again anchored to `on`. One immediate | |
| 326 | * transaction, so two restores at once leave four, not eight. Answers the ids | |
| 391 | * reminders, and the samples are written again anchored to `on`. One immediate | |
| 392 | * transaction, so two restores at once leave seven, not fourteen. Answers the ids | |
| 327 | 393 | * of the deals written. |
| 328 | 394 | */ |
| 329 | 395 | export function restore(db: Database, on = today()): number[] { |
src/server.test.ts +5 −4
| @@ -220,7 +220,7 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 220 | 220 | ); |
| 221 | 221 | expect((await call("list_deals", {})).result.isError).toBe(true); |
| 222 | 222 | const deals = JSON.parse((await call("list_deals", {}, alice)).result.content[0].text); |
| 223 | const open = deals[0].deadlines.find((d: { done_at: unknown }) => d.done_at === null); | |
| 223 | const open = deals.find((d: { deadlines: { done_at: unknown }[] }) => d.deadlines.some((x) => x.done_at === null)).deadlines.find((d: { done_at: unknown }) => d.done_at === null); | |
| 224 | 224 | expect((await call("mark_done", { id: open.id })).result.isError).toBe(true); |
| 225 | 225 | expect((await call("mark_done", { id: open.id }, root)).result.isError).toBeUndefined(); |
| 226 | 226 | expect((await call("mark_done", { id: open.id, done: false }, root)).result.isError).toBeUndefined(); |
| @@ -402,14 +402,14 @@ test("restore_samples over MCP: an admin restores, anyone else is refused and no | ||
| 402 | 402 | const sampleIds = async () => |
| 403 | 403 | ((await (await api("/api/deals", {}, root)).json()) as { id: number; author_id: string }[]).filter((d) => d.author_id === "sample").map((d) => d.id); |
| 404 | 404 | const before = await sampleIds(); |
| 405 | expect(before.length).toBe(4); | |
| 405 | expect(before.length).toBe(7); | |
| 406 | 406 | expect((await call()).result.isError).toBe(true); |
| 407 | 407 | expect((await call(rita)).result.isError).toBe(true); |
| 408 | 408 | expect(await sampleIds()).toEqual(before); |
| 409 | 409 | const restored = await call(root); |
| 410 | 410 | expect(restored.result.isError).toBeUndefined(); |
| 411 | 411 | const after = await sampleIds(); |
| 412 | expect(after.length).toBe(4); | |
| 412 | expect(after.length).toBe(7); | |
| 413 | 413 | const trail = (await (await api("/api/audit", {}, root)).json()) as { verb: string; outcome: string }[]; |
| 414 | 414 | expect(trail.some((r) => r.verb === "delete" && r.outcome === "ok")).toBe(true); |
| 415 | 415 | }); |
| @@ -1046,7 +1046,8 @@ test("a deal whose Closing is marked done leaves the lanes for the Closed card, | ||
| 1046 | 1046 | expect((await post("0")).status).toBe(303); |
| 1047 | 1047 | const back = await (await api("/", {}, closer)).text(); |
| 1048 | 1048 | expect(back).toContain(`<a href="/deals/${deal.id}">${deal.address}</a> <small`); |
| 1049 | expect(back).not.toContain('id="closed"'); | |
| 1049 | const card = back.slice(back.indexOf('id="closed"')); | |
| 1050 | expect(card.slice(0, card.indexOf("</ul>"))).not.toContain(deal.address); | |
| 1050 | 1051 | }); |
| 1051 | 1052 | |
| 1052 | 1053 | test("This week lists the sample's overdue inspection and this week's closing, and nothing done", async () => { |
src/terminal.test.ts +4 −3
| @@ -326,12 +326,13 @@ test("a closed deal's dates leave the date list and reminders, it is listed unde | ||
| 326 | 326 | expect(lines(t).join("\n")).toContain("88 Shut Ave"); |
| 327 | 327 | t.press("d"); |
| 328 | 328 | expect(lines(t).join("\n")).toContain("88 Shut Ave"); |
| 329 | expect(lines(t).join("\n")).not.toContain("Closed ("); | |
| 329 | expect(lines(t).join("\n")).toContain("Closed (3)"); | |
| 330 | expect(lines(t).join("\n")).not.toContain("88 Shut Ave — closed"); | |
| 330 | 331 | markDone(db, alice, { id: closing.id, done: true }); |
| 331 | 332 | t.press("d"); |
| 332 | 333 | const dates = lines(t).join("\n"); |
| 333 | 334 | expect(dates).not.toContain("Inspection period ends — 88 Shut Ave"); |
| 334 | expect(dates).toContain("Closed (1)"); | |
| 335 | expect(dates).toContain("Closed (4)"); | |
| 335 | 336 | expect(dates).toContain(`88 Shut Ave — closed ${spoken(today())}`); |
| 336 | 337 | t.press("r"); |
| 337 | 338 | expect(lines(t).join("\n")).not.toContain("88 Shut Ave"); |
| @@ -342,7 +343,7 @@ test("a closed deal's dates leave the date list and reminders, it is listed unde | ||
| 342 | 343 | goTo(t, "Closing — 88 Shut Ave"); |
| 343 | 344 | t.press("u"); |
| 344 | 345 | t.press("h"); |
| 345 | expect(lines(t).join("\n")).not.toContain("Closed ("); | |
| 346 | expect(lines(t).join("\n")).not.toContain("88 Shut Ave — closed"); | |
| 346 | 347 | expect(lines(t).join("\n")).toContain("Inspection period ends — 88 Shut Ave"); |
| 347 | 348 | }); |
| 348 | 349 |
new above
offline