Public: anyone can read this; the crew can change it.
When a contract is amended to a new closing date, the agent can move every dependent open date by the same days in one reviewed step #298
mason/deal-clock:mason/290-when-a-contract-is-amended-to-a-new-clos → main 5 commits fork 3 threads · 3 unresolved
9 files
changed +355
−8
README.md +8 −1
| @@ -148,7 +148,14 @@ reminder says "10:00 am at 12 Elm St". In the terminal press `@` on the date; | ||
| 148 | 148 | over MCP use `set_appointment`; both show it on the date. A date on a weekend or |
| 149 | 149 | federal holiday says so and offers **Move to** the next business day. For an |
| 150 | 150 | addendum that moves several dates, **Amend the contract** takes one reason and |
| 151 | sends each party one notice. Every date also takes a short working note only | |
| 151 | sends each party one notice. When buyer and seller agree to close on another | |
| 152 | day, **Closing moved** takes the new date and a reason, then lists every open | |
| 153 | date that would shift by the same days with its old and new value (weekends and | |
| 154 | holidays roll to a business day; dates you added are left alone). Untick any | |
| 155 | that should stay; nothing changes until you confirm. In the terminal press `V` | |
| 156 | on a deal: it asks for the new date and the reason, prints the same list, and | |
| 157 | takes `yes` to move them all or the numbers of the dates to keep where they are. | |
| 158 | Every date also takes a short working note only | |
| 152 | 159 | you see. A deal that falls through is marked with **Fell through…** and moves to |
| 153 | 160 | the Closed list; **Reopen** brings it back. Closing a deal is marking its |
| 154 | 161 | Closing date done: it steps off the clock, and a month later a drafted "How is |
docs/TECHNICAL.md +1
| @@ -281,6 +281,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 281 | 281 | | `src/profile.ts` | the agent's saved details (one `profile` row, the environment as defaults): `agentOf`, `zoneOf`, `getProfile`, `setProfile`, `useDefaults`, with `permit` on the writes; called by deals, MCP, routes, sample, the terminal and the Profile page | |
| 282 | 282 | | `src/progress.ts` | how far along a deal is: a deal and a day in, dates done of total, overdue count and days to closing out; called by routes and the clock, deal and progress pages | |
| 283 | 283 | | `src/settled.ts` | what the agent settled today, counted from the records: a database, zone and day in, sent, declined and held counts out; called by routes and the review page | |
| 284 | | `src/shift.ts` | "Closing moved": a deal and a new closing date in, the open non-custom dates that would shift by the same days out, rolled to business days; confirming goes through `amendDeal` | | |
| 284 | 285 | | `static/app.css`, `static/live.js`, `static/sheets.js` | the look in the tokens' words, the clock that stays current, and a date's sheet fetched when tapped | |
| 285 | 286 | |
| 286 | 287 | ## What every app stands on |
src/pages/amend.ts +40
| @@ -5,9 +5,11 @@ | ||
| 5 | 5 | * form comes back with what was typed and the field that was wrong. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { spoken } from "../dates.ts"; | |
| 8 | 9 | import { isOpen } from "../deadlines.ts"; |
| 9 | 10 | import type { DealView } from "../deals.ts"; |
| 10 | 11 | import { escape } from "../http.ts"; |
| 12 | import type { ShiftOk } from "../shift.ts"; | |
| 11 | 13 | |
| 12 | 14 | /** What the form was last sent with, and what was wrong with it. */ |
| 13 | 15 | export interface AmendState { |
| @@ -42,3 +44,41 @@ export function amendForm(deal: DealView, path: string, state?: AmendState): str | ||
| 42 | 44 | </form> |
| 43 | 45 | </section>`; |
| 44 | 46 | } |
| 47 | ||
| 48 | /** The deal page's "Closing moved" form: the new closing date and why. */ | |
| 49 | export function shiftForm(deal: DealView, path: string): string { | |
| 50 | const closing = deal.deadlines.find((d) => d.kind === "closing"); | |
| 51 | if (closing === undefined || !isOpen(closing)) return ""; | |
| 52 | return `<section class="card" id="shift"> | |
| 53 | <h2>Closing moved</h2> | |
| 54 | <p class="muted small">Buyer and seller agreed to close on another day: give the new date and why, and see every open date that would move with it before anything changes.</p> | |
| 55 | <form class="stack" method="post" action="${path}/shift"> | |
| 56 | <label>New closing date<input class="input" type="date" name="closing" required value="${escape(closing.due)}"></label> | |
| 57 | <label>Why<input class="input" name="reason" required maxlength="200" placeholder="Closing pushed a week at the lender's request"></label> | |
| 58 | <button class="button">Preview the shift</button> | |
| 59 | </form> | |
| 60 | </section>`; | |
| 61 | } | |
| 62 | ||
| 63 | /** The preview: each date's old and new value, ticked to move, and one button that confirms. */ | |
| 64 | export function shiftPreview(deal: DealView, path: string, plan: ShiftOk, reason: string): string { | |
| 65 | const rows = plan.rows | |
| 66 | .map( | |
| 67 | (r) => | |
| 68 | `<li><label class="check"><input type="checkbox" name="move_${r.id}" value="yes" checked> <b>${escape(r.label)}</b> <span class="muted">${escape(spoken(r.from))} → ${escape(spoken(r.to))}</span></label></li>`, | |
| 69 | ) | |
| 70 | .join(""); | |
| 71 | const n = Math.abs(plan.days); | |
| 72 | return `<section class="card"> | |
| 73 | <h1>Closing moved</h1> | |
| 74 | <p>${escape(deal.address)}: closing ${plan.days > 0 ? "later" : "earlier"} by ${n} day${n === 1 ? "" : "s"}. Nothing has changed yet; untick any date that should stay.</p> | |
| 75 | <form class="stack" method="post" action="${path}/shift"> | |
| 76 | <input type="hidden" name="closing" value="${escape(plan.closing)}"> | |
| 77 | <input type="hidden" name="reason" value="${escape(reason)}"> | |
| 78 | <input type="hidden" name="confirm" value="yes"> | |
| 79 | <ul class="stack">${rows}</ul> | |
| 80 | <button class="button">Move the ticked dates</button> | |
| 81 | <a class="button button-quiet" href="${path}">Cancel</a> | |
| 82 | </form> | |
| 83 | </section>`; | |
| 84 | } |
src/pages/deal.ts +2 −2
| @@ -16,7 +16,7 @@ import { closingLine } from "../closing.ts"; | ||
| 16 | 16 | import { commission, dollars, percent } from "../money.ts"; |
| 17 | 17 | import { anniversaryDrafts, checkinDrafts, drafts, pending, sentLabel, statusDrafts, type Agent } from "../reminders.ts"; |
| 18 | 18 | import type { Who } from "../who.ts"; |
| 19 | import { amendForm, type AmendState } from "./amend.ts"; | |
| 19 | import { amendForm, shiftForm, type AmendState } from "./amend.ts"; | |
| 20 | 20 | import { partyContact } from "./contact.ts"; |
| 21 | 21 | import { confirmRemove } from "./confirm.ts"; |
| 22 | 22 | import { layout } from "./layout.ts"; |
| @@ -345,7 +345,7 @@ export function dealPage(input: { | ||
| 345 | 345 | ) |
| 346 | 346 | .join("")}</ul>`; |
| 347 | 347 | const forms = may |
| 348 | ? `${amendForm(deal, path, input.amend)}<section class="card"> | |
| 348 | ? `${shiftForm(deal, path)}${amendForm(deal, path, input.amend)}<section class="card"> | |
| 349 | 349 | <h2>Add a date</h2> |
| 350 | 350 | <p class="muted small">A date the contract adds: an HOA review, a repair deadline, a rate lock.</p> |
| 351 | 351 | <form class="stack" method="post" action="${path}/deadlines"> |
src/routes.ts +30
| @@ -54,6 +54,8 @@ import { | ||
| 54 | 54 | getDeal, |
| 55 | 55 | withConflicts, |
| 56 | 56 | canChange, |
| 57 | line, | |
| 58 | REASON_MAX, | |
| 57 | 59 | listDeals, |
| 58 | 60 | listParties, |
| 59 | 61 | markDone, |
| @@ -111,6 +113,9 @@ import { activityPage } from "./pages/activity.ts"; | ||
| 111 | 113 | import { ACTIVITY_PAGE, addresses } from "./activity.ts"; |
| 112 | 114 | import { weekPage } from "./pages/week.ts"; |
| 113 | 115 | import { filePage } from "./pages/file.ts"; |
| 116 | import { layout } from "./pages/layout.ts"; | |
| 117 | import { shiftPreview } from "./pages/amend.ts"; | |
| 118 | import { shiftPlan } from "./shift.ts"; | |
| 114 | 119 | import { timelinePage } from "./pages/timeline.ts"; |
| 115 | 120 | import { welcomePage } from "./pages/welcome.ts"; |
| 116 | 121 | import { failedCheck } from "./health.ts"; |
| @@ -1223,6 +1228,31 @@ export const routes: readonly Route[] = [ | ||
| 1223 | 1228 | return html(dealPage({ who: c.who, deal: found.value, on: dayOf(agentFor(c)), amend: { values: f, problem } }), STATUS.invalid); |
| 1224 | 1229 | }, |
| 1225 | 1230 | }, |
| 1231 | { | |
| 1232 | method: "POST", | |
| 1233 | path: "/deals/:id/shift", | |
| 1234 | handle: async (c) => { | |
| 1235 | const f = await form(c); | |
| 1236 | if (f instanceof Response) return f; | |
| 1237 | const id = Number(c.params.id); | |
| 1238 | const found = getDeal(c.db, c.who, { id }); | |
| 1239 | if (!found.ok) return back(found.message, `/deals/${id}`); | |
| 1240 | const refuse = (message: string): Response => | |
| 1241 | html(refusedPage({ who: c.who, agent: agentFor(c), path: `/deals/${id}`, heading: "Closing moved", error: "invalid", message }), STATUS.invalid); | |
| 1242 | if (!canChange(c.who, found.value)) return back("You cannot change this deal.", `/deals/${id}`); | |
| 1243 | const reason = line(f.reason, 1, REASON_MAX); | |
| 1244 | if (reason === undefined) return refuse(`Say why the closing moved (up to ${REASON_MAX} characters).`); | |
| 1245 | const plan = shiftPlan(found.value, f.closing); | |
| 1246 | if (!plan.ok) return refuse(plan.message); | |
| 1247 | if (f.confirm !== "yes") | |
| 1248 | return html(layout({ who: c.who, ...headerFor(c), agent: agentFor(c), path: `/deals/${id}`, heading: "Closing moved", body: shiftPreview(found.value, `/deals/${id}`, plan, reason) })); | |
| 1249 | const dates = Object.fromEntries(plan.rows.filter((r) => f[`move_${r.id}`] === "yes").map((r) => [String(r.id), r.to])); | |
| 1250 | const n = Object.keys(dates).length; | |
outdated
The count is computed twice, and |
||
| 1251 | if (n === 0) return refuse("Nothing was ticked, so nothing moved."); | |
| 1252 | const moved = amendDeal(c.db, c.who, { dealId: id, dates, reason }); | |
| 1253 | return back(moved.ok ? withConflicts(c.db, c.who, id, `Closing moved: ${n} date${n === 1 ? "" : "s"} shifted.`) : moved.message, `/deals/${id}`); | |
| 1254 | }, | |
| 1255 | }, | |
| 1226 | 1256 | { |
| 1227 | 1257 | method: "POST", |
| 1228 | 1258 | path: "/deals/:id/delete", |
src/shift.test.ts added +162
| @@ -0,0 +1,162 @@ | ||
| 1 | /** "Closing moved": the preview lists the shift, confirming moves the ticked dates in one step. */ | |
| 2 | import { afterAll, beforeEach, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { isBusinessDay } from "./dates.ts"; | |
| 7 | import { serve } from "./server.ts"; | |
| 8 | import { forgetRationing } from "./security.ts"; | |
| 9 | import { shiftPlan } from "./shift.ts"; | |
| 10 | import type { DealView } from "./deals.ts"; | |
| 11 | import { sign } from "./who.ts"; | |
| 12 | ||
| 13 | const KEY = "a-key-only-the-forge-holds"; | |
| 14 | const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-shift-")), identityKey: KEY }); | |
| 15 | const base = `http://${server.hostname}:${server.port}`; | |
| 16 | afterAll(() => server.stop(true)); | |
| 17 | beforeEach(forgetRationing); | |
| 18 | const identity = `u9|nia|user|${Date.now() + 600_000}`; | |
| 19 | const me = { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) }; | |
| 20 | ||
| 21 | const open = async (): Promise<DealView> => { | |
| 22 | const made = await fetch(`${base}/api/deals`, { | |
| 23 | method: "POST", | |
| 24 | headers: { ...me, "content-type": "application/json" }, | |
| 25 | body: JSON.stringify({ address: "8 Shift St", open_anyway: true, buyer: "A", seller: "B", contract_date: "2026-10-01", closing_date: "2026-12-01" }), | |
| 26 | }); | |
| 27 | return (await made.json()) as DealView; | |
| 28 | }; | |
| 29 | const read = async (id: number): Promise<DealView> => (await (await fetch(`${base}/api/deals/${id}`, { headers: me })).json()) as DealView; | |
| 30 | const post = (id: number, fields: Record<string, string>, headers: Record<string, string> = {}) => { | |
| 31 | const body = new FormData(); | |
| 32 | for (const [k, v] of Object.entries(fields)) body.set(k, v); | |
| 33 | return fetch(`${base}/deals/${id}/shift`, { method: "POST", body, headers: { ...me, ...headers }, redirect: "manual" }); | |
| 34 | }; | |
| 35 | const kind = (d: DealView, k: string) => d.deadlines.find((x) => x.kind === k)!; | |
| 36 | ||
| 37 | test("the plan moves each open date by the same days and keeps to business days", async () => { | |
| 38 | const deal = await open(); | |
| 39 | const plan = shiftPlan(deal, "2026-12-08"); | |
| 40 | expect(plan.ok).toBe(true); | |
| 41 | if (!plan.ok) return; | |
| 42 | expect(plan.days).toBe(7); | |
| 43 | for (const r of plan.rows) expect(isBusinessDay(r.to) || r.label === kind(deal, "closing").label).toBe(true); | |
| 44 | expect(plan.rows.find((r) => r.id === kind(deal, "closing").id)!.to).toBe("2026-12-08"); | |
| 45 | }); | |
| 46 | ||
| 47 | test("the plan leaves out done, waived and custom dates and refuses bad input", async () => { | |
| 48 | const deal = await open(); | |
| 49 | const inspection = kind(deal, "inspection"); | |
| 50 | const withSettled = { | |
| 51 | ...deal, | |
| 52 | deadlines: [ | |
| 53 | ...deal.deadlines.map((d) => (d.id === inspection.id ? { ...d, done_at: "2026-10-05" } : d)), | |
| 54 | { ...inspection, id: 9999, kind: "custom" as const, label: "HOA review" }, | |
| 55 | ], | |
| 56 | } as DealView; | |
| 57 | const plan = shiftPlan(withSettled, "2026-12-08"); | |
| 58 | expect(plan.ok && plan.rows.some((r) => r.id === inspection.id || r.id === 9999)).toBe(false); | |
| 59 | expect(shiftPlan(deal, "soon").ok).toBe(false); | |
| 60 | expect(shiftPlan(deal, "2026-12-01").ok).toBe(false); | |
| 61 | }); | |
| 62 | ||
| 63 | test("the preview changes nothing, and confirming moves the ticked dates with the reason", async () => { | |
| 64 | const deal = await open(); | |
| 65 | const page = await post(deal.id, { closing: "2026-12-08", reason: "Lender needs a week" }); | |
| 66 | expect(page.status).toBe(200); | |
| 67 | const html = await page.text(); | |
| 68 | expect(html).toContain("Nothing has changed yet"); | |
| 69 | expect(html).toContain(`name="move_${kind(deal, "closing").id}"`); | |
| 70 | expect((await read(deal.id)).deadlines.map((d) => d.due)).toEqual(deal.deadlines.map((d) => d.due)); | |
| 71 | ||
| 72 | const inspection = kind(deal, "inspection"); | |
| 73 | const plan = shiftPlan(deal, "2026-12-08"); | |
| 74 | if (!plan.ok) throw new Error(plan.message); | |
| 75 | const fields: Record<string, string> = { closing: "2026-12-08", reason: "Lender needs a week", confirm: "yes" }; | |
| 76 | for (const r of plan.rows) if (r.id !== inspection.id) fields[`move_${r.id}`] = "yes"; | |
| 77 | const done = await post(deal.id, fields); | |
| 78 | expect(done.status).toBe(303); | |
| 79 | const after = await read(deal.id); | |
| 80 | expect(kind(after, "closing").due).toBe("2026-12-08"); | |
| 81 | expect(kind(after, "closing").moves[0]).toMatchObject({ reason: "Lender needs a week", by_name: "nia" }); | |
| 82 | expect(kind(after, "inspection").due).toBe(inspection.due); | |
| 83 | expect(kind(after, "inspection").moves).toHaveLength(0); | |
| 84 | const moved = plan.rows.find((r) => r.id === kind(deal, "title").id); | |
| 85 | if (moved) expect(kind(after, "title").due).toBe(moved.to); | |
| 86 | }); | |
| 87 | ||
| 88 | test("a bad date, no reason, nothing ticked and a foreign origin are refused", async () => { | |
| 89 | const deal = await open(); | |
| 90 | expect((await post(deal.id, { closing: "soon", reason: "x" })).status).toBe(422); | |
| 91 | expect((await post(deal.id, { closing: "2026-12-08", reason: "" })).status).toBe(422); | |
| 92 | expect((await post(deal.id, { closing: "2026-12-08", reason: "x", confirm: "yes" })).status).toBe(422); | |
| 93 | const foreign = await post(deal.id, { closing: "2026-12-08", reason: "x", confirm: "yes", [`move_${kind(deal, "closing").id}`]: "yes" }, { origin: "https://evil.example" }); | |
| 94 | expect(foreign.status).toBeGreaterThanOrEqual(400); | |
| 95 | expect(kind(await read(deal.id), "closing").due).toBe("2026-12-01"); | |
| 96 | }); | |
| 97 | ||
| 98 | test("the deal page offers Closing moved", async () => { | |
| 99 | const deal = await open(); | |
| 100 | expect(await (await fetch(`${base}/deals/${deal.id}`, { headers: me })).text()).toContain("Closing moved"); | |
| 101 | }); | |
| 102 | ||
| 103 | // ---- through the route, with reminders ---- | |
| 104 | ||
| 105 | import { openDb } from "./db.ts"; | |
| 106 | import { createDeal, getDeal, markDone, sendReminder, waiveDate } from "./deals.ts"; | |
| 107 | import { drafts } from "./reminders.ts"; | |
| 108 | import type { Who } from "./who.ts"; | |
| 109 | ||
| 110 | const nia: Who = { role: "user", id: "u9", name: "nia" }; | |
| 111 | const agent = { name: "Dana", brokerage: "Harbor", phone: "555", email: "[email protected]" }; | |
| 112 | ||
| 113 | test("through the route, done and waived dates stay, a drafted reminder follows the move and a sent one keeps its words", async () => { | |
| 114 | const dir = mkdtempSync(join(tmpdir(), "app-shift-route-")); | |
| 115 | const srv = serve({ port: 0, dataDir: dir, identityKey: KEY }); | |
| 116 | try { | |
| 117 | const db = openDb(dir); | |
| 118 | const made = createDeal(db, nia, { openAnyway: true, address: "5 Reminder Rd", buyer: "Ann Buyer", seller: "Sam Seller", contractDate: "2026-10-01", closingDate: "2026-12-01" }); | |
| 119 | if (!made.ok) throw new Error(made.message); | |
| 120 | const read = () => { | |
| 121 | const got = getDeal(db, nia, { id: made.value.id }); | |
| 122 | if (!got.ok) throw new Error(got.message); | |
| 123 | return got.value; | |
| 124 | }; | |
| 125 | const deal = read(); | |
| 126 | const by = (k: string) => deal.deadlines.find((d) => d.kind === k)!; | |
| 127 | const [done, waived, sentOne, draftOne] = [by("earnest"), by("appraisal"), by("inspection"), by("title")]; | |
| 128 | expect(markDone(db, nia, { id: done.id, done: true }).ok).toBe(true); | |
| 129 | expect(waiveDate(db, nia, { id: waived.id, waived: true }).ok).toBe(true); | |
| 130 | const buyer = read().parties.find((p) => p.role === "buyer")!; | |
| 131 | expect(sendReminder(db, nia, { deadlineId: sentOne.id, partyId: buyer.id, moment: "before" }).ok).toBe(true); | |
| 132 | const wordsSent = read().sent.find((s) => s.deadline_id === sentOne.id)!.body; | |
| 133 | const draftBefore = drafts(read(), agent).find((d) => d.deadline_id === draftOne.id && d.moment === "before" && d.party_role === "buyer")!; | |
| 134 | expect(draftBefore.sent_at).toBeNull(); | |
| 135 | ||
| 136 | const plan = shiftPlan(deal, "2026-12-15"); | |
| 137 | if (!plan.ok) throw new Error(plan.message); | |
| 138 | const fields: Record<string, string> = { closing: "2026-12-15", reason: "Lender delay", confirm: "yes" }; | |
| 139 | for (const r of plan.rows) fields[`move_${r.id}`] = "yes"; | |
| 140 | // Settled dates are not in the plan; ticking them anyway moves nothing. | |
| 141 | fields[`move_${done.id}`] = "yes"; | |
| 142 | fields[`move_${waived.id}`] = "yes"; | |
| 143 | const body = new FormData(); | |
| 144 | for (const [k, v] of Object.entries(fields)) body.set(k, v); | |
| 145 | const res = await fetch(`http://${srv.hostname}:${srv.port}/deals/${made.value.id}/shift`, { method: "POST", body, headers: me, redirect: "manual" }); | |
| 146 | expect(res.status).toBe(303); | |
| 147 | ||
| 148 | const after = read(); | |
| 149 | const now = (id: number) => after.deadlines.find((d) => d.id === id)!; | |
| 150 | expect(now(done.id).due).toBe(done.due); | |
| 151 | expect(now(waived.id).due).toBe(waived.due); | |
| 152 | expect(now(done.id).moves).toHaveLength(0); | |
| 153 | expect(now(waived.id).moves).toHaveLength(0); | |
| 154 | expect(now(draftOne.id).due).not.toBe(draftOne.due); | |
| 155 | const draftAfter = drafts(after, agent).find((d) => d.deadline_id === draftOne.id && d.moment === "before" && d.party_role === "buyer")!; | |
| 156 | expect(draftAfter.due).toBe(now(draftOne.id).due); | |
| 157 | expect(draftAfter.body).not.toBe(draftBefore.body); | |
| 158 | expect(after.sent.find((s) => s.deadline_id === sentOne.id)!.body).toBe(wordsSent); | |
| 159 | } finally { | |
| 160 | srv.stop(true); | |
| 161 | } | |
| 162 | }); |
src/shift.ts added +48
| @@ -0,0 +1,48 @@ | ||
| 1 | /** | |
| 2 | * "Closing moved": what a new closing date does to the dates that hang on it. | |
| 3 | * Pure: a deal and a date in, the proposed moves out. Nothing is written | |
| 4 | * here; confirming hands the chosen moves to `amendDeal`, which moves them | |
| 5 | * all in one transaction with one reason, exactly as an amendment does. | |
| 6 | */ | |
| 7 | ||
| 8 | import { addDays, daysBetween, isDate, nextBusinessDay, previousBusinessDay } from "./dates.ts"; | |
| 9 | import { isOpen } from "./deadlines.ts"; | |
| 10 | import type { DealView } from "./deals.ts"; | |
| 11 | ||
| 12 | export interface ShiftRow { | |
| 13 | readonly id: number; | |
| 14 | readonly label: string; | |
| 15 | readonly from: string; | |
| 16 | readonly to: string; | |
| 17 | } | |
| 18 | ||
| 19 | /** A shift that can go ahead: the days the closing moves and the dates that move with it. */ | |
| 20 | export interface ShiftOk { | |
| 21 | readonly ok: true; | |
| 22 | /** The new closing date, as validated. */ | |
| 23 | readonly closing: string; | |
| 24 | readonly days: number; | |
| 25 | readonly rows: readonly ShiftRow[]; | |
| 26 | } | |
| 27 | ||
| 28 | export type Shift = ShiftOk | { readonly ok: false; readonly message: string }; | |
| 29 | ||
| 30 | /** | |
| 31 | * Every open, non-custom date moves by the days the closing moves; one that | |
| 32 | * lands on a weekend or holiday goes to the next business day (the previous | |
| 33 | * one when the closing moved earlier). The closing itself takes the new date | |
| 34 | * as given. A date that would not change is left out. | |
| 35 | */ | |
| 36 | export function shiftPlan(deal: DealView, closing: unknown): Shift { | |
| 37 | if (!isDate(closing)) return { ok: false, message: "The new closing date is a date (YYYY-MM-DD)." }; | |
| 38 | const current = deal.deadlines.find((d) => d.kind === "closing"); | |
| 39 | if (current === undefined || !isOpen(current)) return { ok: false, message: "The closing is already settled, so there is nothing to shift." }; | |
| 40 | const days = daysBetween(current.due, closing); | |
| 41 | if (days === 0) return { ok: false, message: "That is already the closing date." }; | |
| 42 | const settle = (date: string): string => (days > 0 ? nextBusinessDay(date) : previousBusinessDay(date)); | |
| 43 | const rows = deal.deadlines | |
| 44 | .filter((d) => isOpen(d) && d.kind !== "custom") | |
| 45 | .map((d) => ({ id: d.id, label: d.label, from: d.due, to: d.kind === "closing" ? closing : settle(addDays(d.due, days)) })) | |
| 46 | .filter((r) => r.to !== r.from); | |
| 47 | return { ok: true, closing, days, rows }; | |
| 48 | } |
src/terminal.test.ts +31 −1
| @@ -2,6 +2,7 @@ | ||
| 2 | 2 | * The terminal is a second door: each key, pressed against a real |
| 3 | 3 | * database, does what the page's button does, through the same action. |
| 4 | 4 | */ |
| 5 | import { shiftPlan } from "./shift.ts"; | |
| 5 | 6 | import { expect, test } from "bun:test"; |
| 6 | 7 | import { mkdtempSync } from "node:fs"; |
| 7 | 8 | import { tmpdir } from "node:os"; |
| @@ -1062,6 +1063,35 @@ test("G lists the ten tour steps from src/tour.ts, a guest is refused, ", () => | ||
| 1062 | 1063 | expect(lines(guest).join("\n")).not.toContain("10. Set up your Profile"); |
| 1063 | 1064 | }); |
| 1064 | 1065 | |
| 1066 | test("V previews the closing shift, keeps the dates numbered away, and moves the rest with one reason", () => { | |
| 1067 | const own = createDeal(db, alice, { openAnyway: true, address: "3 Shift Ct", buyer: "Bea Buyer", seller: "Sid Seller", contractDate: today(DEFAULT_ZONE), closingDate: addDays(today(DEFAULT_ZONE), 60) }); | |
| 1068 | if (!own.ok) throw new Error(own.message); | |
| 1069 | const read = () => { | |
| 1070 | const got = getDeal(db, alice, { id: own.value.id }); | |
| 1071 | if (!got.ok) throw new Error(got.message); | |
| 1072 | return got.value; | |
| 1073 | }; | |
| 1074 | const t = terminal(db, alice, context); | |
| 1075 | goTo(t, "Closing — 3 Shift Ct"); | |
| 1076 | t.press("o"); | |
| 1077 | const before = read(); | |
| 1078 | const closing = addDays(before.deadlines.find((d) => d.kind === "closing")!.due, 7); | |
| 1079 | press(t, "V", ...closing, "\r", ..."Lender needs a week", "\r"); | |
| 1080 | expect(lines(t).join("\n")).toContain("Nothing has changed yet"); | |
| 1081 | expect(read().deadlines.map((d) => d.due)).toEqual(before.deadlines.map((d) => d.due)); | |
| 1082 | press(t, "1", "\r"); | |
| 1083 | const after = read(); | |
| 1084 | expect(after.deadlines.filter((d) => d.moves.length > 0).length).toBeGreaterThan(0); | |
| 1085 | expect(after.deadlines.find((d) => d.kind === "closing")!.moves[0]!.reason).toBe("Lender needs a week"); | |
| 1086 | const plan = shiftPlan(before, closing); | |
| 1087 | if (!plan.ok) throw new Error(plan.message); | |
| 1088 | expect(after.deadlines.filter((d) => d.moves.length > 0)).toHaveLength(plan.rows.length - 1); | |
| 1089 | expect(after.deadlines.find((d) => d.id === plan.rows[0]!.id)!.moves).toHaveLength(0); | |
| 1090 | // A bad date is refused before any preview. | |
| 1091 | press(t, "V", ..."soon", "\r", ..."x", "\r"); | |
| 1092 | expect(lines(t).join("\n")).toContain("is a date"); | |
| 1093 | }); | |
| 1094 | ||
| 1065 | 1095 | test("p and e set how a party likes to be reached, an unknown value is refused, and P and the party list show it", () => { |
| 1066 | 1096 | const made2 = createDeal(db, alice, { openAnyway: true, address: "31 Reach Way", buyer: "Rae Buyer", seller: "Sid Seller", contractDate: today(DEFAULT_ZONE), closingDate: addDays(today(DEFAULT_ZONE), 45) }); |
| 1067 | 1097 | if (!made2.ok) throw new Error(made2.message); |
| @@ -1097,4 +1127,4 @@ test("p and e set how a party likes to be reached, an unknown value is refused, | ||
| 1097 | 1127 | for (let i = 0; i < 20 && !under(back).includes("Tex Lender"); i++) back.press("j"); |
| 1098 | 1128 | press(back, "e", "Tex Lender\r", "[email protected]\r", "\r", "\r"); |
| 1099 | 1129 | expect(read().parties.find((p) => p.id === tex.id)!.reach).toBe(""); |
| 1100 | }, 20_000); | |
| 1130 | }, 20000); |
src/terminal.ts +33 −4
| @@ -12,7 +12,7 @@ | ||
| 12 | 12 | * file a deal's broker file: figures, parties, every date and message (f on a deal) |
| 13 | 13 | * activity who approved, declined or changed what, newest first (L, admins); m only messages, o older, y newer |
| 14 | 14 | * profile your name, brokerage, phone, email, own line and time zone (I); e edits them, U goes back to the defaults |
| 15 | * deal one deal's dates and parties; a adds a date, p a party, A corrects the address, C sets the closing time and place, M amends the contract, S drafts a status update for the party under the cursor, D removes it, F marks it fell through (or brings it back), Y changes the side (buyer, listing or both) | |
| 15 | * deal one deal's dates and parties; a adds a date, p a party, A corrects the address, C sets the closing time and place, M amends the contract, V moves the closing and shifts the dates that hang on it (previewed first), S drafts a status update for the party under the cursor, D removes it, F marks it fell through (or brings it back), Y changes the side (buyer, listing or both) | |
| 16 | 16 | * |
| 17 | 17 | * On a date (dates or deal view): x marks it done, W waives it (the buyer gave it up), u reopens it or un-waives it, m moves it |
| 18 | 18 | * (a new date, then the reason), N moves one flagged "not a business day" to the next business day, l sets how many days before it reminds, @ sets an inspection's or walkthrough's time and place. o or enter opens its deal; n opens a new |
| @@ -23,6 +23,7 @@ | ||
| 23 | 23 | */ |
| 24 | 24 | |
| 25 | 25 | import type { Database } from "bun:sqlite"; |
| 26 | import { shiftPlan } from "./shift.ts"; | |
| 26 | 27 | import { bookLine, tally } from "./book.ts"; |
| 27 | 28 | import { appointmentLine, isAppointment } from "./appointment.ts"; |
| 28 | 29 | import { closingLine } from "./closing.ts"; |
| @@ -51,6 +52,7 @@ import { | ||
| 51 | 52 | plannedDates, |
| 52 | 53 | type NewDeal, |
| 53 | 54 | amendDeal, |
| 55 | line, | |
| 54 | 56 | moveDeadline, |
| 55 | 57 | withConflicts, |
| 56 | 58 | removeDeadline, |
| @@ -115,6 +117,8 @@ interface Prompt { | ||
| 115 | 117 | /** Longest an answer may be; 200 unless the step asks for more. */ |
| 116 | 118 | readonly max?: number; |
| 117 | 119 | readonly finish: (answers: readonly string[]) => string; |
| 120 | /** Lines shown above the question, such as a preview. */ | |
| 121 | readonly above?: readonly string[]; | |
| 118 | 122 | } |
| 119 | 123 | |
| 120 | 124 | export interface Terminal { |
| @@ -274,7 +278,7 @@ export function terminal( | ||
| 274 | 278 | |
| 275 | 279 | function onDeal(key: string): boolean { |
| 276 | 280 | const deal = openDeal(); |
| 277 | if (deal === undefined || !["a", "p", "D", "e", "X", "A", "C", "M", "S", "F", "Y"].includes(key)) return false; | |
| 281 | if (deal === undefined || !["a", "p", "D", "e", "X", "A", "C", "M", "V", "S", "F", "Y"].includes(key)) return false; | |
| 278 | 282 | const party = deal.parties[cursor - deal.deadlines.length]; |
| 279 | 283 | const date = deal.deadlines[cursor]; |
| 280 | 284 | if (key === "S") { |
| @@ -329,6 +333,31 @@ export function terminal( | ||
| 329 | 333 | return report(amendDeal(db, me, { dealId: deal.id, dates, reason: answers[open.length] }), () => withConflicts(db, me, deal.id, `Amended the contract: ${count} date${count === 1 ? "" : "s"} considered.`)); |
| 330 | 334 | }); |
| 331 | 335 | } |
| 336 | if (key === "V") | |
| 337 | ask(deal, ["New closing date (YYYY-MM-DD)", "Why the closing moved"], ([closing, reason]) => { | |
| 338 | const plan = shiftPlan(deal, closing); | |
| 339 | if (!plan.ok) return plan.message; | |
| 340 | if (line(reason, 1, REASON_MAX) === undefined) return `Say why the closing moved (up to ${REASON_MAX} characters).`; | |
| 341 | prompt = { | |
| 342 | steps: ["Type yes to move them all, numbers to keep some where they are (2 4), anything else cancels"], | |
| 343 | answers: [], | |
| 344 | text: "", | |
| 345 | above: [ | |
| 346 | `Closing moves ${Math.abs(plan.days)} day${Math.abs(plan.days) === 1 ? "" : "s"} ${plan.days > 0 ? "later" : "earlier"}. Nothing has changed yet:`, | |
| 347 | ...plan.rows.map((r, i) => ` ${i + 1}. ${r.label}: ${r.from} -> ${r.to}`), | |
| 348 | ], | |
| 349 | finish: ([answer]) => { | |
| 350 | const typed = (answer ?? "").trim(); | |
| 351 | const keep = typed === "yes" ? new Set<number>() : new Set(typed.split(/[\s,]+/).map(Number)); | |
| 352 | if (typed !== "yes" && (typed === "" || [...keep].some((n) => !Number.isInteger(n) || n < 1 || n > plan.rows.length))) return "Cancelled: nothing moved."; | |
| 353 | const dates = Object.fromEntries(plan.rows.flatMap((r, i) => (keep.has(i + 1) ? [] : [[String(r.id), r.to]]))); | |
| 354 | const n = Object.keys(dates).length; | |
| 355 | if (n === 0) return "Nothing left to move."; | |
| 356 | return report(amendDeal(db, me, { dealId: deal.id, dates, reason }), () => withConflicts(db, me, deal.id, `Closing moved: ${n} date${n === 1 ? "" : "s"} shifted.`)); | |
| 357 | }, | |
| 358 | }; | |
| 359 | return ""; | |
| 360 | }, REASON_MAX); | |
| 332 | 361 | if (key === "e") { |
| 333 | 362 | if (party === undefined) said = "Move to a party to edit it."; |
| 334 | 363 | else |
| @@ -967,7 +996,7 @@ export function terminal( | ||
| 967 | 996 | } |
| 968 | 997 | lines.push(""); |
| 969 | 998 | if (prompt !== undefined) |
| 970 | lines.push(`${prompt.steps[prompt.answers.length]}: ${prompt.text}${esc}7m ${esc}0m`, dim("enter to go on · esc to cancel")); | |
| 999 | lines.push(...(prompt.above ?? []), `${prompt.steps[prompt.answers.length]}: ${prompt.text}${esc}7m ${esc}0m`, dim("enter to go on · esc to cancel")); | |
| 971 | 1000 | else { |
| 972 | 1001 | if (said !== "") lines.push(...said.split("\n")); |
| 973 | 1002 | const keys = |
| @@ -990,7 +1019,7 @@ export function terminal( | ||
| 990 | 1019 | : view === "timeline" || view === "file" |
| 991 | 1020 | ? "b back to the deal · d dates · q quit" |
| 992 | 1021 | : view === "deal" |
| 993 | ? "j/k move · t client timeline · f broker file · s messages · x done · W waive · u reopen/un-waive · m move · N next business day · l remind lead · T note · @ time and place (inspection, walkthrough) · a add date · p add party · e edit party · A edit address · C closing time and place · M amend the contract · S status update to the party · X remove date/party · D remove deal · b back · q quit" | |
| 1022 | ? "j/k move · t client timeline · f broker file · s messages · x done · W waive · u reopen/un-waive · m move · N next business day · l remind lead · T note · @ time and place (inspection, walkthrough) · a add date · p add party · e edit party · A edit address · C closing time and place · M amend the contract · V closing moved · S status update to the party · X remove date/party · D remove deal · b back · q quit" | |
| 994 | 1023 | : `j/k move · x done · W waive · u reopen · m move · N next business day · l lead · T note · @ time and place · o open deal · n new deal · c check dates · / find · R restore samples · O bring deals back · h ${showDone ? "hide" : "show"} done · w this week · r reminders · P people · I profile · E export · L activity · q quit`; |
| 995 | 1024 | lines.push(dim(me.role === "anon" ? `${keys} · sign in with a key the forge knows to change anything` : keys)); |
| 996 | 1025 | } |
new above
offline
This renders an <h1> inside the body while layout() is already given heading "Closing moved". Check that the page does not end up with two h1s. Also give the inline
plantype a name (export Shift's ok variant) instead of repeating it.