Public: anyone can read this; the crew can change it.

An open deal nothing has touched in 14 days says so on the clock #324

Closed opened this
Closed without merging

mason/deal-clock:mason/310-an-open-deal-nothing-has-touched-in-14-d → main 17 commits fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
20 files changed +280 −28
README.md +6 −2
@@ -39,8 +39,12 @@ with a link to the page it names. In the terminal press `G` for the same steps.
3939 adds up your book, like "4 open · $1,693,400 under contract · 2 close in
4040 November · 3 closed this year, $1,375,000 · $45,328 commission pending ·
4141 $37,395 earned this year" (the November count follows the month you open it
42 in). Below the clock, **This week** lists every open date that is overdue or
43 due in the next seven days, with who owes it.
42 in). A lane that says **Quiet 16 days** beside its address (2210 Juniper
43 Court) is an open deal nothing has touched for two weeks and with no date
44 due in the next three; the deal page says when it was last touched, and the
45 terminal shows the same label on that deal's rows and the same line on its
46 deal view. Below the clock, **This week** lists every open date that is
47 overdue or due in the next seven days, with who owes it.
4448 3. **Open a deal.** Tap the red inspection marker on 77 Harbor View Dr. A
4549 sheet opens with the date, who owes it (the buyer), what is owed, and the
4650 reminders for each party. Tap the address to open the deal's page: every
docs/TECHNICAL.md +1
@@ -285,6 +285,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
285285 | `src/money.ts` | the sale price and earnest amount in whole dollars: `amount` validates input, `dollars` formats it one way; called by deals, book, reminders, MCP, routes and the pages |
286286 | `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 (the `voice`, warm or brief, is one of its fields and the drafting in `src/reminders.ts` reads it and the two voices' words live in `src/wording.ts`; `src/voice.ts` renders the Profile page's sample of each); called by deals, MCP, routes, sample, the terminal and the Profile page |
287287 | `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 |
288 | `src/quiet.ts` | which open deals have gone quiet: a deal and a day in, days since the last touch out, or null when closed, recently touched, or a date is due within 3 days; the last touch (`DealView.touched`) is derived in `src/deals.ts` from dates done, waived or moved, messages sent or declined, and the audit trail, so there is no migration; called by `src/pages/quiet.ts` for the clock lane badge and the deal page line |
288289 | `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 |
289290 | `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` |
290291 | `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 |
docs/design/areas/clock.md +2
@@ -7,3 +7,5 @@ New classes:
77 - `.progress` — the paragraph that holds both at the top of a deal's page.
88
99 - `.closing-clash` — one amber line (`--color-soon`) under a closing on This week, the print sheet and the deal page: "Also closing at 2:30 pm: 2210 Juniper Court", the address linked to its deal.
10
11 - `.quiet-badge` — the word pill "Quiet 16 days" (amber border and text, `--color-soon`) beside an open deal's address on its lane and above its dates on its page: shown when nothing has touched the deal for 14 days and no date is due within 3. A word, so colour is never the only signal.
src/closing-window.test.ts +4 −4
@@ -88,13 +88,13 @@ test("the sample deals land in the buckets their closing days say", () => {
8888 const held = (n: number) => onBusinessDay(addDays(today, n), today, true);
8989 const at4 = held(2);
9090 const at33 = held(33);
91 const at40 = held(40);
92 // The samples close in 2, 16, 33 and 40 days; no month ends more than 30 days out.
93 expect(open.map((d) => d.closing).sort()).toEqual([at4, held(16), at33, at40].sort());
91 const at60 = held(60);
92 // The samples close in 2, 16, 33 and 60 days; no month ends more than 30 days out.
93 expect(open.map((d) => d.closing).sort()).toEqual([at4, held(16), at33, at60].sort());
9494 expect(days("week")).toEqual([at4]);
9595 expect(days("month")).toContain(at4);
9696 expect(days("month")).not.toContain(at33);
97 expect(days("later")).toEqual(expect.arrayContaining([at33, at40]));
97 expect(days("later")).toEqual(expect.arrayContaining([at33, at60]));
9898 expect(days("later")).not.toContain(at4);
9999 expect(days("")).toHaveLength(4);
100100 });
src/db.ts +2
@@ -596,6 +596,8 @@ export const migrations: string[] = [
596596 at TEXT NOT NULL,
597597 since TEXT
598598 )`,
599 // Reading when a deal was last touched asks the trail by target (src/quiet.ts); without this every read of the clock scans the whole trail.
600 `CREATE INDEX IF NOT EXISTS audit_target ON audit (resource, target, at)`,
599601 ];
600602
601603 export function openDb(dir = dataDir): Database {
src/dealprint.test.ts +1 −1
@@ -4,7 +4,7 @@ import { join } from "node:path";
44 import { dealPage } from "./pages/deal.ts";
55 import type { DealView } from "./deals.ts";
66
7 const deal = { id: 1, address: "A", closed: false, note: "", contract_date: "2026-10-01", closing: "2026-11-01", closing_time: null, closing_place: null, business_days: 0, parties: [], deadlines: [], conflicts: [], closingConflicts: [], sent: [] } as unknown as DealView;
7 const deal = { id: 1, address: "A", touched: () => "2026-10-10", closed: false, note: "", contract_date: "2026-10-01", closing: "2026-11-01", closing_time: null, closing_place: null, business_days: 0, parties: [], deadlines: [], conflicts: [], closingConflicts: [], sent: [] } as unknown as DealView;
88
99 test("the deal page marks itself and the two blocks that print", () => {
1010 const html = dealPage({ who: { role: "user", id: "u", name: "Dana" }, on: "2026-10-10", deal });
src/deals.ts +63 −9
@@ -227,6 +227,8 @@ export interface DealView extends Deal {
227227 readonly conflicts: readonly Conflict[];
228228 /** Other open deals of this agent closing the same day less than two hours from this one (`overlap.ts`). */
229229 readonly closingConflicts: readonly ClosingConflict[];
230 /** The day anything last happened on the deal: opened, a date done, waived or moved, a message sent or declined, or any change the audit trail records (`quiet.ts`). A function, read when asked and remembered: only the clock and the deal page pay for it, and a copy of the view keeps it. */
231 readonly touched: () => string;
230232 }
231233
232234 export type Moment = "before" | "day_of" | "late" | "moved" | "status" | "checkin" | "anniversary";
@@ -320,10 +322,29 @@ function withHref(m: SentRow, parties: readonly Party[], zone: string): Sent {
320322 const NO_PAPERS: readonly Paper[] = [];
321323
322324 /** One deal assembled from its rows. */
323 function assemble(deal: Deal, rows: Children, zone: string): DealView {
325 function assemble(deal: Deal, rows: Children, zone: string, audited: () => string | null): DealView {
324326 const moves = rows.moves;
327 // Read when asked (the clock and the deal page), not for every list that builds a deal.
328 let touched: string | undefined;
329 const touch = (): string => {
330 if (touched !== undefined) return touched;
331 // Stamps are ISO and in UTC, so the latest sorts last: only that one is turned into a day in the agent's zone.
332 let latest = deal.created_at;
333 const later = (at: string | null): void => {
334 if (at !== null && at > latest) latest = at;
335 };
336 later(audited());
337 for (const d of rows.deadlines) {
338 later(d.done_at);
339 later(d.waived_at);
340 }
341 for (const m of moves) later(m.at);
342 for (const m of rows.sent) later(m.sent_at);
343 for (const m of rows.declined) later(m.at);
344 return (touched = dayOfStamp(zone, latest));
345 };
325346 const papers = rows.papers.length === 0 ? undefined : byDeal(rows.papers, (p) => p.deadline_id);
326 return {
347 const view: DealView = {
327348 ...deal,
328349 zone,
329350 closing:
@@ -346,7 +367,9 @@ function assemble(deal: Deal, rows: Children, zone: string): DealView {
346367 held: rows.held,
347368 conflicts: conflicts(rows.deadlines),
348369 closingConflicts: [],
370 touched: touch,
349371 };
372 return view;
350373 }
351374
352375 /** The deal with the closings of its agent's other deals it overlaps; one read, and only for a timed, open closing. */
@@ -359,11 +382,13 @@ function withClosingConflicts(db: Database, deal: DealView): DealView {
359382
360383 /** The deals with the closing conflicts and the appointment clashes read off the loaded set. */
361384 function withClashes(deals: readonly DealView[], closings: Map<number, ClosingConflict[]>, clashes: Map<number, Clash[]>): DealView[] {
362 return deals.map((d) => ({
363 ...d,
364 closingConflicts: closings.get(d.id) ?? [],
365 deadlines: d.deadlines.map((x) => (clashes.has(x.id) ? { ...x, clashes: clashes.get(x.id)! } : x)),
366 }));
385 return deals.map((d) =>
386 ({
387 ...d,
388 closingConflicts: closings.get(d.id) ?? [],
389 deadlines: d.deadlines.map((x) => (clashes.has(x.id) ? { ...x, clashes: clashes.get(x.id)! } : x)),
390 }),
391 );
367392 }
368393
369394 function view(db: Database, deal: Deal): DealView {
@@ -397,7 +422,19 @@ function view(db: Database, deal: Deal): DealView {
397422 )
398423 .all(deal.id),
399424 held: db.query<Held, [number]>("SELECT * FROM reminder_hold WHERE deal_id = ?1 ORDER BY id").all(deal.id),
400 }, zoneOf(db));
425 }, zoneOf(db), () => lastAudited(db, deal.id));
426 }
427
428 /** The audit rows that are a change made to a deal: allowed, and done. */
429 const AUDITED = "resource = 'deal' AND allowed = 1 AND outcome = 'ok'";
430
431 /** When the audit trail last recorded a change to this deal; null when it recorded none, or cannot be read (a read never fails for the trail). */
432 function lastAudited(db: Database, dealId: number): string | null {
433 try {
434 return db.query<{ at: string | null }, [number]>(`SELECT max(at) AS at FROM audit WHERE ${AUDITED} AND target = ?1`).get(dealId)?.at ?? null;
435 } catch {
436 return null;
437 }
401438 }
402439
403440 /** Rows grouped by the deal they hang off, in the order read. */
@@ -460,6 +497,23 @@ function viewMany(db: Database, where: string, params: string[]): DealView[] {
460497 (r) => r.deal_id,
461498 );
462499 const zone = zoneOf(db);
500 // One query for the whole list, made the first time any deal's last touch is read.
501 let audits: Map<number, string> | undefined;
502 const audited = (id: number): string | null => {
503 audits ??= (() => {
504 try {
505 return new Map(
506 db
507 .query<{ target: string; at: string }, string[]>(`SELECT target, max(at) AS at FROM audit WHERE ${AUDITED} AND target IN (SELECT CAST(id AS TEXT) FROM deal${where}) GROUP BY target`)
508 .all(...params)
509 .map((r) => [Number(r.target), r.at] as const),
510 );
511 } catch {
512 return new Map<number, string>();
513 }
514 })();
515 return audits.get(id) ?? null;
516 };
463517 return deals.map((deal) =>
464518 assemble(deal, {
465519 deadlines: deadlines.get(deal.id) ?? [],
@@ -469,7 +523,7 @@ function viewMany(db: Database, where: string, params: string[]): DealView[] {
469523 sent: sent.get(deal.id) ?? [],
470524 declined: declined.get(deal.id) ?? [],
471525 held: held.get(deal.id) ?? [],
472 }, zone),
526 }, zone, () => audited(deal.id)),
473527 );
474528 }
475529
src/migrations.pin.json +4 −1
@@ -62,5 +62,8 @@
6262 "8d0b71567ca7fc8e0927bfb1465c84a30b6702a8460145f7640e612b829530f5",
6363 "c82fe9b3aea33d99a2c65ffb4179901d5aecde6b2ef759531b94ff12035a719a",
6464 "d7c7a79baf6dcd397af5c223839a821db0f0b744f6f02358ea4ba38532ae148b",
65 "9d29fe2875d6dff02bf7b79a6afbea525f86bca59756a28378e979916a8cfc27"
65 "9d29fe2875d6dff02bf7b79a6afbea525f86bca59756a28378e979916a8cfc27",
66 "6c8473e560e240dcc74e0e01f198acd6fc1f33a1fc19e80c17fbfae097ee1caf",
67 "115f68369d3d3d0d3dfa78ba9dfc0e83528d2f104c25b3bd8e1d69ee768cba7b",
68 "73ce6502f90acf67bfdb43e658f1ea0fdd15d29ca378c6d470743c571e83bdbc"
6669 ]
src/migrations.test.ts +1 −1
@@ -13,7 +13,7 @@ test("every migration is pinned by position; a pull may only append", () => {
1313 });
1414
1515 test("a database made before the Profile line gets the column and reads the agent", () => {
16 const before = migrations.filter((m) => !/profile ADD COLUMN line|commission_rate|profile ADD COLUMN zone|ADD COLUMN cash|'anniversary'|deadline ADD COLUMN (time|place)|party ADD COLUMN reach|reminder_hold ADD COLUMN back_on|TABLE IF NOT EXISTS paper|deal ADD COLUMN note|deadline_move ADD COLUMN undo|profile ADD COLUMN voice|TABLE IF NOT EXISTS visit/.test(m));
16 const before = migrations.filter((m) => !/profile ADD COLUMN line|commission_rate|profile ADD COLUMN zone|ADD COLUMN cash|'anniversary'|deadline ADD COLUMN (time|place)|party ADD COLUMN reach|reminder_hold ADD COLUMN back_on|TABLE IF NOT EXISTS paper|deal ADD COLUMN note|deadline_move ADD COLUMN undo|profile ADD COLUMN voice|TABLE IF NOT EXISTS visit|INDEX IF NOT EXISTS audit_target/.test(m));
1717 const db = new Database(":memory:");
1818 db.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))");
1919 before.forEach((sql, i) => {
src/pages/clock.ts +2 −1
@@ -29,6 +29,7 @@ import { bookLine, tally } from "../book.ts";
2929 import { closedInLine, daysToClose, paceLine, paceOf } from "../pace.ts";
3030 import { dollars } from "../money.ts";
3131 import { progressLine } from "./progress.ts";
32 import { quietBadge } from "./quiet.ts";
3233
3334 /** The days the clock shows: a week behind today (further, if something open is older), to a few days past the last closing. */
3435 export function span(deals: readonly DealView[], on: string): { start: string; days: number } {
@@ -83,7 +84,7 @@ function drawLane(deal: DealView, start: string, on: string): string {
8384 })
8485 .join("");
8586 return `<li class="lane">
86 <div class="lane-head"><a href="/deals/${deal.id}">${escape(deal.address)}</a> <small class="muted">${dealSideLabel(deal)} · ${deal.conflicts.length === 0 ? "" : `<b class="u-overdue">${deal.conflicts.length} out of order</b> · `}${progressLine(deal, on)}</small></div>
87 <div class="lane-head"><a href="/deals/${deal.id}">${escape(deal.address)}</a> ${quietBadge(deal, on)}<small class="muted">${dealSideLabel(deal)} · ${deal.conflicts.length === 0 ? "" : `<b class="u-overdue">${deal.conflicts.length} out of order</b> · `}${progressLine(deal, on)}</small></div>
8788 <div class="track" style="height:calc(${height} * 3rem)">${markers}</div>
8889 </li>`;
8990 }
src/pages/deal.ts +2
@@ -24,6 +24,7 @@ import { sideOf } from "../side.ts";
2424 import { dealSideLabel, sideLabel, sideSelect } from "./side.ts";
2525 import { papersLine } from "../papers.ts";
2626 import { progressLine } from "./progress.ts";
27 import { touchedNote } from "./quiet.ts";
2728 import { appointmentForm, appointmentText, type AppointmentProblem } from "./appointment.ts";
2829 import { badge, conflictLines, nonBusinessLine, noteLine, owesChangedLine, rollForm, undoMoveForm, draftItem, dueLine, outboxLine, owedBy, releaseForm, sheet, sheetId } from "./sheet.ts";
2930
@@ -398,6 +399,7 @@ export function dealPage(input: {
398399 </section>
399400 <div id="deal" data-live="deal.changed">
400401 <p class="progress">${progressLine(deal, on)}</p>
402 ${touchedNote(deal, on)}
401403 <section class="card print-keep">
402404 <h2>Dates</h2>
403405 ${conflictLines(deal)}
src/pages/quiet.ts added +17
@@ -0,0 +1,17 @@
1 /** The quiet badge on a deal's lane, and the line on its page that says when it was last touched. */
2
3 import { type DealView } from "../deals.ts";
4 import { escape } from "../http.ts";
5 import { quietDays, quietLabel, touchedLine } from "../quiet.ts";
6
7 /** "Quiet 16 days" in a pill when the open deal has gone quiet, then a space; nothing otherwise. */
8 export function quietBadge(deal: DealView, on: string): string {
9 const days = quietDays(deal, on);
10 return days === null ? "" : `<b class="quiet-badge">${escape(quietLabel(days))}</b> `;
11 }
12
13 /** When an open deal was last touched, with the badge if it is quiet; nothing for a closed or fallen-through deal. */
14 export function touchedNote(deal: DealView, on: string): string {
15 if (deal.closed || deal.fell_through) return "";
16 return `<p class="small muted">${quietBadge(deal, on)}${escape(touchedLine(deal.touched(), on))}</p>`;
17 }
src/partycontact.test.ts +1 −1
@@ -15,7 +15,7 @@ const dealHtml = (contact: string) => bare(
1515 dealPage({
1616 who,
1717 on: "2026-10-10",
18 deal: { id: 1, address: "A", note: "", closed: false, contract_date: "2026-10-01", closing: "2026-11-01", closing_time: null, closing_place: null, business_days: 0, parties: [{ id: 1, deal_id: 1, role: "lender", name: "P", contact, email: "", phone: "" }], deadlines: [], conflicts: [], closingConflicts: [], sent: [] } as unknown as DealView,
18 deal: { id: 1, address: "A", touched: () => "2026-10-10", note: "", closed: false, contract_date: "2026-10-01", closing: "2026-11-01", closing_time: null, closing_place: null, business_days: 0, parties: [{ id: 1, deal_id: 1, role: "lender", name: "P", contact, email: "", phone: "" }], deadlines: [], conflicts: [], closingConflicts: [], sent: [] } as unknown as DealView,
1919 }));
2020
2121 for (const [page, render] of [["people", peopleHtml], ["deal", dealHtml]] as const) {
src/quiet.test.ts added +88
@@ -0,0 +1,88 @@
1 /** An open deal nothing has touched in 14 days says so on the clock; a date due soon, or a closed deal, never does. */
2 import { 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 { addDays, DEFAULT_ZONE, today } from "./dates.ts";
7 import { openDb } from "./db.ts";
8 import { createDeal, getDeal, listDeals, markDone, setDealNote } from "./deals.ts";
9 import { clock } from "./pages/clock.ts";
10 import { dealPage } from "./pages/deal.ts";
11 import { quietDays, QUIET_DAYS } from "./quiet.ts";
12 import type { Who } from "./who.ts";
13
14 const on = today(DEFAULT_ZONE);
15 const alice: Who = { role: "user", id: "u1", name: "alice" };
16 const dl = (due: string, open = true) => ({ done_at: open ? null : "x", waived_at: null, due }) as never;
17 const deal = (touched: string, deadlines: unknown[] = [dl(addDays(on, 20))], over: object = {}) => ({ closed: false, fell_through: false, touched: () => touched, deadlines, ...over }) as never;
18
19 test("quiet at 14 days and not at 13", () => {
20 expect(QUIET_DAYS).toBe(14);
21 expect(quietDays(deal(addDays(on, -14)), on)).toBe(14);
22 expect(quietDays(deal(addDays(on, -16)), on)).toBe(16);
23 expect(quietDays(deal(addDays(on, -13)), on)).toBeNull();
24 });
25
26 test("a date due inside 3 days exempts the deal; a date 4 days out, or a done one, does not", () => {
27 const old = addDays(on, -30);
28 expect(quietDays(deal(old, [dl(addDays(on, 3))]), on)).toBeNull();
29 expect(quietDays(deal(old, [dl(on)]), on)).toBeNull();
30 expect(quietDays(deal(old, [dl(addDays(on, 4))]), on)).toBe(30);
31 expect(quietDays(deal(old, [dl(addDays(on, 1), false)]), on)).toBe(30);
32 });
33
34 test("a closed or fallen-through deal is never quiet", () => {
35 const old = addDays(on, -30);
36 expect(quietDays(deal(old, [], { closed: true }), on)).toBeNull();
37 expect(quietDays(deal(old, [], { fell_through: true }), on)).toBeNull();
38 });
39
40 test("the last touch is read from the records: a deal opened long ago goes quiet, a note or a done date wakes it", () => {
41 const db = openDb(mkdtempSync(join(tmpdir(), "app-quiet-")));
42 const made = createDeal(db, alice, { openAnyway: true, address: "5 Quiet Way", buyer: "A Buyer", seller: "A Seller", contractDate: addDays(on, -20), closingDate: addDays(on, 60) });
43 if (!made.ok) throw new Error(made.message);
44 const id = made.value.id;
45 const view = () => { const g = getDeal(db, alice, { id }); if (!g.ok) throw new Error(g.message); return g.value; };
46 expect(view().touched()).toBe(on);
47 const old = `${addDays(on, -16)}T09:00:00.000Z`;
48 db.query("UPDATE deal SET created_at = ?1 WHERE id = ?2").run(old, id);
49 db.query("DELETE FROM audit").run();
50 db.query("UPDATE deadline SET due = ?1 WHERE deal_id = ?2 AND kind <> 'closing'").run(addDays(on, 30), id);
51 expect(view().touched()).toBe(addDays(on, -16));
52 const html = (v = view()) => clock({ who: alice, deals: [v], on });
53 expect(html()).toContain("Quiet 16 days");
54 expect(dealPage({ who: alice, deal: view(), on } as never)).toContain("Last touched 16 days ago");
55 // A private note on the deal is an audited change: the deal is touched today.
56 expect(setDealNote(db, alice, { id, note: "called the lender" }).ok).toBe(true);
57 expect(view().touched()).toBe(on);
58 expect(html()).not.toContain("quiet-badge");
59 // A date marked done is a touch too.
60 db.query("DELETE FROM audit").run();
61 expect(view().touched()).toBe(addDays(on, -16));
62 const first = view().deadlines.find((d) => d.kind === "earnest")!;
63 expect(markDone(db, alice, { id: first.id, done: true }).ok).toBe(true);
64 db.query("DELETE FROM audit").run();
65 expect(view().touched()).toBe(on);
66 });
67
68 test("the sample deals on a fresh boot show one quiet deal on the clock, and none of the closed ones", () => {
69 const db = openDb(mkdtempSync(join(tmpdir(), "app-quiet-sample-")));
70 const l = listDeals(db, alice);
71 if (!l.ok) throw new Error(l.message);
72 const html = clock({ who: alice, deals: l.value, on });
73 expect(html.match(/class="quiet-badge"/g)?.length).toBe(1);
74 expect(html).toMatch(/2210 Juniper Court<\/a> <b class="quiet-badge">Quiet 1[5-7] days<\/b>/);
75 const juniper = l.value.find((d) => d.address === "2210 Juniper Court")!;
76 expect(quietDays(juniper, on)).toBeGreaterThanOrEqual(14);
77 expect(dealPage({ who: alice, deal: juniper, on } as never)).toContain("Last touched 1");
78 const closed = l.value.find((d) => d.closed)!;
79 expect(dealPage({ who: alice, deal: closed, on } as never)).not.toContain("Last touched");
80 });
81
82 test("a copy of a deal view keeps its last touch", () => {
83 const db = openDb(mkdtempSync(join(tmpdir(), "app-quiet-copy-")));
84 const l = listDeals(db, alice);
85 if (!l.ok) throw new Error(l.message);
86 const first = l.value[0]!;
87 expect({ ...first }.touched()).toBe(first.touched());
88 });
src/quiet.ts added +35
@@ -0,0 +1,35 @@
1 /**
2 * Which open deals have gone quiet: nothing has touched them for a while and
3 * no date is about to force the matter. Plain data in, plain data out; the
4 * clock, the deal page and the terminal read it from here.
5 */
6
7 import { daysBetween } from "./dates.ts";
8 import { isOpen } from "./deadlines.ts";
9 import type { DealView } from "./deals.ts";
10
11 /** Whole days without a touch after which an open deal is quiet. */
12 export const QUIET_DAYS = 14;
13 /** A deal with an open date due today or within this many days is never quiet: the date is the nudge. An overdue date is not an exemption: the clock already flags it, and a deal nobody has touched since is the one a client will ask about. */
14 export const SOON_DAYS = 3;
15
16 /** How many days an open deal has been quiet for, or null when it is not quiet (closed, fell through, recently touched, or a date is due soon). */
17 export function quietDays(
18 deal: Pick<DealView, "closed" | "fell_through" | "touched" | "deadlines">,
19 on: string,
20 ): number | null {
21 if (deal.closed || deal.fell_through) return null;
22 const days = daysBetween(deal.touched(), on);
23 if (days < QUIET_DAYS) return null;
24 if (deal.deadlines.some((d) => isOpen(d) && daysBetween(on, d.due) >= 0 && daysBetween(on, d.due) <= SOON_DAYS)) return null;
25 return days;
26 }
27
28 /** "Quiet 16 days": a word, so colour is never the only signal. */
29 export const quietLabel = (days: number): string => `Quiet ${days} days`;
30
31 /** "Last touched 16 days ago", "today" or "yesterday", for the deal page. */
32 export function touchedLine(touched: string, on: string): string {
33 const days = daysBetween(touched, on);
34 return days <= 0 ? "Last touched today" : days === 1 ? "Last touched yesterday" : `Last touched ${days} days ago`;
35 }
src/sample.ts +11 −5
@@ -37,6 +37,8 @@ interface Sample {
3737 /** Closed a year ago to the day, so its home anniversary note waits today; the buyer's email. */
3838 readonly anniversary?: string;
3939 readonly lengths?: Partial<typeof BRIEF_LENGTHS>;
40 /** Days since anything touched the deal: it was opened then and its waived dates stamped then, so it wears the quiet badge on every boot. */
41 readonly quiet?: number;
4042 /** When and where it closes, for the sample that closes this week. */
4143 readonly closingAt?: readonly [string, string];
4244 /** Role, name, email or phone, and a phone besides when the first is an email. */
@@ -138,12 +140,14 @@ const SAMPLES: readonly Sample[] = [
138140 address: "2210 Juniper Court",
139141 buyer: "Dev & Anika Rao",
140142 seller: "Carla Mendes",
141 contract: -3,
142 closing: 40,
143 contract: -17,
144 closing: 60,
143145 price: 389_900,
144146 rate: 250,
145147 earnest: 8_000,
146 lengths: { earnest: 5 },
148 // Nothing is due for a week and nobody has touched it for sixteen days: the quiet deal.
149 quiet: 16,
150 lengths: { earnest: 29, inspection: 33, title: 37, appraisal: 41, financing: 47 },
147151 parties: [
148152 ["lender", "Owen Brandt, First Keel Bank", "[email protected]"],
149153 ["other_agent", "Jordan Ellis, Crestline Homes", "(555) 014-2290"],
@@ -306,7 +310,7 @@ export interface SampleState {
306310 export function isUntouched(state: SampleState, anchor: string): boolean {
307311 const s = SAMPLES.find((x) => x.address === state.sample);
308312 if (s === undefined) return false;
309 const marker = `${addDays(anchor, -1)}${DONE_TIME}`;
313 const marker = `${addDays(anchor, -(s.quiet ?? 1))}${DONE_TIME}`;
310314 const stamped = (d: string | null, i: number) => d === null || (d.endsWith(DONE_TIME) && d.slice(0, 10) < state.dues[i]!) || d === marker;
311315 return (
312316 state.parties === s.parties.length &&
@@ -398,6 +402,7 @@ export function refresh(db: Database, on = today(zoneOf(db))): void {
398402 for (const m of shifted.moves) db.query("UPDATE deadline_move SET from_due = ?1, to_due = ?2 WHERE id = ?3").run(rolled(m.fromDue), rolled(m.toDue), m.id);
399403 // The sample's sent reminder follows its date, so it stays "yesterday's".
400404 const days = `+${daysBetween(anchor, on)} days`;
405 db.query("UPDATE deal SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', created_at, ?1) WHERE id = ?2").run(days, deal.id);
401406 db.query("UPDATE deadline_move SET at = strftime('%Y-%m-%dT%H:%M:%fZ', at, ?1) WHERE deadline_id IN (SELECT id FROM deadline WHERE deal_id = ?2)").run(days, deal.id);
402407 db.query("UPDATE reminder SET due = date(due, ?1), sent_at = strftime('%Y-%m-%dT%H:%M:%fZ', sent_at, ?1) WHERE deal_id = ?2 AND sample = 1").run(days, deal.id);
403408 db.query("UPDATE reminder SET due = (SELECT due FROM deadline WHERE id = reminder.deadline_id) WHERE deal_id = ?1 AND sample = 1 AND moment <> 'checkin'").run(deal.id);
@@ -431,6 +436,7 @@ function writeSamples(db: Database, on: string): void {
431436 commissionRate: s.rate,
432437 side: s.side ?? "buyer",
433438 });
439 if (s.quiet !== undefined) db.query("UPDATE deal SET created_at = ?1 WHERE id = ?2").run(`${addDays(on, -s.quiet)}T09:00:00.000Z`, deal.id);
434440 if (s.closingAt !== undefined)
435441 db.query("UPDATE deal SET closing_time = ?1, closing_place = ?2 WHERE id = ?3").run(s.closingAt[0], s.closingAt[1], deal.id);
436442 for (const kind of APPOINTMENT_KINDS) {
@@ -459,7 +465,7 @@ function writeSamples(db: Database, on: string): void {
459465 else db.query("UPDATE deadline SET done_at = min(date(due, '-1 day'), ?1) || ?2 WHERE deal_id = ?3 AND kind = ?4").run(finished, DONE_TIME, deal.id, kind);
460466 }
461467 for (const kind of s.waived ?? [])
462 db.query("UPDATE deadline SET waived_at = ?1, waived_by = ?2 WHERE deal_id = ?3 AND kind = ?4").run(`${addDays(on, -1)}${DONE_TIME}`, SAMPLE.name, deal.id, kind);
468 db.query("UPDATE deadline SET waived_at = ?1, waived_by = ?2 WHERE deal_id = ?3 AND kind = ?4").run(`${addDays(on, -(s.quiet ?? 1))}${DONE_TIME}`, SAMPLE.name, deal.id, kind);
463469 for (const [kind, note] of s.notes ?? [])
464470 db.query("UPDATE deadline SET note = ?1 WHERE deal_id = ?2 AND kind = ?3").run(note, deal.id, kind);
465471 for (const [kind, name, isIn] of s.papers ?? []) {
src/terminal.test.ts +13
@@ -1337,3 +1337,16 @@ test("the clock says what moved since the last look, drawn once per session, and
13371337 expect(lines(again).join("\n")).toMatch(/Since (earlier today|yesterday): 1 date moved/);
13381338 expect(lines(terminal(db, { role: "anon" }, context)).join("\n")).not.toContain("Since ");
13391339 });
1340
1341 test("the terminal says what the clock says: a quiet deal's rows and its page carry the label and when it was last touched", () => {
1342 const quietDb = openDb(mkdtempSync(join(tmpdir(), "app-terminal-quiet-")));
1343 const t = terminal(quietDb, alice, context);
1344 t.press("d");
1345 const juniper = lines(t).filter((l) => l.includes("2210 Juniper Court"));
1346 expect(juniper.length).toBeGreaterThan(0);
1347 expect(juniper.every((l) => /Quiet 1[5-7] days/.test(l))).toBe(true);
1348 expect(lines(t).filter((l) => l.includes("Alder Lane")).some((l) => l.includes("Quiet"))).toBe(false);
1349 goTo(t, "2210 Juniper Court");
1350 t.press("o");
1351 expect(lines(t).join("\n")).toMatch(/Last touched 1[5-7] days ago {2}Quiet 1[5-7] days/);
1352 });
src/terminal.ts +13 −2
@@ -100,6 +100,7 @@ import { listAudit } from "./actions.ts";
100100 import { clashText, clashesText } from "./overlap.ts";
101101 import { owner, thisWeek } from "./week.ts";
102102 import { PAPERS_MAX, addPaper, openPapersOf, papersLine, removePaper, stillOpenLine, suggestedPaper, tickPaper } from "./papers.ts";
103 import { quietDays, quietLabel, touchedLine } from "./quiet.ts";
103104 import { SIDE_CHOICE, SIDE_LABEL, SIDE_REFUSAL, sideTyped } from "./side.ts";
104105
105106 const esc = "\x1b[";
@@ -873,9 +874,19 @@ export function terminal(
873874 const where = appointmentLine(date) === "" ? "" : dim(` ${appointmentLine(date)}`);
874875 const paper = date.papers.length === 0 ? "" : dim(` ${papersLine(date.papers)}`);
875876 const clash = date.clashes.length === 0 ? "" : tint("overdue", ` ${clashesText(date.clashes)}`);
876 return `${i === cursor ? "▸" : " "} ${when} ${i === cursor ? bold(text) : text}${where}${paper}${clash}${flag}${lead}${moved}${note}`;
877 // The clock's quiet badge, as words: on a deal's rows wherever the row names the deal.
878 const quiet = withDeal ? quietDays(deal, on()) : null;
879 const quietTag = quiet === null ? "" : tint("soon", ` ${quietLabel(quiet)}`);
880 return `${i === cursor ? "▸" : " "} ${when} ${i === cursor ? bold(text) : text}${quietTag}${where}${paper}${clash}${flag}${lead}${moved}${note}`;
877881 };
878882
883 /** The deal page's "Last touched" line, with the quiet label when it applies; nothing for a closed or fallen-through deal. */
884 function touchedRow(deal: DealView, day: string): string[] {
885 if (deal.closed || deal.fell_through) return [];
886 const quiet = quietDays(deal, day);
887 return [dim(touchedLine(deal.touched(), day)) + (quiet === null ? "" : tint("soon", ` ${quietLabel(quiet)}`))];
888 }
889
879890 function screen(): string[] {
880891 const day = on();
881892 const all = deals();
@@ -1051,7 +1062,7 @@ export function terminal(
10511062 const deal = openDeal();
10521063 if (deal === undefined) lines.push(dim("That deal is gone."));
10531064 else {
1054 lines.push(bold(deal.address), ...(deal.fell_through ? [bold(`Fell through on ${spoken(fellOn(deal))}: ${deal.fell_reason ?? ""}`)] : []), ...(deal.closed ? [bold(`Closed on ${spoken(closedOn(deal))}`)] : []), dim(`${SIDE_LABEL[deal.side]}${deal.cash === 1 ? " · Cash" : ""} · under contract ${spoken(deal.contract_date)} · closes ${spoken(deal.closing)}, ${relative(deal.closing, day)}`), ...(deal.closing_time === null && deal.closing_place === null ? [] : [dim(closingLine(deal))]), ...(deal.note === "" ? [] : [dim(`Private note: ${oneLine(deal.note)}`)]), "");
1065 lines.push(bold(deal.address), ...(deal.fell_through ? [bold(`Fell through on ${spoken(fellOn(deal))}: ${deal.fell_reason ?? ""}`)] : []), ...(deal.closed ? [bold(`Closed on ${spoken(closedOn(deal))}`)] : []), dim(`${SIDE_LABEL[deal.side]}${deal.cash === 1 ? " · Cash" : ""} · under contract ${spoken(deal.contract_date)} · closes ${spoken(deal.closing)}, ${relative(deal.closing, day)}`), ...(deal.closing_time === null && deal.closing_place === null ? [] : [dim(closingLine(deal))]), ...(deal.note === "" ? [] : [dim(`Private note: ${oneLine(deal.note)}`)]), ...touchedRow(deal, day), "");
10551066 deal.deadlines.forEach((date, i) => lines.push(dateLine({ deal, date }, i, false)));
10561067 if (deal.closingConflicts.length > 0) lines.push("", tint("overdue", clashText(deal.closingConflicts)));
10571068 if (deal.conflicts.length > 0) lines.push("", ...deal.conflicts.map((c) => tint("overdue", `! ${conflictLine(c)}`)));
src/tour.ts +1 −1
@@ -28,7 +28,7 @@ export const STEPS: readonly Step[] = [
2828 },
2929 {
3030 title: "Read the clock",
31 text: "One lane per open deal. Time runs left to right and today is the dark line down the middle. Every date in a contract is a marker: red is overdue (the inspection on 77 Harbor View Dr), amber is due within three days, teal is coming up, grey is done (the earlier dates on 1418 Alder Lane, which closes in a few days). Under the heading, one line adds up your book, like \"4 open · $1,693,400 under contract · 2 close in November · 3 closed this year, $1,375,000 · $45,328 commission pending · $37,395 earned this year\" (the November count follows the month you open it in). Below the clock, **This week** lists every open date that is overdue or due in the next seven days, with who owes it.",
31 text: "One lane per open deal. Time runs left to right and today is the dark line down the middle. Every date in a contract is a marker: red is overdue (the inspection on 77 Harbor View Dr), amber is due within three days, teal is coming up, grey is done (the earlier dates on 1418 Alder Lane, which closes in a few days). Under the heading, one line adds up your book, like \"4 open · $1,693,400 under contract · 2 close in November · 3 closed this year, $1,375,000 · $45,328 commission pending · $37,395 earned this year\" (the November count follows the month you open it in). A lane that says **Quiet 16 days** beside its address (2210 Juniper Court) is an open deal nothing has touched for two weeks and with no date due in the next three; the deal page says when it was last touched, and the terminal shows the same label on that deal's rows and the same line on its deal view. Below the clock, **This week** lists every open date that is overdue or due in the next seven days, with who owes it.",
3232 go: { label: "the clock", page: "/" },
3333 },
3434 {
static/app.css +13
@@ -1023,6 +1023,7 @@ a.marker {
10231023 white-space: normal;
10241024 }
10251025 .you,
1026 .quiet-badge,
10261027 .side,
10271028 .button {
10281029 white-space: normal;
@@ -1059,6 +1060,18 @@ a.marker {
10591060 max-width: 24rem;
10601061 }
10611062
1063 /* An open deal nothing has touched in two weeks: a word in a pill, never colour alone. */
1064 .quiet-badge {
1065 display: inline-block;
1066 padding: 0 var(--space-2);
1067 border: 1px solid var(--color-soon);
1068 border-radius: var(--radius-pill);
1069 color: var(--color-soon);
1070 font-size: var(--text-sm);
1071 font-weight: 600;
1072 white-space: nowrap;
1073 }
1074
10621075 /* The header's Review link: the count has a slot of its own, so it appearing moves nothing. */
10631076 .review-count {
10641077 display: inline-block;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs