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

Parked opened this
Closed without merging

mason/deal-clock:mason/290-when-a-contract-is-amended-to-a-new-clos → main 5 commits fork 3 threads · 3 unresolved

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
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;
148148 over MCP use `set_appointment`; both show it on the date. A date on a weekend or
149149 federal holiday says so and offers **Move to** the next business day. For an
150150 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
152159 you see. A deal that falls through is marked with **Fell through…** and moves to
153160 the Closed list; **Reopen** brings it back. Closing a deal is marking its
154161 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 +40
@@ -5,9 +5,11 @@
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 { ShiftOk } from "../shift.ts";
1113
1214 /** What the form was last sent with, and what was wrong with it. */
1315 export interface AmendState {
@@ -42,3 +44,41 @@ export function amendForm(deal: DealView, path: string, state?: AmendState): str
4244 </form>
4345 </section>`;
4446 }
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);

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 plan type a name (export Shift's ok variant) instead of repeating it.

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";
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";
@@ -345,7 +345,7 @@ export function dealPage(input: {
345345 )
346346 .join("")}</ul>`;
347347 const forms = may
348 ? `${amendForm(deal, path, input.amend)}<section class="card">
348 ? `${shiftForm(deal, path)}${amendForm(deal, path, input.amend)}<section class="card">
349349 <h2>Add a date</h2>
350350 <p class="muted small">A date the contract adds: an HOA review, a repair deadline, a rate lock.</p>
351351 <form class="stack" method="post" action="${path}/deadlines">
src/routes.ts +30
@@ -54,6 +54,8 @@ import {
5454 getDeal,
5555 withConflicts,
5656 canChange,
57 line,
58 REASON_MAX,
5759 listDeals,
5860 listParties,
5961 markDone,
@@ -111,6 +113,9 @@ import { activityPage } from "./pages/activity.ts";
111113 import { ACTIVITY_PAGE, addresses } from "./activity.ts";
112114 import { weekPage } from "./pages/week.ts";
113115 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";
114119 import { timelinePage } from "./pages/timeline.ts";
115120 import { welcomePage } from "./pages/welcome.ts";
116121 import { failedCheck } from "./health.ts";
@@ -1223,6 +1228,31 @@ export const routes: readonly Route[] = [
12231228 return html(dealPage({ who: c.who, deal: found.value, on: dayOf(agentFor(c)), amend: { values: f, problem } }), STATUS.invalid);
12241229 },
12251230 },
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 f.closing! is a non-null assertion. Compute const n = Object.keys(dates).length once and use the validated closing date from the plan rather than !.

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 },
12261256 {
12271257 method: "POST",
12281258 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 @@
22 * The terminal is a second door: each key, pressed against a real
33 * database, does what the page's button does, through the same action.
44 */
5 import { shiftPlan } from "./shift.ts";
56 import { expect, test } from "bun:test";
67 import { mkdtempSync } from "node:fs";
78 import { tmpdir } from "node:os";
@@ -1062,6 +1063,35 @@ 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 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
10651095 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", () => {
10661096 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) });
10671097 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,
10971127 for (let i = 0; i < 20 && !under(back).includes("Tex Lender"); i++) back.press("j");
10981128 press(back, "e", "Tex Lender\r", "[email protected]\r", "\r", "\r");
10991129 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 @@
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. o or enter opens its deal; n opens a new
@@ -23,6 +23,7 @@
2323 */
2424
2525 import type { Database } from "bun:sqlite";
26 import { shiftPlan } from "./shift.ts";
2627 import { bookLine, tally } from "./book.ts";
2728 import { appointmentLine, isAppointment } from "./appointment.ts";
2829 import { closingLine } from "./closing.ts";
@@ -51,6 +52,7 @@ import {
5152 plannedDates,
5253 type NewDeal,
5354 amendDeal,
55 line,
5456 moveDeadline,
5557 withConflicts,
5658 removeDeadline,
@@ -115,6 +117,8 @@ interface Prompt {
115117 /** Longest an answer may be; 200 unless the step asks for more. */
116118 readonly max?: number;
117119 readonly finish: (answers: readonly string[]) => string;
120 /** Lines shown above the question, such as a preview. */
121 readonly above?: readonly string[];
118122 }
119123
120124 export interface Terminal {
@@ -274,7 +278,7 @@ export function terminal(
274278
275279 function onDeal(key: string): boolean {
276280 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;
278282 const party = deal.parties[cursor - deal.deadlines.length];
279283 const date = deal.deadlines[cursor];
280284 if (key === "S") {
@@ -329,6 +333,31 @@ export function terminal(
329333 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.`));
330334 });
331335 }
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);
332361 if (key === "e") {
333362 if (party === undefined) said = "Move to a party to edit it.";
334363 else
@@ -967,7 +996,7 @@ export function terminal(
967996 }
968997 lines.push("");
969998 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"));
9711000 else {
9721001 if (said !== "") lines.push(...said.split("\n"));
9731002 const keys =
@@ -990,7 +1019,7 @@ export function terminal(
9901019 : view === "timeline" || view === "file"
9911020 ? "b back to the deal · d dates · q quit"
9921021 : 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"
9941023 : `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`;
9951024 lines.push(dim(me.role === "anon" ? `${keys} · sign in with a key the forge knows to change anything` : keys));
9961025 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs