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 #304

Merged opened this

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

Merged

mason/deal-clock:mason/290-when-a-contract-is-amended-to-a-new-clos → main 13 commits fork

  • Merged. mason/290-when-a-contract-is-amended-to-a-new-clos was merged into main as 8515495.

Review

Sign in to review this change.
9 files changed +413 −8
README.md +8 −1
@@ -167,7 +167,14 @@ reminder says "10:00 am at 12 Elm St". In the terminal press `@` on the date;
167167 over MCP use `set_appointment`; both show it on the date. A date on a weekend or
168168 federal holiday says so and offers **Move to** the next business day. For an
169169 addendum that moves several dates, **Amend the contract** takes one reason and
170 sends each party one notice. Every date also takes a short working note only
170 sends each party one notice. When buyer and seller agree to close on another
171 day, **Closing moved** takes the new date and a reason, then lists every open
172 date that would shift by the same days with its old and new value (weekends and
173 holidays roll to a business day; dates you added are left alone). Untick any
174 that should stay; nothing changes until you confirm. In the terminal press `V`
175 on a deal: it asks for the new date and the reason, prints the same list, and
176 takes `yes` to move them all or the numbers of the dates to keep where they are.
177 Every date also takes a short working note only
171178 you see. A deal that falls through is marked with **Fell through…** and moves to
172179 the Closed list; **Reopen** brings it back. Closing a deal is marking its
173180 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.
281281 | `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 |
282282 | `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 |
283283 | `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` |
284285 | `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 |
285286
286287 ## What every app stands on
src/pages/amend.ts +50
@@ -5,9 +5,14 @@
55 * form comes back with what was typed and the field that was wrong.
66 */
77
8 import { spoken } from "../dates.ts";
89 import { isOpen } from "../deadlines.ts";
910 import type { DealView } from "../deals.ts";
1011 import { escape } from "../http.ts";
12 import type { Agent } from "../reminders.ts";
13 import type { ShiftOk } from "../shift.ts";
14 import type { Who } from "../who.ts";
15 import { layout } from "./layout.ts";
1116
1217 /** What the form was last sent with, and what was wrong with it. */
1318 export interface AmendState {
@@ -42,3 +47,48 @@ export function amendForm(deal: DealView, path: string, state?: AmendState): str
4247 </form>
4348 </section>`;
4449 }
50
51 /** The deal page's "Closing moved" form: the new closing date and why. */
52 export function shiftForm(deal: DealView, path: string): string {
53 const closing = deal.deadlines.find((d) => d.kind === "closing");
54 if (closing === undefined || !isOpen(closing)) return "";
55 return `<section class="card" id="shift">
56 <h2>Closing moved</h2>
57 <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>
58 <form class="stack" method="post" action="${path}/shift">
59 <label>New closing date<input class="input" type="date" name="closing" required value="${escape(closing.due)}"></label>
60 <label>Why<input class="input" name="reason" required maxlength="200" placeholder="Closing pushed a week at the lender's request"></label>
61 <button class="button">Preview the shift</button>
62 </form>
63 </section>`;
64 }
65
66 /** The preview: each date's old and new value, ticked to move, and one button that confirms. */
67 export function shiftPreview(deal: DealView, path: string, plan: ShiftOk, reason: string): string {
68 const rows = plan.rows
69 .map(
70 (r) =>
71 `<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>`,
72 )
73 .join("");
74 const n = Math.abs(plan.days);
75 return `<section class="card">
76 <h1>Closing moved</h1>
77 <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>
78 <form class="stack" method="post" action="${path}/shift">
79 <input type="hidden" name="closing" value="${escape(plan.closing)}">
80 <input type="hidden" name="reason" value="${escape(reason)}">
81 <input type="hidden" name="confirm" value="yes">
82 <ul class="stack">${rows}</ul>
83 <button class="button">Move the ticked dates</button>
84 <a class="button button-quiet" href="${path}">Cancel</a>
85 </form>
86 </section>`;
87 }
88
89 /** The preview as a whole page: what the route answers when the shift is not yet confirmed. */
90 export function shiftPage(input: { who: Who; agent: Agent; waiting: number; tour: boolean; deal: DealView; plan: ShiftOk; reason: string }): string {
91 const { who, agent, waiting, tour, deal, plan, reason } = input;
92 const path = `/deals/${deal.id}`;
93 return layout({ who, agent, waiting, tour, path, heading: "Closing moved", body: shiftPreview(deal, path, plan, reason) });
94 }
src/pages/deal.ts +2 −2
@@ -16,7 +16,7 @@ import { closingLine } from "../closing.ts";
1616 import { commission, dollars, percent } from "../money.ts";
1717 import { anniversaryDrafts, checkinDrafts, drafts, pending, sentLabel, statusDrafts, type Agent } from "../reminders.ts";
1818 import type { Who } from "../who.ts";
19 import { amendForm, type AmendState } from "./amend.ts";
19 import { amendForm, shiftForm, type AmendState } from "./amend.ts";
2020 import { partyContact } from "./contact.ts";
2121 import { confirmRemove } from "./confirm.ts";
2222 import { layout } from "./layout.ts";
@@ -346,7 +346,7 @@ export function dealPage(input: {
346346 )
347347 .join("")}</ul>`;
348348 const forms = may
349 ? `${amendForm(deal, path, input.amend)}<section class="card">
349 ? `${shiftForm(deal, path)}${amendForm(deal, path, input.amend)}<section class="card">
350350 <h2>Add a date</h2>
351351 <p class="muted small">A date the contract adds: an HOA review, a repair deadline, a rate lock.</p>
352352 <form class="stack" method="post" action="${path}/deadlines">
src/routes.ts +30
@@ -55,6 +55,8 @@ import {
5555 getDeal,
5656 withConflicts,
5757 canChange,
58 line,
59 REASON_MAX,
5860 listDeals,
5961 listParties,
6062 markDone,
@@ -112,6 +114,9 @@ import { activityPage } from "./pages/activity.ts";
112114 import { ACTIVITY_PAGE, addresses } from "./activity.ts";
113115 import { weekPage } from "./pages/week.ts";
114116 import { filePage } from "./pages/file.ts";
117 import { layout } from "./pages/layout.ts";
118 import { shiftPage } from "./pages/amend.ts";
119 import { shiftPlan } from "./shift.ts";
115120 import { timelinePage } from "./pages/timeline.ts";
116121 import { welcomePage } from "./pages/welcome.ts";
117122 import { failedCheck } from "./health.ts";
@@ -1229,6 +1234,31 @@ export const routes: readonly Route[] = [
12291234 return html(dealPage({ who: c.who, deal: found.value, on: dayOf(agentFor(c)), amend: { values: f, problem } }), STATUS.invalid);
12301235 },
12311236 },
1237 {
1238 method: "POST",
1239 path: "/deals/:id/shift",
1240 handle: async (c) => {
1241 const f = await form(c);
1242 if (f instanceof Response) return f;
1243 const id = Number(c.params.id);
1244 const found = getDeal(c.db, c.who, { id });
1245 if (!found.ok) return back(found.message, `/deals/${id}`);
1246 const refuse = (message: string): Response =>
1247 html(refusedPage({ who: c.who, agent: agentFor(c), path: `/deals/${id}`, heading: "Closing moved", error: "invalid", message }), STATUS.invalid);
1248 if (!canChange(c.who, found.value)) return back("You cannot change this deal.", `/deals/${id}`);
1249 const reason = line(f.reason, 1, REASON_MAX);
1250 if (reason === undefined) return refuse(`Say why the closing moved (up to ${REASON_MAX} characters).`);
1251 const plan = shiftPlan(found.value, f.closing);
1252 if (!plan.ok) return refuse(plan.message);
1253 if (f.confirm !== "yes")
1254 return html(shiftPage({ who: c.who, ...headerFor(c), agent: agentFor(c), deal: found.value, plan, reason }));
1255 const dates = Object.fromEntries(plan.rows.filter((r) => f[`move_${r.id}`] === "yes").map((r) => [String(r.id), r.to]));
1256 const n = Object.keys(dates).length;
1257 if (n === 0) return refuse("Nothing was ticked, so nothing moved.");
1258 const moved = amendDeal(c.db, c.who, { dealId: id, dates, reason });
1259 return back(moved.ok ? withConflicts(c.db, c.who, id, `Closing moved: ${n} date${n === 1 ? "" : "s"} shifted.`) : moved.message, `/deals/${id}`);
1260 },
1261 },
12321262 {
12331263 method: "POST",
12341264 path: "/deals/:id/delete",
src/shift.test.ts added +185
@@ -0,0 +1,185 @@
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 { openDb } from "./db.ts";
8 import { createDeal, getDeal, markDone, sendReminder, waiveDate } from "./deals.ts";
9 import { drafts } from "./reminders.ts";
10 import { serve } from "./server.ts";
11 import { forgetRationing } from "./security.ts";
12 import { shiftPlan } from "./shift.ts";
13 import type { DealView } from "./deals.ts";
14 import { sign, type Who } from "./who.ts";
15
16 const KEY = "a-key-only-the-forge-holds";
17 const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-shift-")), identityKey: KEY });
18 const base = `http://${server.hostname}:${server.port}`;
19 afterAll(() => server.stop(true));
20 beforeEach(forgetRationing);
21 const identity = `u9|nia|user|${Date.now() + 600_000}`;
22 const me = { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
23
24 const open = async (): Promise<DealView> => {
25 const made = await fetch(`${base}/api/deals`, {
26 method: "POST",
27 headers: { ...me, "content-type": "application/json" },
28 body: JSON.stringify({ address: "8 Shift St", open_anyway: true, buyer: "A", seller: "B", contract_date: "2026-10-01", closing_date: "2026-12-01" }),
29 });
30 return (await made.json()) as DealView;
31 };
32 const read = async (id: number): Promise<DealView> => (await (await fetch(`${base}/api/deals/${id}`, { headers: me })).json()) as DealView;
33 const post = (id: number, fields: Record<string, string>, headers: Record<string, string> = {}) => {
34 const body = new FormData();
35 for (const [k, v] of Object.entries(fields)) body.set(k, v);
36 return fetch(`${base}/deals/${id}/shift`, { method: "POST", body, headers: { ...me, ...headers }, redirect: "manual" });
37 };
38 const kind = (d: DealView, k: string) => d.deadlines.find((x) => x.kind === k)!;
39
40 test("the plan moves each open date by the same days and says the closing's new date as given", async () => {
41 const deal = await open();
42 const plan = shiftPlan(deal, "2026-12-08");
43 expect(plan.ok).toBe(true);
44 if (!plan.ok) return;
45 expect(plan.days).toBe(7);
46 expect(plan.rows.find((r) => r.id === kind(deal, "closing").id)!.to).toBe("2026-12-08");
47 for (const r of plan.rows) if (r.id !== kind(deal, "closing").id) expect(isBusinessDay(r.to)).toBe(true);
48 });
49
50 /** A deal whose dates are known: the closing on Tue 2026-12-01 and an inspection on Wed 2026-12-02. */
51 const fixed = async (): Promise<DealView> => {
52 const deal = await open();
53 const set = (k: string, due: string) => ({ ...kind(deal, k), due });
54 return { ...deal, deadlines: [set("closing", "2026-12-01"), set("inspection", "2026-12-02")] } as DealView;
55 };
56
57 test("a date that lands on a weekend goes to the next business day when the closing moves later, the previous one when earlier", async () => {
58 const deal = await fixed();
59 const inspection = kind(deal, "inspection");
60 // +3 days: Wed 12-02 -> Sat 12-05 -> Mon 12-07.
61 const later = shiftPlan(deal, "2026-12-04");
62 if (!later.ok) throw new Error(later.message);
63 expect(later.rows.find((r) => r.id === inspection.id)!.to).toBe("2026-12-07");
64 // -3 days: Wed 12-02 -> Sun 11-29 -> Fri 11-27.
65 const earlier = shiftPlan(deal, "2026-11-28");
66 if (!earlier.ok) throw new Error(earlier.message);
67 expect(earlier.days).toBe(-3);
68 expect(earlier.rows.find((r) => r.id === inspection.id)!.to).toBe("2026-11-27");
69 // The closing itself keeps a weekend date when that is what was agreed.
70 expect(earlier.rows.find((r) => r.id === kind(deal, "closing").id)!.to).toBe("2026-11-28");
71 });
72
73 test("the plan leaves out done, waived and custom dates and refuses bad input", async () => {
74 const deal = await open();
75 const inspection = kind(deal, "inspection");
76 const withSettled = {
77 ...deal,
78 deadlines: [
79 ...deal.deadlines.map((d) => (d.id === inspection.id ? { ...d, done_at: "2026-10-05" } : d)),
80 { ...inspection, id: 9999, kind: "custom" as const, label: "HOA review" },
81 ],
82 } as DealView;
83 const plan = shiftPlan(withSettled, "2026-12-08");
84 expect(plan.ok && plan.rows.some((r) => r.id === inspection.id || r.id === 9999)).toBe(false);
85 expect(shiftPlan(deal, "soon").ok).toBe(false);
86 expect(shiftPlan(deal, "2026-12-01").ok).toBe(false);
87 });
88
89 test("the preview changes nothing, and confirming moves the ticked dates with the reason", async () => {
90 const deal = await open();
91 const page = await post(deal.id, { closing: "2026-12-08", reason: "Lender needs a week" });
92 expect(page.status).toBe(200);
93 const html = await page.text();
94 expect(html).toContain("Nothing has changed yet");
95 expect(html).toContain(`name="move_${kind(deal, "closing").id}"`);
96 expect((await read(deal.id)).deadlines.map((d) => d.due)).toEqual(deal.deadlines.map((d) => d.due));
97
98 const inspection = kind(deal, "inspection");
99 const plan = shiftPlan(deal, "2026-12-08");
100 if (!plan.ok) throw new Error(plan.message);
101 const fields: Record<string, string> = { closing: "2026-12-08", reason: "Lender needs a week", confirm: "yes" };
102 for (const r of plan.rows) if (r.id !== inspection.id) fields[`move_${r.id}`] = "yes";
103 const done = await post(deal.id, fields);
104 expect(done.status).toBe(303);
105 const after = await read(deal.id);
106 expect(kind(after, "closing").due).toBe("2026-12-08");
107 expect(kind(after, "closing").moves[0]).toMatchObject({ reason: "Lender needs a week", by_name: "nia" });
108 expect(kind(after, "inspection").due).toBe(inspection.due);
109 expect(kind(after, "inspection").moves).toHaveLength(0);
110 const title = plan.rows.find((r) => r.id === kind(deal, "title").id);
111 expect(title).toBeDefined();
112 expect(title!.to).not.toBe(kind(deal, "title").due);
113 expect(kind(after, "title").due).toBe(title!.to);
114 });
115
116 test("a bad date, no reason, nothing ticked and a foreign origin are refused", async () => {
117 const deal = await open();
118 expect((await post(deal.id, { closing: "soon", reason: "x" })).status).toBe(422);
119 expect((await post(deal.id, { closing: "2026-12-08", reason: "" })).status).toBe(422);
120 expect((await post(deal.id, { closing: "2026-12-08", reason: "x", confirm: "yes" })).status).toBe(422);
121 const foreign = await post(deal.id, { closing: "2026-12-08", reason: "x", confirm: "yes", [`move_${kind(deal, "closing").id}`]: "yes" }, { origin: "https://evil.example" });
122 expect(foreign.status).toBeGreaterThanOrEqual(400);
123 expect(kind(await read(deal.id), "closing").due).toBe("2026-12-01");
124 });
125
126 test("the deal page offers Closing moved", async () => {
127 const deal = await open();
128 expect(await (await fetch(`${base}/deals/${deal.id}`, { headers: me })).text()).toContain("Closing moved");
129 });
130
131 // ---- through the route, with reminders ----
132
133 const nia: Who = { role: "user", id: "u9", name: "nia" };
134 const agent = { name: "Dana", brokerage: "Harbor", phone: "555", email: "[email protected]" };
135
136 test("through the route, done and waived dates stay, a drafted reminder follows the move and a sent one keeps its words", async () => {
137 const dir = mkdtempSync(join(tmpdir(), "app-shift-route-"));
138 const srv = serve({ port: 0, dataDir: dir, identityKey: KEY });
139 try {
140 const db = openDb(dir);
141 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" });
142 if (!made.ok) throw new Error(made.message);
143 const read = () => {
144 const got = getDeal(db, nia, { id: made.value.id });
145 if (!got.ok) throw new Error(got.message);
146 return got.value;
147 };
148 const deal = read();
149 const by = (k: string) => deal.deadlines.find((d) => d.kind === k)!;
150 const [done, waived, sentOne, draftOne] = [by("earnest"), by("appraisal"), by("inspection"), by("title")];
151 expect(markDone(db, nia, { id: done.id, done: true }).ok).toBe(true);
152 expect(waiveDate(db, nia, { id: waived.id, waived: true }).ok).toBe(true);
153 const buyer = read().parties.find((p) => p.role === "buyer")!;
154 expect(sendReminder(db, nia, { deadlineId: sentOne.id, partyId: buyer.id, moment: "before" }).ok).toBe(true);
155 const wordsSent = read().sent.find((s) => s.deadline_id === sentOne.id)!.body;
156 const draftBefore = drafts(read(), agent).find((d) => d.deadline_id === draftOne.id && d.moment === "before" && d.party_role === "buyer")!;
157 expect(draftBefore.sent_at).toBeNull();
158
159 const plan = shiftPlan(deal, "2026-12-15");
160 if (!plan.ok) throw new Error(plan.message);
161 const fields: Record<string, string> = { closing: "2026-12-15", reason: "Lender delay", confirm: "yes" };
162 for (const r of plan.rows) fields[`move_${r.id}`] = "yes";
163 // Settled dates are not in the plan; ticking them anyway moves nothing.
164 fields[`move_${done.id}`] = "yes";
165 fields[`move_${waived.id}`] = "yes";
166 const body = new FormData();
167 for (const [k, v] of Object.entries(fields)) body.set(k, v);
168 const res = await fetch(`http://${srv.hostname}:${srv.port}/deals/${made.value.id}/shift`, { method: "POST", body, headers: me, redirect: "manual" });
169 expect(res.status).toBe(303);
170
171 const after = read();
172 const now = (id: number) => after.deadlines.find((d) => d.id === id)!;
173 expect(now(done.id).due).toBe(done.due);
174 expect(now(waived.id).due).toBe(waived.due);
175 expect(now(done.id).moves).toHaveLength(0);
176 expect(now(waived.id).moves).toHaveLength(0);
177 expect(now(draftOne.id).due).not.toBe(draftOne.due);
178 const draftAfter = drafts(after, agent).find((d) => d.deadline_id === draftOne.id && d.moment === "before" && d.party_role === "buyer")!;
179 expect(draftAfter.due).toBe(now(draftOne.id).due);
180 expect(draftAfter.body).not.toBe(draftBefore.body);
181 expect(after.sent.find((s) => s.deadline_id === sentOne.id)!.body).toBe(wordsSent);
182 } finally {
183 srv.stop(true);
184 }
185 });
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 +56 −1
@@ -9,6 +9,7 @@ import { join } from "node:path";
99 import { addDays, spoken, today, DEFAULT_ZONE } from "./dates.ts";
1010 import { canChange, setAppointment, shown, addDeadline, addParty, createDeal, editParty, getDeal, listDeals, markDone, moveDeadline, sendReminder, updateDeal, type DealView } from "./deals.ts";
1111 import { openDb } from "./db.ts";
12 import { shiftPlan } from "./shift.ts";
1213 import { terminal, type Terminal } from "./terminal.ts";
1314 import type { Who } from "./who.ts";
1415
@@ -21,7 +22,7 @@ const under = (t: Terminal): string => lines(t).find((l) => l.startsWith("▸"))
2122 /** Move the cursor to the row that says `text`. */
2223 function goTo(t: Terminal, text: string): void {
2324 t.press("d");
24 for (let i = 0; i < 200 && !under(t).includes(text); i++) t.press("j");
25 for (let i = 0; i < 400 && !under(t).includes(text); i++) t.press("j");
2526 expect(under(t)).toContain(text);
2627 }
2728 const press = (t: Terminal, ...chunks: string[]) => chunks.forEach((c) => t.press(c));
@@ -1062,6 +1063,60 @@ test("G lists the ten tour steps from src/tour.ts, a guest is refused, ", () =>
10621063 expect(lines(guest).join("\n")).not.toContain("10. Set up your Profile");
10631064 });
10641065
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 const plan = shiftPlan(before, closing);
1080 if (!plan.ok) throw new Error(plan.message);
1081 press(t, "V", ...closing, "\r", ..."Lender needs a week", "\r");
1082 expect(lines(t).join("\n")).toContain("Nothing has changed yet");
1083 expect(read().deadlines.map((d) => d.due)).toEqual(before.deadlines.map((d) => d.due));
1084 press(t, "1", "\r");
1085 const after = read();
1086 expect(after.deadlines.find((d) => d.kind === "closing")!.moves[0]!.reason).toBe("Lender needs a week");
1087 expect(after.deadlines.filter((d) => d.moves.length > 0)).toHaveLength(plan.rows.length - 1);
1088 expect(after.deadlines.find((d) => d.id === plan.rows[0]!.id)!.moves).toHaveLength(0);
1089 // A bad date is refused before any preview.
1090 press(t, "V", ..."soon", "\r", ..."x", "\r");
1091 expect(lines(t).join("\n")).toContain("is a date");
1092 }, 20_000);
1093
1094 test("V: yes moves every date in the preview; a number out of range or a word cancels with nothing moved", () => {
1095 const own = createDeal(db, alice, { openAnyway: true, address: "4 Shift Ct", buyer: "Bo Buyer", seller: "Sue Seller", contractDate: today(DEFAULT_ZONE), closingDate: addDays(today(DEFAULT_ZONE), 60) });
1096 if (!own.ok) throw new Error(own.message);
1097 const read = () => {
1098 const got = getDeal(db, alice, { id: own.value.id });
1099 if (!got.ok) throw new Error(got.message);
1100 return got.value;
1101 };
1102 const t = terminal(db, alice, context);
1103 goTo(t, "Closing — 4 Shift Ct");
1104 t.press("o");
1105 const before = read();
1106 const closing = addDays(before.deadlines.find((d) => d.kind === "closing")!.due, 7);
1107 const plan = shiftPlan(before, closing);
1108 if (!plan.ok) throw new Error(plan.message);
1109 const moves = () => read().deadlines.filter((d) => d.moves.length > 0).length;
1110 for (const answer of ["9", "0", "maybe"]) {
1111 press(t, "V", ...closing, "\r", ..."Lender needs a week", "\r", ...answer, "\r");
1112 expect(lines(t).join("\n")).toContain("Cancelled: nothing moved.");
1113 expect(moves()).toBe(0);
1114 }
1115 press(t, "V", ...closing, "\r", ..."Lender needs a week", "\r", ..."yes", "\r");
1116 expect(moves()).toBe(plan.rows.length);
1117 for (const r of plan.rows) expect(read().deadlines.find((d) => d.id === r.id)!.due).toBe(r.to);
1118 }, 20_000);
1119
10651120 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", () => {
10661121 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) });
10671122 if (!made2.ok) throw new Error(made2.message);
src/terminal.ts +33 −4
@@ -12,7 +12,7 @@
1212 * file a deal's broker file: figures, parties, every date and message (f on a deal)
1313 * activity who approved, declined or changed what, newest first (L, admins); m only messages, o older, y newer
1414 * 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)
1616 *
1717 * 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
1818 * (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, i lists its papers and adds, ticks in or out, removes or suggests one (x asks once when papers are open). o or enter opens its deal; n opens a new
@@ -26,6 +26,7 @@ import type { Database } from "bun:sqlite";
2626 import { bookLine, tally } from "./book.ts";
2727 import { appointmentLine, isAppointment } from "./appointment.ts";
2828 import { closingLine } from "./closing.ts";
29 import { shiftPlan } from "./shift.ts";
2930 import { timelineRows } from "./timeline.ts";
3031 import { fileRecord } from "./pages/file.ts";
3132 import { DEFAULT_ZONE, isDate, relative, spoken, today, urgencyOf, type Urgency } from "./dates.ts";
@@ -51,6 +52,7 @@ import {
5152 plannedDates,
5253 type NewDeal,
5354 amendDeal,
55 line,
5456 moveDeadline,
5557 withConflicts,
5658 removeDeadline,
@@ -116,6 +118,8 @@ interface Prompt {
116118 /** Longest an answer may be; 200 unless the step asks for more. */
117119 readonly max?: number;
118120 readonly finish: (answers: readonly string[]) => string;
121 /** Lines shown above the question, such as a preview. */
122 readonly above?: readonly string[];
119123 }
120124
121125 export interface Terminal {
@@ -312,7 +316,7 @@ export function terminal(
312316
313317 function onDeal(key: string): boolean {
314318 const deal = openDeal();
315 if (deal === undefined || !["a", "p", "D", "e", "X", "A", "C", "M", "S", "F", "Y"].includes(key)) return false;
319 if (deal === undefined || !["a", "p", "D", "e", "X", "A", "C", "M", "V", "S", "F", "Y"].includes(key)) return false;
316320 const party = deal.parties[cursor - deal.deadlines.length];
317321 const date = deal.deadlines[cursor];
318322 if (key === "S") {
@@ -367,6 +371,31 @@ export function terminal(
367371 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.`));
368372 });
369373 }
374 if (key === "V")
375 ask(deal, ["New closing date (YYYY-MM-DD)", "Why the closing moved"], ([closing, reason]) => {
376 const plan = shiftPlan(deal, closing);
377 if (!plan.ok) return plan.message;
378 if (line(reason, 1, REASON_MAX) === undefined) return `Say why the closing moved (up to ${REASON_MAX} characters).`;
379 prompt = {
380 steps: ["Type yes to move them all, numbers to keep some where they are (2 4), anything else cancels"],
381 answers: [],
382 text: "",
383 above: [
384 `Closing moves ${Math.abs(plan.days)} day${Math.abs(plan.days) === 1 ? "" : "s"} ${plan.days > 0 ? "later" : "earlier"}. Nothing has changed yet:`,
385 ...plan.rows.map((r, i) => ` ${i + 1}. ${r.label}: ${r.from} -> ${r.to}`),
386 ],
387 finish: ([answer]) => {
388 const typed = (answer ?? "").trim();
389 const keep = typed === "yes" ? new Set<number>() : new Set(typed.split(/[\s,]+/).map(Number));
390 if (typed !== "yes" && (typed === "" || [...keep].some((n) => !Number.isInteger(n) || n < 1 || n > plan.rows.length))) return "Cancelled: nothing moved.";
391 const dates = Object.fromEntries(plan.rows.flatMap((r, i) => (keep.has(i + 1) ? [] : [[String(r.id), r.to]])));
392 const n = Object.keys(dates).length;
393 if (n === 0) return "Nothing left to move.";
394 return report(amendDeal(db, me, { dealId: deal.id, dates, reason }), () => withConflicts(db, me, deal.id, `Closing moved: ${n} date${n === 1 ? "" : "s"} shifted.`));
395 },
396 };
397 return "";
398 }, REASON_MAX);
370399 if (key === "e") {
371400 if (party === undefined) said = "Move to a party to edit it.";
372401 else
@@ -1006,7 +1035,7 @@ export function terminal(
10061035 }
10071036 lines.push("");
10081037 if (prompt !== undefined)
1009 lines.push(`${prompt.steps[prompt.answers.length]}: ${prompt.text}${esc}7m ${esc}0m`, dim("enter to go on · esc to cancel"));
1038 lines.push(...(prompt.above ?? []), `${prompt.steps[prompt.answers.length]}: ${prompt.text}${esc}7m ${esc}0m`, dim("enter to go on · esc to cancel"));
10101039 else {
10111040 if (said !== "") lines.push(...said.split("\n"));
10121041 const keys =
@@ -1029,7 +1058,7 @@ export function terminal(
10291058 : view === "timeline" || view === "file"
10301059 ? "b back to the deal · d dates · q quit"
10311060 : view === "deal"
1032 ? "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"
1061 ? "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"
10331062 : `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`;
10341063 lines.push(dim(me.role === "anon" ? `${keys} · sign in with a key the forge knows to change anything` : keys));
10351064 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs