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

Each date can carry a short paperwork checklist the agent ticks off, and the deal page and client timeline show what is still missing #300

Merged opened this

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

Merged

mason/deal-clock:mason/296-each-date-can-carry-a-short-paperwork-ch → main 6 commits fork 2 threads · 2 unresolved

  • Merged. mason/296-each-date-can-carry-a-short-paperwork-ch was merged into main as 3764f8c.

Review

Sign in to review this change.
24 files changed +724 −14
README.md +19
@@ -106,6 +106,25 @@ all-deals export. In the terminal, **p** (add a party) and **e** (correct one)
106106 ask the same question, and the deal view, **P** and the reminders list show the
107107 answer. The sample parties carry a mix.
108108
109 ## Papers a date needs
110
111 A date is rarely just a date: the inspection needs the signed access form,
112 financing needs the lender's approval letter. Open a date on the deal page
113 and use **Papers** to list up to eight of them (names only, 1 to 80
114 characters; nothing is stored) and **Mark in** as each arrives. A standard
115 date offers one suggestion, such as "Lender's approval letter" for Financing,
116 and adds it only when you press **Add suggested**. The deal page says "2 of 3
117 papers in" under the date; the client timeline lists what is still needed
118 under the date, with papers already in struck through and marked "in". Marking
119 a date done while papers are open asks once ("2 papers still open. Mark done
120 anyway?") and then goes ahead. The all-deals export carries the papers (older
121 files without them still import). Two sample deals carry a few.
122
123 In the terminal, **i** on a date shows its papers ("2 of 3 papers in" also
124 shows beside the date) and asks for one command: `add NAME`, `in N`, `out N`,
125 `remove N`, or `suggest` to take the offered paper. **x** asks once, "2 papers
126 still open. Mark done anyway?", and goes ahead on `yes`.
127
109128 ## Finding a deal
110129
111130 Type part of an address, or of a buyer's, seller's or other party's name,
contract/openapi.json +60
@@ -1176,6 +1176,36 @@
11761176 ],
11771177 "description": "Who recorded the waiver."
11781178 },
1179 "papers": {
1180 "type": "array",
1181 "description": "The paperwork checklist on this date, names only, up to 8, in the order added; in_at is when it was ticked in, null while outstanding.",
1182 "items": {
1183 "type": "object",
1184 "properties": {
1185 "id": {
1186 "type": "integer"
1187 },
1188 "deadline_id": {
1189 "type": "integer"
1190 },
1191 "name": {
1192 "type": "string"
1193 },
1194 "in_at": {
1195 "type": [
1196 "string",
1197 "null"
1198 ]
1199 }
1200 },
1201 "required": [
1202 "id",
1203 "deadline_id",
1204 "name",
1205 "in_at"
1206 ]
1207 }
1208 },
11791209 "time": {
11801210 "type": [
11811211 "string",
@@ -3450,6 +3480,36 @@
34503480 ],
34513481 "description": "Who recorded the waiver."
34523482 },
3483 "papers": {
3484 "type": "array",
3485 "description": "The paperwork checklist on this date, names only, up to 8, in the order added; in_at is when it was ticked in, null while outstanding.",
3486 "items": {
3487 "type": "object",
3488 "properties": {
3489 "id": {
3490 "type": "integer"
3491 },
3492 "deadline_id": {
3493 "type": "integer"
3494 },
3495 "name": {
3496 "type": "string"
3497 },
3498 "in_at": {
3499 "type": [
3500 "string",
3501 "null"
3502 ]
3503 }
3504 },
3505 "required": [
3506 "id",
3507 "deadline_id",
3508 "name",
3509 "in_at"
3510 ]
3511 }
3512 },
34533513 "time": {
34543514 "type": [
34553515 "string",
docs/TECHNICAL.md +4
@@ -401,6 +401,10 @@ columns, same rules, validated by `src/appointment.ts` through `closing.ts`). `s
401401 (any other kind of date is 422 with `field: "time"`). They appear on the deal page, timeline, broker
402402 file, export (`dates[].time`, `dates[].place`), the terminal's date row, MCP `client_timeline` (`when_and_where`), the calendar file (`LOCATION`; `DTSTART`/`DTEND` in UTC, the wall time taken in the Profile zone, unlike the closing's floating time) and
403403 the reminder draft for that date.
404 ### Papers
405
406 `paper` (`id`, `deadline_id` cascade-deleted with its date, `name` 1 to 80, `in_at` nullable) is a date's paperwork checklist, names only. `src/papers.ts` holds the words (`papersLine`, `stillOpenLine`, `suggestedPaper`, one suggestion per standard kind) and three changes, each an `update` of the deal that asks `permit(who, "deal", "update", deal)` through `deadlineFor` and is audited (`paper.add`, `paper.tick`, `paper.remove`): at most 8 per date, a name once per date. Doors: `POST /deadlines/:id/papers` (`name`), `POST /papers/:id/tick` (`in=0` unticks), `POST /papers/:id/remove`; terminal: `i` on a date (one prompt: `add NAME`, `in N`, `out N`, `remove N`, `suggest`) calls the same three functions, and `x` asks once through `openPapersOf` (`src/papers.ts`) before `markDone`, as the route does; there is no API or MCP door yet. Each date in a deal's `deadlines` carries `papers: [{id, deadline_id, name, in_at}]`. `POST /deadlines/:id/done` refuses a date with open papers unless `confirm=yes` (the sheet's "Mark done anyway"); `markDone` itself does not ask, so the API and MCP are unchanged. The export writes `dates[].papers: [{name, in}]` (`in` a moment or null); the import reads the key when present, so files without it still load. No contract cell changed.
407
404408 ### Side
405409
406410 `deal.side` (`buyer`, `listing` or `both`; migration default `buyer`) says which side the agent represents. `src/side.ts` holds the words, `sideOf` (nothing or blank is `buyer`, anything but the three words is refused with 422 and `field: "side"`) and `onSide` (the narrowing: a `both` deal is on each side). It is an `update` of the deal, audited as one: `PATCH /api/deals/:id` with `side`, `POST /deals/:id/side`, MCP `set_side`. `GET /` and `GET /week` take `?side=buyer|listing|all` beside `?q=`; `GET /api/deals` and `list_deals` take `side` too. `GET /reminders/today` does not narrow by side. No new contract cell.
docs/design/areas/papers.md added +8
@@ -0,0 +1,8 @@
1 # Papers
2
3 Classes in a date's sheet (`/deals/:id`, the popover), the checklist of papers a date needs. Names only; no documents.
4
5 - `.papers` — the sheet section; a heading, "2 of 3 papers in", the list, the suggestion and the add form.
6 - `.papers-list` — the list of papers, no markers.
7 - `.paper-row` — one paper: its name (struck through and "in" once ticked, "still open" before) and its Mark in and Remove buttons, wrapping at 360px.
8 - `.papers-line` — the "2 of 3 papers in" line under a date on the deal page.
docs/design/areas/timeline.md +1
@@ -10,3 +10,4 @@ Classes on `/deals/:id/timeline`, the page handed to a client. It prints portrai
1010 - `.timeline-row` — one date; never split across printed pages.
1111 - `.timeline-date`, `.timeline-label`, `.timeline-owner`, `.timeline-status` — its four facts; the status is words first, with a mark.
1212 - `.timeline-todo` — under an open date, one line of what the client does; spans the row and prints with it.
13 - `.timeline-papers` — under a date that has a paperwork checklist: the words "n papers still needed" (or "All papers in") and a list; a paper that is in is struck through and says "in", one outstanding says "still needed". Spans the row and prints with it.
src/activity.ts +3
@@ -27,6 +27,9 @@ const WORDS: Readonly<Record<string, string>> = {
2727 "date.lead": "changed when a date's reminder goes out",
2828 "date.add": "added a date",
2929 "date.remove": "removed a date",
30 "paper.add": "added a paper to a date's checklist",
31 "paper.tick": "ticked a paper in or out",
32 "paper.remove": "removed a paper from a date's checklist",
3033 "deal.amend": "amended the contract dates",
3134 "deal.fell": "marked a deal fell through",
3235 "deal.reopen": "brought a deal that fell through back onto the clock",
src/db.ts +8
@@ -576,6 +576,14 @@ export const migrations: string[] = [
576576 `ALTER TABLE party ADD COLUMN reach TEXT NOT NULL DEFAULT '' CHECK (reach IN ('', 'text', 'email', 'call'))`,
577577 // The day the agent chose for a held reminder to come back; NULL for a plain "tomorrow" hold.
578578 `ALTER TABLE reminder_hold ADD COLUMN back_on TEXT`,
579 // A short paperwork checklist on a date: names only (no documents), ticked in with a moment. Goes with its date.
580 `CREATE TABLE IF NOT EXISTS paper (
581 id INTEGER PRIMARY KEY,
582 deadline_id INTEGER NOT NULL REFERENCES deadline (id) ON DELETE CASCADE,
583 name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 80),
584 in_at TEXT
585 );
586 CREATE INDEX IF NOT EXISTS paper_deadline ON paper (deadline_id)`,
579587 ];
580588
581589 export function openDb(dir = dataDir): Database {
src/deals.ts +18 −2
@@ -42,6 +42,7 @@ import { SEND_SPAN, draftOfMoment, statusDrafts, type Agent, type Draft } from "
4242 import { audited, conflict, fail, ok, refuse, type Result } from "./result.ts";
4343 import { appointmentClashes, closingConflicts, type Clash, type ClosingConflict } from "./overlap.ts";
4444 import type { Who } from "./who.ts";
45 import type { Paper } from "./papers.ts";
4546
4647 export interface Deal {
4748 readonly id: number;
@@ -183,6 +184,8 @@ export interface Deadline {
183184 readonly place: string | null;
184185 }
185186 export interface DeadlineView extends Omit<Deadline, "owes_from" | "owes_by" | "owes_on"> {
187 /** The paperwork checklist on this date, in the order it was added. */
188 readonly papers: readonly Paper[];
186189 /** The day it was marked done, by the app's own calendar (`today()`); null while open or after a reopen. */
187190 readonly done_on: string | null;
188191 /** The day it was waived, in the agent's zone; null while it stands. */
@@ -296,6 +299,7 @@ export const shown = (who: Who, deal: DealView): DealView =>
296299 interface Children {
297300 deadlines: Deadline[];
298301 moves: Move[];
302 papers: Paper[];
299303 parties: Party[];
300304 sent: SentRow[];
301305 declined: Declined[];
@@ -309,9 +313,12 @@ function withHref(m: SentRow, parties: readonly Party[], zone: string): Sent {
309313 return { ...m, sent_on: dayOfStamp(zone, m.sent_at), href: hrefs[0] ?? null, hrefs };
310314 }
311315
316 const NO_PAPERS: readonly Paper[] = [];
317
312318 /** One deal assembled from its rows. */
313319 function assemble(deal: Deal, rows: Children, zone: string): DealView {
314320 const moves = rows.moves;
321 const papers = rows.papers.length === 0 ? undefined : byDeal(rows.papers, (p) => p.deadline_id);
315322 return {
316323 ...deal,
317324 zone,
@@ -326,6 +333,7 @@ function assemble(deal: Deal, rows: Children, zone: string): DealView {
326333 waived_on: d.waived_at === null ? null : dayOfStamp(zone, d.waived_at),
327334 owes_changed: owes_from === null || owes_by === null || owes_on === null ? null : { from: owes_from, by: owes_by, on: owes_on },
328335 moves: moves.filter((m) => m.deadline_id === d.id),
336 papers: papers?.get(d.id) ?? NO_PAPERS,
329337 nonBusinessDay: isOpen(d) && !isBusinessDay(d.due) ? { next: nextBusinessDay(d.due) } : null,
330338 clashes: [],
331339 })),
@@ -366,6 +374,9 @@ function view(db: Database, deal: Deal): DealView {
366374 "SELECT m.* FROM deadline_move m JOIN deadline d ON d.id = m.deadline_id WHERE d.deal_id = ?1 ORDER BY m.id",
367375 )
368376 .all(deal.id),
377 papers: db
378 .query<Paper, [number]>("SELECT p.* FROM paper p JOIN deadline d ON d.id = p.deadline_id WHERE d.deal_id = ?1 ORDER BY p.id")
379 .all(deal.id),
369380 parties: db
370381 .query<Party, [number]>(
371382 "SELECT * FROM party WHERE deal_id = ?1 ORDER BY id",
@@ -422,6 +433,10 @@ function viewMany(db: Database, where: string, params: string[]): DealView[] {
422433 .all(...params),
423434 (r) => dealOf.get(r.deadline_id)!,
424435 );
436 const papers = byDeal(
437 db.query<Paper, string[]>(`SELECT p.* FROM paper p WHERE EXISTS (SELECT 1 FROM deadline d WHERE d.id = p.deadline_id AND d.${of}) ORDER BY p.id`).all(...params),
438 (r) => dealOf.get(r.deadline_id)!,
439 );
425440 const parties = byDeal(
426441 db.query<Party, string[]>(`SELECT * FROM party WHERE ${of} ORDER BY id`).all(...params),
427442 (r) => r.deal_id,
@@ -445,6 +460,7 @@ function viewMany(db: Database, where: string, params: string[]): DealView[] {
445460 assemble(deal, {
446461 deadlines: deadlines.get(deal.id) ?? [],
447462 moves: moves.get(deal.id) ?? [],
463 papers: papers.get(deal.id) ?? [],
448464 parties: parties.get(deal.id) ?? [],
449465 sent: sent.get(deal.id) ?? [],
450466 declined: declined.get(deal.id) ?? [],
@@ -861,7 +877,7 @@ function remove(db: Database, who: Who, input: { id: number }): Result<{ id: num
861877 // ---- a deal's dates, parties and reminders: each an update of the deal ----
862878
863879 /** A change inside deal `dealId`, audited as an update of that deal; `what` names which change (src/pages/activity.ts words it). */
864 function within<T extends { readonly id: number }>(
880 export function within<T extends { readonly id: number }>(
865881 db: Database,
866882 who: Who,
867883 what: string,
@@ -874,7 +890,7 @@ function within<T extends { readonly id: number }>(
874890 }
875891
876892 /** The deadline and its deal, when `who` may change the deal. */
877 function deadlineFor(
893 export function deadlineFor(
878894 db: Database,
879895 who: Who,
880896 id: number,
src/export.ts +1
@@ -54,6 +54,7 @@ const dealOf = (deal: DealView): Record<string, unknown> => ({
5454 note: d.note,
5555 time: d.time,
5656 place: d.place,
57 papers: d.papers.map((p) => ({ name: p.name, in: p.in_at })),
5758 moves: d.moves.map((m) => ({ from: m.from_due, to: m.to_due, reason: m.reason, by: m.by_name, at: m.at })),
5859 })),
5960 messages: deal.sent.map((m) => ({
src/holddate.test.ts +2 −1
@@ -78,7 +78,8 @@ test("a migration adds the column and leaves old held rows held with no date", (
7878 const old = new Database(":memory:");
7979 migrate(old);
8080 old.exec("ALTER TABLE reminder_hold DROP COLUMN back_on");
81 old.query("DELETE FROM schema_version WHERE version = ?1").run(migrations.length);
81 // Back to just before the back_on migration, wherever it sits in the list (later ones run again harmlessly).
82 old.query("DELETE FROM schema_version WHERE version >= ?1").run(migrations.findIndex((m) => m.includes("reminder_hold ADD COLUMN back_on")) + 1);
8283 old.exec("INSERT INTO reminder_hold (deal_id, deadline_id, party_id, moment, due, move_id, until, held_on, by_name) VALUES (1, 1, 1, 'before', '2026-12-01', 0, '2026-11-12', '2026-11-11', 'alice')");
8384 migrate(old);
8485 expect(old.query("SELECT until, back_on FROM reminder_hold").all()).toEqual([{ until: "2026-11-12", back_on: null }]);
src/import.ts +15 −1
@@ -16,6 +16,7 @@ import { appointmentPlace, appointmentTime, isAppointment } from "./appointment.
1616 import { closingPlace, closingTime } from "./closing.ts";
1717 import { NOTE_MAX, REASON_MAX, insertDeal, line, partyText, plan, sameAddress, type NewDeal, type Plan, type Reach } from "./deals.ts";
1818 import { EXPORT_MAX, EXPORT_VERSION } from "./export.ts";
19 import { PAPERS_MAX, PAPER_MAX, insertPaper, paperName } from "./papers.ts";
1920 import { emit } from "./live.ts";
2021 import { audited, fail, ok, refuse as denied, type Result } from "./result.ts";
2122 import type { Who } from "./who.ts";
@@ -44,6 +45,7 @@ interface DateIn {
4445 done: string | null;
4546 waived: string | null;
4647 waivedBy: string | null;
48 papers: { name: string; in: string | null }[];
4749 moves: Move[];
4850 }
4951 interface PartyIn {
@@ -123,6 +125,17 @@ function readDeal(raw: unknown, n: number): Result<DealIn> {
123125 if (waived === undefined) return refuse(n, `date ${i + 1} (${label}) has a bad waived time.`, `${at}.waived`);
124126 if (done !== null && waived !== null) return refuse(n, `date ${i + 1} (${label}) is both done and waived.`, `${at}.waived`);
125127 const waivedBy = waived === null ? null : (line(o.waived_by, 1, 100) ?? "");
128 // Older files have no papers; a date carries up to PAPERS_MAX, each named 1 to 80 characters and in (a moment) or not.
129 const papersRaw = list(o.papers, PAPERS_MAX, `${at}.papers`);
130 if (!Array.isArray(papersRaw)) return papersRaw;
131 const papers: DateIn["papers"] = [];
132 for (const [j, pr] of papersRaw.entries()) {
133 const q = obj(pr);
134 const name = paperName(q.name);
135 const inAt = stamp(q.in);
136 if (name === undefined || inAt === undefined) return refuse(n, `paper ${j + 1} of date ${i + 1} (${label}) needs a name (up to ${PAPER_MAX} characters) and a time or nothing for in.`, `${at}.papers[${j}]`);
137 papers.push({ name, in: inAt });
138 }
126139 const movesRaw = list(o.moves, MOVES_MAX, `${at}.moves`);
127140 if (!Array.isArray(movesRaw)) return movesRaw;
128141 const moves: Move[] = [];
@@ -135,7 +148,7 @@ function readDeal(raw: unknown, n: number): Result<DealIn> {
135148 return refuse(n, `move ${j + 1} of date ${i + 1} (${label}) is not a from and to date, a reason and who moved it.`, `${at}.moves[${j}]`);
136149 moves.push({ from: m.from, to: m.to, reason, by, at: when });
137150 }
138 dates.push({ label, kind: o.kind as Kind, due: o.due, owes: own ? "agent" : (owes as PartyRole), note, time: time.value, place: place.value, done, waived, waivedBy, moves });
151 dates.push({ label, kind: o.kind as Kind, due: o.due, owes: own ? "agent" : (owes as PartyRole), note, time: time.value, place: place.value, done, waived, waivedBy, papers, moves });
139152 }
140153
141154 const closing = dates.find((x) => x.kind === "closing");
@@ -232,6 +245,7 @@ function write(db: Database, who: Who & { role: "user" | "admin" }, deals: reado
232245 "INSERT INTO deadline (deal_id, kind, label, due, owes, what, note, done_at, waived_at, waived_by, lead_days, time, place) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) RETURNING id",
233246 )
234247 .get(deal.id, x.kind, x.label, x.due, x.owes, what, x.note, x.done, x.waived, x.waivedBy, lead, x.time, x.place)!;
248 for (const p of x.papers) insertPaper(db, row.id, p.name, p.in);
235249 for (const m of x.moves)
236250 db.query("INSERT INTO deadline_move (deadline_id, from_due, to_due, reason, by_name, at) VALUES (?1, ?2, ?3, ?4, ?5, COALESCE(?6, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))").run(row.id, m.from, m.to, m.reason, m.by, m.at);
237251 }
src/migrations.pin.json +2 −1
@@ -59,5 +59,6 @@
5959 "c5df0d263e04d4e07f9df383beb57f033966af540bb228d9bbbd7b57f3620408",
6060 "4a52d9de76ede6d34d831d9bc6fc1d3d3a501a5d3652bf63695a9de49f2a7d39",
6161 "fafa7c66cff02dd250f2bed223947161b54f286b868fdd07c07c8c6d8b4556e3",
62 "8d0b71567ca7fc8e0927bfb1465c84a30b6702a8460145f7640e612b829530f5"
62 "8d0b71567ca7fc8e0927bfb1465c84a30b6702a8460145f7640e612b829530f5",
63 "c82fe9b3aea33d99a2c65ffb4179901d5aecde6b2ef759531b94ff12035a719a"
6364 ]
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/.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/.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/deal.ts +2 −1
@@ -22,6 +22,7 @@ import { confirmRemove } from "./confirm.ts";
2222 import { layout } from "./layout.ts";
2323 import { sideOf } from "../side.ts";
2424 import { dealSideLabel, sideLabel, sideSelect } from "./side.ts";
25 import { papersLine } from "../papers.ts";
2526 import { progressLine } from "./progress.ts";
2627 import { appointmentForm, appointmentText, type AppointmentProblem } from "./appointment.ts";
2728 import { badge, conflictLines, nonBusinessLine, noteLine, owesChangedLine, rollForm, draftItem, dueLine, outboxLine, owedBy, releaseForm, sheet, sheetId } from "./sheet.ts";
@@ -301,7 +302,7 @@ export function dealPage(input: {
301302 <div>
302303 <p>${badge(d, on)} <b>${escape(d.label)}</b></p>
303304 <p class="muted small">${escape(dueLine(d, on))}${d.moves.length > 0 ? ` · moved ${d.moves.length}×` : ""}</p>
304 ${appointmentText(d)}
305 ${appointmentText(d)}${d.papers.length === 0 ? "" : `<p class="small papers-line">${escape(papersLine(d.papers))}</p>`}
305306 <p class="small">${owedBy(deal, d)}</p>
306307 ${owesChangedLine(d)}
307308 ${noteLine(d)}
src/pages/sheet.ts +43 −1
@@ -15,6 +15,7 @@ import { labelOf, NO_CONTACT, outboxes } from "../outbox.ts";
1515 import { conflictLine } from "../order.ts";
1616 import { agent as defaults } from "../env.ts";
1717 import { draftsFor, heldFrom, momentLabel, MOMENTS, remindsLine, type Agent, type Draft } from "../reminders.ts";
18 import { PAPERS_MAX, PAPER_MAX, papersLine, stillOpenLine, suggestedPaper } from "../papers.ts";
1819 import type { Who } from "../who.ts";
1920 import { owner } from "../week.ts";
2021
@@ -186,6 +187,35 @@ export const draftLink = (draft: Draft, on: string): string =>
186187 ? `<li class="draft"><a class="draft-open" href="/deals/${draft.deal_id}#${draft.moment}">${draftLine(draft, on, true)}</a></li>`
187188 : `<li class="draft"><a class="draft-open" href="/deals/${draft.deal_id}#sheet-${draft.deadline_id}" data-sheet="sheet-${draft.deadline_id}" data-sheets="/deals/${draft.deal_id}/sheets">${draftLine(draft, on, true)}</a></li>`;
188189
190 /** A paper's name with its state in words: struck through and "in" once ticked, "still open" before. */
191 export const paperText = (p: { name: string; in_at: string | null }): string =>
192 p.in_at === null ? `${escape(p.name)} <span class="muted">· still open</span>` : `<s>${escape(p.name)}</s> <span class="muted">· in</span>`;
193
194 /** The checklist of a date: each paper with its tick, a form to add one (up to 8), and the one suggestion for a standard date. */
195 function papersSection(d: DeadlineView, may: boolean, to: string): string {
196 if (d.papers.length === 0 && !may) return "";
197 const suggestion = may && d.papers.length < PAPERS_MAX ? suggestedPaper(d.kind, d.papers) : undefined;
198 const items = d.papers
199 .map((p) => {
200 const tick = may
201 ? `<form method="post" action="/papers/${p.id}/tick">${hidden({ in: p.in_at === null ? 1 : 0, to })}<button class="button button-quiet button-small" aria-label="${p.in_at === null ? "Mark in" : "Not in yet"}: ${escape(p.name)}">${p.in_at === null ? "Mark in" : "Not in yet"}</button></form>
202 <form method="post" action="/papers/${p.id}/remove">${hidden({ to })}<button class="button button-quiet button-small" aria-label="Remove ${escape(p.name)}">Remove</button></form>`
203 : "";
204 return `<li class="paper-row"><span>${paperText(p)}</span>${tick}</li>`;
205 })
206 .join("");
207 const add = may && d.papers.length < PAPERS_MAX
208 ? `<form class="move" method="post" action="/deadlines/${d.id}/papers">${hidden({ to })}
209 <label>Add a paper<input class="input" name="name" maxlength="${PAPER_MAX}" autocomplete="off" required placeholder="e.g. Signed access form"></label>
210 <button class="button button-quiet">Add paper</button>
211 </form>`
212 : "";
213 const offer = suggestion === undefined
214 ? ""
215 : `<form method="post" action="/deadlines/${d.id}/papers">${hidden({ to, name: suggestion })}<button class="button button-quiet button-small">Add suggested: ${escape(suggestion)}</button></form>`;
216 return `<section class="papers"><h3>Papers</h3>${d.papers.length === 0 ? `<p class="muted">No papers listed. Names only; nothing is stored.</p>` : `<p class="muted">${escape(papersLine(d.papers))}</p><ul class="papers-list">${items}</ul>`}${offer}${add}</section>`;
217 }
218
189219 export function sheet(deal: DealView, d: DeadlineView, who: Who, on: string, to: string, agent: Agent = defaults, typed?: { id: number; note: string }): string {
190220 const id = sheetId(d);
191221 const may = canChange(who, deal);
@@ -219,9 +249,20 @@ export function sheet(deal: DealView, d: DeadlineView, who: Who, on: string, to:
219249 ? `<form method="post" action="/deadlines/${d.id}/waive">${hidden({ waived: 0, to })}
220250 <button class="button">Un-waive</button>
221251 </form>`
252 : `${
253 d.done_at === null && stillOpenLine(d.papers) !== ""
254 ? confirmRemove({
255 summary: "Mark done…",
256 goes: `${stillOpenLine(d.papers)} Mark done anyway?`,
257 action: `/deadlines/${d.id}/done`,
258 fields: { to, done: "1" },
259 name: `Yes, mark ${d.label} done anyway`,
260 yes: "Mark done anyway",
261 })
222262 : `<form method="post" action="/deadlines/${d.id}/done">${hidden({ done: d.done_at === null ? 1 : 0, to })}
223263 <button class="button${d.done_at === null ? "" : " button-quiet"}">${d.done_at === null ? "Mark done" : "Reopen"}</button>
224 </form>${
264 </form>`
265 }${
225266 isOpen(d) && isWaivable(d.kind)
226267 ? confirmRemove({
227268 summary: "Waive…",
@@ -284,6 +325,7 @@ export function sheet(deal: DealView, d: DeadlineView, who: Who, on: string, to:
284325 ${conflictLines(deal, d)}
285326 ${nonBusinessLine(d)}${may ? rollForm(d, to) : ""}
286327 ${moves}
328 ${papersSection(d, may, to)}
287329 <section><h3>Reminders</h3>${isOpen(d) ? `<p class="muted">${remindsLine(d.lead_days)}</p>` : ""}${reminders}</section>
288330 ${actions}
289331 </div>`;
src/pages/timeline.ts +9 −1
@@ -25,6 +25,14 @@ function todo(lines: Guidance[]): string {
2525 .join("");
2626 }
2727
28 /** The papers of a date under it: each paper in the order added, one that is in struck through and named "in", one outstanding named "still needed". Nothing when it has none. */
29 function papers(list: readonly { name: string; in: boolean }[]): string {
30 if (list.length === 0) return "";
31 const open = list.filter((p) => !p.in).length;
32 const items = list.map((p) => (p.in ? `<li class="timeline-paper"><s>${escape(p.name)}</s> — in</li>` : `<li class="timeline-paper">${escape(p.name)} — still needed</li>`)).join("");
33 return `<div class="timeline-papers"><p><b>${open === 0 ? "All papers in" : `${open} ${open === 1 ? "paper" : "papers"} still needed`}</b></p><ul>${items}</ul></div>`;
34 }
35
2836 export function timelinePage(input: { who: Who; agent?: Agent | undefined; deal: DealView; on: string }): string {
2937 const { who, deal, on } = input;
3038 const agent = input.agent ?? defaults;
@@ -35,7 +43,7 @@ export function timelinePage(input: { who: Who; agent?: Agent | undefined; deal:
3543 <p class="timeline-date">${escape(r.spoken)}${r.where === "" ? "" : ` · ${escape(r.where)}`}</p>
3644 <p class="timeline-label"><b>${escape(r.label)}</b></p>
3745 <p class="timeline-owner">${escape(r.role)}${r.name === undefined ? "" : ` · ${escape(r.name)}`}</p>
38 <p class="timeline-status"><span aria-hidden="true">${r.mark}</span> ${r.doneLine === "" ? r.status : escape(r.doneLine)}</p>${todo(r.todo)}
46 <p class="timeline-status"><span aria-hidden="true">${r.mark}</span> ${r.doneLine === "" ? r.status : escape(r.doneLine)}</p>${todo(r.todo)}${papers(r.papers)}
3947 </li>`,
4048 )
4149 .join("");
src/papers.test.ts added +236
@@ -0,0 +1,236 @@
1 /** The paperwork checklist on a date: add, tick, limit, validate, escape, confirm on done, cascade, export round trip. */
2 import { afterAll, 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 { openDb } from "./db.ts";
7 import { getDeal, insertDeal, markDone, plan, removeDeadline, addDeadline } from "./deals.ts";
8 import { exportDeals } from "./export.ts";
9 import { importDeals } from "./import.ts";
10 import { PAPERS_MAX, addPaper, papersLine, removePaper, stillOpenLine, suggestedPaper, tickPaper } from "./papers.ts";
11 import { serve } from "./server.ts";
12 import { sign, type Who } from "./who.ts";
13
14 const KEY = "a-key-only-the-forge-holds";
15 const alice: Who = { role: "user", id: "u1", name: "alice" };
16 const bob: Who = { role: "user", id: "u2", name: "bob" };
17 const anon: Who = { role: "anon" } as Who;
18 const must = <T>(r: { ok: true; value: T } | { ok: false; message: string }): T => {
19 if (!r.ok) throw new Error(r.message);
20 return r.value;
21 };
22
23 const db = openDb(mkdtempSync(join(tmpdir(), "app-papers-")));
24 const opened = (address = "5 Paper Lane") => {
25 const p = plan({ address, buyer: "Bea", seller: "Sam", contractDate: "2026-10-01", closingDate: "2026-11-20" });
26 if (!p.ok) throw new Error(p.message);
27 return insertDeal(db, { id: "u1", name: "alice" }, p.value).id;
28 };
29 const dateOf = (dealId: number, kind: string) => must(getDeal(db, alice, { id: dealId })).deadlines.find((d) => d.kind === kind)!;
30
31 test("a new deal's dates carry no papers: a suggestion is only offered, never added", () => {
32 const id = opened();
33 for (const d of must(getDeal(db, alice, { id })).deadlines) expect(d.papers).toEqual([]);
34 expect(suggestedPaper("financing", [])).toBe("Lender's approval letter");
35 expect(suggestedPaper("custom", [])).toBeUndefined();
36 expect(suggestedPaper("financing", [{ id: 1, deadline_id: 1, name: "lender's approval LETTER", in_at: null }])).toBeUndefined();
37 });
38
39 test("add, tick and untick a paper; the words say how many are in", () => {
40 const id = opened("6 Paper Lane");
41 const date = dateOf(id, "inspection");
42 const a = must(addPaper(db, alice, { deadlineId: date.id, name: " Signed access form " }));
43 expect(a.name).toBe("Signed access form");
44 must(addPaper(db, alice, { deadlineId: date.id, name: "Seller's disclosure" }));
45 must(addPaper(db, alice, { deadlineId: date.id, name: "Key lockbox code" }));
46 must(tickPaper(db, alice, { id: a.id, in: true }));
47 const papers = dateOf(id, "inspection").papers;
48 expect(papers.map((p) => p.name)).toEqual(["Signed access form", "Seller's disclosure", "Key lockbox code"]);
49 expect(papersLine(papers)).toBe("1 of 3 papers in");
50 expect(stillOpenLine(papers)).toBe("2 papers still open.");
51 expect(papers[0]!.in_at).not.toBeNull();
52 must(tickPaper(db, alice, { id: a.id, in: false }));
53 expect(papersLine(dateOf(id, "inspection").papers)).toBe("0 of 3 papers in");
54 expect(papersLine([])).toBe("");
55 must(removePaper(db, alice, { id: a.id }));
56 expect(dateOf(id, "inspection").papers).toHaveLength(2);
57 });
58
59 test("a name is 1 to 80 characters, once per date, and a date holds 8", () => {
60 const id = opened("7 Paper Lane");
61 const date = dateOf(id, "title");
62 for (const bad of ["", " ", "x".repeat(81), 5, null, undefined]) {
63 const r = addPaper(db, alice, { deadlineId: date.id, name: bad });
64 expect(r.ok).toBe(false);
65 if (!r.ok) expect([r.error, r.field]).toEqual(["invalid", "name"]);
66 }
67 expect(addPaper(db, alice, { deadlineId: date.id, name: "x".repeat(80) }).ok).toBe(true);
68 expect(addPaper(db, alice, { deadlineId: date.id, name: "X".repeat(80) }).ok).toBe(false);
69 for (let i = 1; i < PAPERS_MAX; i++) must(addPaper(db, alice, { deadlineId: date.id, name: `Paper ${i}` }));
70 const ninth = addPaper(db, alice, { deadlineId: date.id, name: "One too many" });
71 expect(ninth.ok).toBe(false);
72 expect(dateOf(id, "title").papers).toHaveLength(PAPERS_MAX);
73 });
74
75 test("the table decides: a stranger and another agent's date are refused, and a paper that is not there is not found", () => {
76 const id = opened("8 Paper Lane");
77 const date = dateOf(id, "inspection");
78 const p = must(addPaper(db, alice, { deadlineId: date.id, name: "Access form" }));
79 expect(addPaper(db, anon, { deadlineId: date.id, name: "Nope" })).toMatchObject({ ok: false, error: "signin_required" });
80 expect(tickPaper(db, anon, { id: p.id, in: true }).ok).toBe(false);
81 expect(tickPaper(db, alice, { id: 99999, in: true })).toMatchObject({ ok: false, error: "not_found" });
82 expect(tickPaper(db, alice, { id: Number.NaN, in: true })).toMatchObject({ ok: false, error: "not_found" });
83 expect(removePaper(db, anon, { id: p.id }).ok).toBe(false);
84 expect(dateOf(id, "inspection").papers.map((x) => x.name)).toEqual(["Access form"]);
85 // Another signed-in agent is told by the same table as for editing the date.
86 expect(addPaper(db, bob, { deadlineId: date.id, name: "Mine now" }).ok).toBe(addDeadline(db, bob, { dealId: id, label: "X", due: "2026-11-01", owes: "buyer" }).ok);
87 });
88
89 test("the paper goes with its date", () => {
90 const id = opened("9 Paper Lane");
91 const custom = must(addDeadline(db, alice, { dealId: id, label: "HOA review", due: "2026-11-05", owes: "buyer" }));
92 must(addPaper(db, alice, { deadlineId: custom.id, name: "HOA budget" }));
93 expect(db.query("SELECT count(*) AS n FROM paper WHERE deadline_id = ?1").get(custom.id)).toEqual({ n: 1 });
94 must(removeDeadline(db, alice, { id: custom.id }));
95 expect(db.query("SELECT count(*) AS n FROM paper WHERE deadline_id = ?1").get(custom.id)).toEqual({ n: 0 });
96 });
97
98 test("the sample deals carry a few papers, some in and some open", () => {
99 const n = db.query<{ n: number; done: number }, []>("SELECT count(*) AS n, count(in_at) AS done FROM paper p JOIN deadline d ON d.id = p.deadline_id JOIN deal e ON e.id = d.deal_id WHERE e.author_id = 'sample'").get()!;
100 expect(n.n).toBeGreaterThanOrEqual(4);
101 expect(n.done).toBeGreaterThan(0);
102 expect(n.done).toBeLessThan(n.n);
103 });
104
105 test("the export carries the papers and the import brings them back; a file from before has none and still loads", () => {
106 const id = opened("10 Paper Lane");
107 const date = dateOf(id, "financing");
108 const a = must(addPaper(db, alice, { deadlineId: date.id, name: "Lender's approval letter" }));
109 must(addPaper(db, alice, { deadlineId: date.id, name: "Insurance binder" }));
110 must(tickPaper(db, alice, { id: a.id, in: true }));
111 const file = JSON.parse(JSON.stringify(exportDeals([must(getDeal(db, alice, { id }))], new Date("2026-10-05T00:00:00Z"))));
112 const filed = file.deals[0].dates.find((d: { kind: string }) => d.kind === "financing");
113 expect(filed.papers).toEqual([{ name: "Lender's approval letter", in: a.in_at ?? expect.any(String) }, { name: "Insurance binder", in: null }]);
114 const into = openDb(mkdtempSync(join(tmpdir(), "app-papers-into-")));
115 const got = must(importDeals(into, bob, file));
116 const back = must(getDeal(into, bob, { id: got.id })).deadlines.find((d) => d.kind === "financing")!;
117 expect(back.papers.map((p) => [p.name, p.in_at !== null])).toEqual([["Lender's approval letter", true], ["Insurance binder", false]]);
118 // An older file: no papers key at all.
119 for (const d of file.deals[0].dates) delete d.papers;
120 file.deals[0].address = "11 Older File Rd";
121 const older = must(importDeals(into, bob, file));
122 expect(must(getDeal(into, bob, { id: older.id })).deadlines.every((d) => d.papers.length === 0)).toBe(true);
123 // A bad paper refuses the whole file and writes nothing.
124 file.deals[0].address = "12 Bad File Rd";
125 file.deals[0].dates[0].papers = [{ name: "x".repeat(81), in: null }];
126 const bad = importDeals(into, bob, file);
127 expect(bad.ok).toBe(false);
128 if (!bad.ok) expect(bad.field).toContain("papers[0]");
129 file.deals[0].dates[0].papers = Array.from({ length: 9 }, (_, i) => ({ name: `p${i}`, in: null }));
130 expect(importDeals(into, bob, file).ok).toBe(false);
131 expect(into.query("SELECT count(*) AS n FROM deal WHERE address LIKE '12 Bad%'").get()).toEqual({ n: 0 });
132 });
133
134 // ---- the pages ----
135
136 const dir = mkdtempSync(join(tmpdir(), "app-papers-web-"));
137 const server = serve({ port: 0, dataDir: dir, identityKey: KEY });
138 const base = `http://${server.hostname}:${server.port}`;
139 afterAll(() => server.stop(true));
140 const headers = (id: string) => {
141 const identity = `${id}|${id}|user|${Date.now() + 600_000}`;
142 return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
143 };
144 const post = (path: string, fields: Record<string, string>, who = headers("u1"), extra: Record<string, string> = {}) => {
145 const body = new FormData();
146 for (const [k, v] of Object.entries(fields)) body.set(k, v);
147 return fetch(base + path, { method: "POST", body, redirect: "manual", headers: { ...who, ...extra } });
148 };
149 const made = async () => {
150 const r = await fetch(base + "/api/deals", { method: "POST", headers: { ...headers("u1"), "content-type": "application/json" }, body: JSON.stringify({ address: "3 Web Paper Lane", open_anyway: true, buyer: "A", seller: "B", contract_date: "2026-10-01", closing_date: "2026-12-01" }) });
151 const id = ((await r.json()) as { id: number }).id;
152 const deal = (await (await fetch(`${base}/api/deals/${id}`, { headers: headers("u1") })).json()) as { deadlines: { id: number; kind: string }[] };
153 return { id, inspection: deal.deadlines.find((d) => d.kind === "inspection")!.id };
154 };
155
156 test("the forms add, tick and remove; the sheet and the deal page say it in words, escaped", async () => {
157 const { id, inspection } = await made();
158 const to = `/deals/${id}`;
159 expect((await post(`/deadlines/${inspection}/papers`, { name: "<b>Access</b> form", to })).status).toBe(303);
160 expect((await post(`/deadlines/${inspection}/papers`, { name: "Second paper", to })).status).toBe(303);
161 let page = await (await fetch(base + to, { headers: headers("u1") })).text();
162 expect(page).toContain("0 of 2 papers in");
163 expect(page).toContain("&lt;b&gt;Access&lt;/b&gt; form");
164 expect(page).not.toContain("<b>Access</b>");
165 expect(page).toContain("still open");
166 const paperId = Number((await (await fetch(`${base}/deals/${id}/sheets`, { headers: headers("u1") })).text()).match(/\/papers\/(\d+)\/tick/)![1]);
167 expect((await post(`/papers/${paperId}/tick`, { in: "1", to })).status).toBe(303);
168 page = await (await fetch(base + to, { headers: headers("u1") })).text();
169 expect(page).toContain("1 of 2 papers in");
170 expect(page).toContain("<s>");
171 // A refused name comes back as the page, naming why.
172 const long = await post(`/deadlines/${inspection}/papers`, { name: "x".repeat(81), to });
173 expect(long.status).toBe(422);
174 expect(await long.text()).toContain("1 to 80 characters");
175 // Another site's post is refused.
176 const cross = await post(`/deadlines/${inspection}/papers`, { name: "Evil", to }, headers("u1"), { origin: "https://evil.example", "sec-fetch-site": "cross-site" });
177 expect(cross.status).toBe(403);
178 expect((await post(`/papers/${paperId}/remove`, { to })).status).toBe(303);
179 expect(await (await fetch(base + to, { headers: headers("u1") })).text()).toContain("0 of 1 paper in");
180 });
181
182 test("the sheet offers the suggestion and adds nothing until it is pressed", async () => {
183 const { id, inspection } = await made();
184 const sheets = await (await fetch(`${base}/deals/${id}/sheets`, { headers: headers("u1") })).text();
185 expect(sheets).toContain("Add suggested: Signed access form");
186 expect(sheets).not.toContain("papers in");
187 expect(await (await fetch(`${base}/deals/${id}`, { headers: headers("u1") })).text()).not.toContain("papers in");
188 await post(`/deadlines/${inspection}/papers`, { name: "Signed access form", to: `/deals/${id}` });
189 expect(await (await fetch(`${base}/deals/${id}/sheets`, { headers: headers("u1") })).text()).not.toContain("Add suggested: Signed access form");
190 });
191
192 test("marking a date done with open papers asks once, then goes ahead", async () => {
193 const { id, inspection } = await made();
194 const to = `/deals/${id}`;
195 await post(`/deadlines/${inspection}/papers`, { name: "One", to });
196 await post(`/deadlines/${inspection}/papers`, { name: "Two", to });
197 const sheets = await (await fetch(`${base}/deals/${id}/sheets`, { headers: headers("u1") })).text();
198 expect(sheets).toContain("2 papers still open. Mark done anyway?");
199 const asked = await post(`/deadlines/${inspection}/done`, { done: "1", to });
200 expect(asked.status).toBe(422);
201 expect(await asked.text()).toContain("2 papers still open.");
202 const state = async () => ((await (await fetch(`${base}/api/deals/${id}`, { headers: headers("u1") })).json()) as { deadlines: { id: number; done_at: string | null }[] }).deadlines.find((d) => d.id === inspection)!.done_at;
203 expect(await state()).toBeNull();
204 expect((await post(`/deadlines/${inspection}/done`, { done: "1", to, confirm: "yes" })).status).toBe(303);
205 expect(await state()).not.toBeNull();
206 // Reopening needs no question.
207 expect((await post(`/deadlines/${inspection}/done`, { done: "0", to })).status).toBe(303);
208 expect(await state()).toBeNull();
209 });
210
211 test("the client timeline lists the open papers under the date, with done ones struck through and said in words, and nothing else of the sheet", async () => {
212 const { id, inspection } = await made();
213 const to = `/deals/${id}`;
214 await post(`/deadlines/${inspection}/papers`, { name: "Signed <i>access</i> form", to });
215 await post(`/deadlines/${inspection}/papers`, { name: "Seller disclosure", to });
216 const paperId = Number((await (await fetch(`${base}/deals/${id}/sheets`, { headers: headers("u1") })).text()).match(/\/papers\/(\d+)\/tick/)![1]);
217 await post(`/papers/${paperId}/tick`, { in: "1", to });
218 const page = await (await fetch(`${base}${to}/timeline`, { headers: headers("u1") })).text();
219 expect(page).toContain("1 paper still needed");
220 expect(page).toContain("<s>Signed &lt;i&gt;access&lt;/i&gt; form</s> — in");
221 expect(page).toContain("Seller disclosure — still needed");
222 expect(page).not.toContain("Mark in");
223 });
224
225 test("a deal with no papers shows none on the timeline", async () => {
226 const { id } = await made();
227 const page = await (await fetch(`${base}/deals/${id}/timeline`, { headers: headers("u1") })).text();
228 expect(page).not.toContain("timeline-papers");
229 });
230
231 test("markDone itself does not ask: the API door is unchanged", () => {
232 const id = opened("13 Paper Lane");
233 const date = dateOf(id, "inspection");
234 must(addPaper(db, alice, { deadlineId: date.id, name: "Open paper" }));
235 expect(markDone(db, alice, { id: date.id, done: true }).ok).toBe(true);
236 });
src/papers.ts added +103
@@ -0,0 +1,103 @@
1 /**
2 * The paperwork checklist on a date: a few names (no documents) the agent
3 * ticks in as the papers arrive. Pure words about a list at the top
4 * (`papersLine`, `openPapers`, `suggestedPaper`); the three changes below it —
5 * add, tick, remove — are updates of the deal the date hangs off: each asks
6 * `permit` as editing a date does and is audited against the deal.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import type { Kind } from "./deadlines.ts";
11 import { deadlineFor, line, within } from "./deals.ts";
12 import { fail, ok, type Result } from "./result.ts";
13 import type { Who } from "./who.ts";
14
15 export const PAPERS_MAX = 8;
16 export const PAPER_MAX = 80;
17
18 export interface Paper {
19 readonly id: number;
20 readonly deadline_id: number;
21 readonly name: string;
22 /** When it was ticked in; null while outstanding. */
23 readonly in_at: string | null;
24 }
25
26 /** The one paper each standard date offers, added only when the agent accepts it. */
27 export const SUGGESTED_PAPER: Readonly<Partial<Record<Kind, string>>> = {
28 earnest: "Earnest money receipt",
29 inspection: "Signed access form",
30 appraisal: "Appraisal access confirmation",
31 financing: "Lender's approval letter",
32 title: "Title commitment",
33 walkthrough: "Signed walkthrough checklist",
34 closing: "Photo ID for signing",
35 };
36
37 export const openPapers = (papers: readonly Paper[]): Paper[] => papers.filter((p) => p.in_at === null);
38
39 /** "2 of 3 papers in"; empty when the date has none. */
40 export const papersLine = (papers: readonly Paper[]): string =>
41 papers.length === 0 ? "" : `${papers.length - openPapers(papers).length} of ${papers.length} ${papers.length === 1 ? "paper" : "papers"} in`;
42
43 /** "2 papers still open", the words asked before a date is marked done; empty when none are open. */
44 export const stillOpenLine = (papers: readonly Paper[]): string => {
45 const n = openPapers(papers).length;
46 return n === 0 ? "" : `${n} ${n === 1 ? "paper" : "papers"} still open.`;
47 };
48
49 /** The papers of a date still outstanding, read fresh: what a door asks before it marks the date done. */
50 export const openPapersOf = (db: Database, deadlineId: number): Paper[] =>
51 openPapers(db.query<Paper, [number]>("SELECT * FROM paper WHERE deadline_id = ?1 ORDER BY id").all(deadlineId));
52
53 /** The suggestion for a standard date, unless it is custom or the list already has it. */
54 export const suggestedPaper = (kind: Kind, papers: readonly Paper[]): string | undefined => {
55 const name = SUGGESTED_PAPER[kind];
56 return name === undefined || papers.some((p) => p.name.toLowerCase() === name.toLowerCase()) ? undefined : name;
57 };
58
59 /** A paper's name from a person: one line of 1 to 80 characters, or undefined. */
60 export const paperName = (v: unknown): string | undefined => line(v, 1, PAPER_MAX);
61
62 /** Write a paper for a date with no checks: for the import and the sample, which have made theirs. */
63 export const insertPaper = (db: Database, deadlineId: number, name: string, inAt: string | null = null): void => {
64 db.query("INSERT INTO paper (deadline_id, name, in_at) VALUES (?1, ?2, ?3)").run(deadlineId, name, inAt);
65 };
66
67 const reread = (db: Database, id: number): Paper => db.query<Paper, [number]>("SELECT * FROM paper WHERE id = ?1").get(id)!;
68
69 /** Add a paper to a date: up to 8, each a different name. */
70 export function addPaper(db: Database, who: Who, input: { deadlineId: number; name: unknown }): Result<Paper> {
71 const { dealId, found } = deadlineFor(db, who, input.deadlineId);
72 if (!found.ok) return within(db, who, "paper.add", dealId, found);
73 const name = paperName(input.name);
74 if (name === undefined) return within(db, who, "paper.add", dealId, fail("invalid", `A paper is named in 1 to ${PAPER_MAX} characters.`, "name"));
75 const have = db.query<{ name: string }, [number]>("SELECT name FROM paper WHERE deadline_id = ?1").all(input.deadlineId);
76 if (have.length >= PAPERS_MAX)
77 return within(db, who, "paper.add", dealId, fail("invalid", `A date carries up to ${PAPERS_MAX} papers.`, "name"));
78 if (have.some((p) => p.name.toLowerCase() === name.toLowerCase()))
79 return within(db, who, "paper.add", dealId, fail("invalid", "That paper is already on the list.", "name"));
outdated

addPaper, tickPaper and removePaper are only reachable from the web routes. src/tty.ts needs matching keys for add, tick and remove, and for accepting the suggestion.

80 const row = db.query<Paper, [number, string]>("INSERT INTO paper (deadline_id, name) VALUES (?1, ?2) RETURNING *").get(input.deadlineId, name)!;
81 return within(db, who, "paper.add", dealId, ok(row));
82 }
83
84 /** The paper and the date it hangs off, as an id the door gave. */
85 const dateOf = (db: Database, id: number): number =>
86 Number.isInteger(id) ? (db.query<{ deadline_id: number }, [number]>("SELECT deadline_id FROM paper WHERE id = ?1").get(id)?.deadline_id ?? Number.NaN) : Number.NaN;
87
88 /** Tick a paper in, or untick it. */
89 export function tickPaper(db: Database, who: Who, input: { id: number; in: boolean }): Result<Paper> {
90 const { dealId, found } = deadlineFor(db, who, dateOf(db, input.id));
91 if (!found.ok) return within(db, who, "paper.tick", dealId, found.error === "invalid" ? fail("not_found", "No such paper.") : found);
92 db.query("UPDATE paper SET in_at = ?1 WHERE id = ?2").run(input.in ? new Date().toISOString() : null, input.id);
93 return within(db, who, "paper.tick", dealId, ok(reread(db, input.id)));
94 }
95
96 /** Take a paper off its date's list. */
97 export function removePaper(db: Database, who: Who, input: { id: number }): Result<{ id: number; name: string }> {
98 const { dealId, found } = deadlineFor(db, who, dateOf(db, input.id));
99 if (!found.ok) return within(db, who, "paper.remove", dealId, found.error === "invalid" ? fail("not_found", "No such paper.") : found);
100 const gone = reread(db, input.id);
101 db.query("DELETE FROM paper WHERE id = ?1").run(input.id);
102 return within(db, who, "paper.remove", dealId, ok({ id: gone.id, name: gone.name }));
103 }
src/routes.ts +47
@@ -44,6 +44,7 @@ import { nextComing, type Next } from "./coming.ts";
4444 import { tally } from "./book.ts";
4545 import { AMOUNT_MAX, RATE_MAX } from "./money.ts";
4646 import { progress } from "./progress.ts";
47 import { addPaper, openPapersOf, removePaper, stillOpenLine, tickPaper } from "./papers.ts";
4748 import {
4849 addDeadline,
4950 addParty,
@@ -336,6 +337,11 @@ export const DEAL: Schema = {
336337 done_on: { type: ["string", "null"], description: "The day it was marked done, YYYY-MM-DD in the app's local calendar; null while open. Reopening clears it." },
337338 waived_at: { type: ["string", "null"], description: "When the buyer waived this date on purpose; null while it stands. A waived date is settled: no reminders, flags or conflicts." },
338339 waived_by: { type: ["string", "null"], description: "Who recorded the waiver." },
340 papers: {
341 type: "array",
342 description: "The paperwork checklist on this date, names only, up to 8, in the order added; in_at is when it was ticked in, null while outstanding.",
343 items: { type: "object", properties: { id: { type: "integer" }, deadline_id: { type: "integer" }, name: { type: "string" }, in_at: { type: ["string", "null"] } }, required: ["id", "deadline_id", "name", "in_at"] },
344 },
339345 time: { type: ["string", "null"], description: "An inspection's or walkthrough's time, HH:MM on a quarter hour; null while unset and for any other kind. This week shows it and lists a day's timed dates first, earliest first." },
340346 place: { type: ["string", "null"], maxLength: 200, description: "An inspection's or walkthrough's place; null while unset and for any other kind." },
341347 note: { type: "string", maxLength: 200, description: "The agent's working note on this date; empty when none. Internal, never sent to a client." },
@@ -1266,6 +1272,10 @@ export const routes: readonly Route[] = [
12661272 const f = await form(c);
12671273 if (f instanceof Response) return f;
12681274 const isDone = f.done !== "0";
1275 // Papers still open ask once; the sheet's "Mark done anyway" carries the confirm.
1276 const owed = isDone && f.confirm !== "yes" ? stillOpenLine(openPapersOf(c.db, Number(c.params.id))) : "";
1277 if (owed !== "")

Raw SQL in the route handler. Put this lookup in src/papers.ts and call it here. The terminal needs the same lookup for its own done-with-open-papers question.

1278 return html(refusedPage({ who: c.who, path: backTo(f.to), heading: "Not marked done", error: "invalid", message: `${owed} Open the date and choose Mark done anyway.` }), STATUS.invalid);
12691279 return done(markDone(c.db, c.who, { id: Number(c.params.id), done: isDone }), backTo(f.to), isDone ? "Marked done." : "Reopened.");
12701280 },
12711281 },
@@ -1326,6 +1336,43 @@ export const routes: readonly Route[] = [
13261336 return done(set, backTo(f.to));
13271337 },
13281338 },
1339 {
1340 method: "POST",
1341 path: "/deadlines/:id/papers",
1342 handle: async (c) => {
1343 const f = await form(c);
1344 if (f instanceof Response) return f;
1345 const id = Number(c.params.id);
1346 const added = addPaper(c.db, c.who, { deadlineId: id, name: f.name });
1347 if (added.ok) return done(added, backTo(f.to), `Added ${added.value.name}.`);
1348 const deal = c.db.query<{ deal_id: number }, [number]>("SELECT deal_id FROM deadline WHERE id = ?1").get(id)?.deal_id;
1349 const found = deal === undefined ? undefined : getDeal(c.db, c.who, { id: deal });
1350 if (found?.ok && added.error === "invalid")
1351 return html(dealPage({ who: c.who, ...headerFor(c), agent: agentFor(c), deal: found.value, on: dayOf(agentFor(c)), notice: added.message }), STATUS.invalid);
1352 return done(added, backTo(f.to));
1353 },
1354 },
1355 {
1356 method: "POST",
1357 path: "/papers/:id/tick",
1358 handle: async (c) => {
1359 const f = await form(c);
1360 if (f instanceof Response) return f;
1361 const isIn = f.in !== "0";
1362 const set = tickPaper(c.db, c.who, { id: Number(c.params.id), in: isIn });
1363 return done(set, backTo(f.to), set.ok ? (isIn ? `${set.value.name} is in.` : `${set.value.name} is not in yet.`) : undefined);
1364 },
1365 },
1366 {
1367 method: "POST",
1368 path: "/papers/:id/remove",
1369 handle: async (c) => {
1370 const f = await form(c);
1371 if (f instanceof Response) return f;
1372 const gone = removePaper(c.db, c.who, { id: Number(c.params.id) });
1373 return done(gone, backTo(f.to), gone.ok ? `Removed ${gone.value.name}.` : undefined);
1374 },
1375 },
13291376 {
13301377 method: "POST",
13311378 path: "/deadlines/:id/appointment",
src/sample.ts +16
@@ -14,6 +14,7 @@ import { zoneOf } from "./profile.ts";
1414 import { type PartyRole } from "./deadlines.ts";
1515 import { BRIEF_LENGTHS, validEmail } from "./env.ts";
1616 import { APPOINTMENT_KINDS } from "./appointment.ts";
17 import { insertPaper } from "./papers.ts";
1718 import { insertDeal, type Reach } from "./deals.ts";
1819 import type { Side } from "./side.ts";
1920
@@ -49,6 +50,8 @@ interface Sample {
4950 readonly moved?: readonly [string, number, string];
5051 /** The agent's working note on a date: date kind, note. */
5152 readonly notes?: readonly (readonly [string, string])[];
53 /** The paperwork on a date: date kind, paper, and whether it is in. */
54 readonly papers?: readonly (readonly [string, string, boolean])[];
5255 /** A reminder already sent, in the agent's own edited words: date kind, party role, moment, subject, body. */
5356 readonly sent?: readonly [string, PartyRole, "before" | "day_of", string, string];
5457 }
@@ -72,6 +75,11 @@ const SAMPLES: readonly Sample[] = [
7275 ],
7376 reach: [["Maya & Tom Okafor", "text"], ["Priya Nair, Coastline Mortgage", "email"], ["Jordan Ellis, Crestline Homes", "call"]],
7477 done: ["earnest", "inspection", "title", "appraisal"],
78 papers: [
79 ["financing", "Lender's approval letter", true],
80 ["financing", "Homeowner's insurance binder", false],
81 ["financing", "Signed loan estimate", true],
82 ],
7583 sent: [
7684 "inspection",
7785 "buyer",
@@ -97,6 +105,10 @@ const SAMPLES: readonly Sample[] = [
97105 reach: [["Samuel Reyes", "call"], ["Owen Brandt, First Keel Bank", "email"]],
98106 done: ["earnest"],
99107 notes: [["inspection", "Inspector booked for Friday 9am; seller will leave the attic open"]],
108 papers: [
109 ["inspection", "Signed access form", true],
110 ["inspection", "Seller's disclosure packet", false],
111 ],
100112 custom: [["HOA documents review ends", 9, "buyer", "Read the HOA budget, rules and minutes; object in writing if needed"]],
101113 },
102114 {
@@ -444,6 +456,10 @@ function writeSamples(db: Database, on: string): void {
444456 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);
445457 for (const [kind, note] of s.notes ?? [])
446458 db.query("UPDATE deadline SET note = ?1 WHERE deal_id = ?2 AND kind = ?3").run(note, deal.id, kind);
459 for (const [kind, name, isIn] of s.papers ?? []) {
460 const d = db.query<{ id: number; due: string }, [number, string]>("SELECT id, due FROM deadline WHERE deal_id = ?1 AND kind = ?2").get(deal.id, kind)!;
461 insertPaper(db, d.id, name, isIn ? `${addDays(on, -1)}${DONE_TIME}` : null);
462 }
447463 for (const [label, days, owes, what] of s.custom ?? [])
448464 db.query("INSERT INTO deadline (deal_id, kind, label, due, owes, what) VALUES (?1, 'custom', ?2, ?3, ?4, ?5)").run(deal.id, label, onBusinessDay(addDays(on, days), on), owes, what);
449465 if (s.sent !== undefined) {
src/terminal.test.ts +53
@@ -1098,3 +1098,56 @@ test("p and e set how a party likes to be reached, an unknown value is refused,
10981098 press(back, "e", "Tex Lender\r", "[email protected]\r", "\r", "\r");
10991099 expect(read().parties.find((p) => p.id === tex.id)!.reach).toBe("");
11001100 }, 20_000);
1101
1102 test("i on a date lists, adds, ticks, unticks, removes and suggests its papers, and x asks once while any is open", () => {
1103 const made3 = createDeal(db, alice, { openAnyway: true, address: "41 Papers Way", buyer: "Pia Buyer", seller: "Sol Seller", contractDate: today(DEFAULT_ZONE), closingDate: addDays(today(DEFAULT_ZONE), 45) });
1104 if (!made3.ok) throw new Error(made3.message);
1105 const inspection = () => {
1106 const got = getDeal(db, alice, { id: made3.value.id });
1107 if (!got.ok) throw new Error(got.message);
1108 return got.value.deadlines.find((d) => d.kind === "inspection")!;
1109 };
1110 const t = terminal(db, alice, context);
1111 goTo(t, "Closing — 41 Papers Way");
1112 t.press("o");
1113 for (let i = 0; i < 20 && !under(t).includes("Inspection"); i++) t.press("j");
1114 expect(under(t)).toContain("Inspection");
1115 // The prompt lists (none yet) and offers the suggestion; nothing is added until it is accepted.
1116 t.press("i");
1117 expect(lines(t).join("\n")).toContain("no papers yet");
1118 expect(lines(t).join("\n")).toContain("suggest");
1119 press(t, "suggest\r");
1120 expect(inspection().papers.map((p) => p.name)).toEqual(["Signed access form"]);
1121 press(t, "i", "add Seller's disclosure\r");
1122 expect(inspection().papers).toHaveLength(2);
1123 expect(under(t)).toContain("0 of 2 papers in");
1124 press(t, "i", "in 1\r");
1125 expect(inspection().papers[0]!.in_at).not.toBeNull();
1126 expect(under(t)).toContain("1 of 2 papers in");
1127 press(t, "i", "out 1\r");
1128 expect(inspection().papers[0]!.in_at).toBeNull();
1129 // A bad name and a bad number are refused in words, changing nothing.
1130 press(t, "i", `add ${"x".repeat(81)}\r`);
1131 expect(lines(t).join("\n")).toContain("1 to 80 characters");
1132 press(t, "i", "in 9\r");
1133 expect(lines(t).join("\n")).toContain("No paper 9");
1134 expect(inspection().papers).toHaveLength(2);
1135 // Done asks once with both open; anything but yes leaves it open.
1136 t.press("x");
1137 expect(lines(t).join("\n")).toContain("2 papers still open. Mark done anyway?");
1138 press(t, "no\r");
1139 expect(inspection().done_at).toBeNull();
1140 press(t, "x", "yes\r");
1141 expect(inspection().done_at).not.toBeNull();
1142 press(t, "u", "i", "remove 2\r");
1143 expect(inspection().papers.map((p) => p.name)).toEqual(["Signed access form"]);
1144 // With nothing open, x asks nothing.
1145 press(t, "i", "in 1\r", "u", "x");
1146 expect(inspection().done_at).not.toBeNull();
1147 }, 20_000);
1148
1149 test("a guest is not asked to type papers", () => {
1150 const t = terminal(db, { role: "anon" } as Who, context);
1151 t.press("i");
1152 expect(lines(t).join("\n")).not.toContain("Papers on");
1153 });
src/terminal.ts +43 −4
@@ -15,7 +15,7 @@
1515 * 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)
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
18 * (a new date, then the reason), N moves one flagged "not a business day" to the next business day, l sets how many days before it reminds, @ sets an inspection's or walkthrough's time and place. o or enter opens its deal; n opens a new
18 * (a new date, then the reason), N moves one flagged "not a business day" to the next business day, l sets how many days before it reminds, @ sets an inspection's or walkthrough's time and place, 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
1919 * deal (address, buyer, seller, contract and closing dates, side); d, r and b move
2020 * between views; esc cancels a prompt; q leaves. / finds a deal by address or
2121 * party and narrows dates, week and reminders to it; / then enter clears it. E writes every deal you may read to one JSON file
@@ -92,6 +92,7 @@ import { addresses, refusal, when, wording } from "./activity.ts";
9292 import { listAudit } from "./actions.ts";
9393 import { clashText, clashesText } from "./overlap.ts";
9494 import { owner, thisWeek } from "./week.ts";
95 import { PAPERS_MAX, addPaper, openPapersOf, papersLine, removePaper, stillOpenLine, suggestedPaper, tickPaper } from "./papers.ts";
9596 import { SIDE_CHOICE, SIDE_LABEL, SIDE_REFUSAL, sideTyped } from "./side.ts";
9697
9798 const esc = "\x1b[";
@@ -213,15 +214,52 @@ export function terminal(
213214 prompt = { steps, answers: [], text: "", ...(max === undefined ? {} : { max }), finish };
214215 };
215216
217 /** `i` on a date: its papers in one line, and one command: add NAME, in N, out N, remove N or suggest. */
218 function papers(deal: DealView, date: DeadlineView): void {
219 const now = (): DeadlineView => {
220 const got = getDeal(db, me, { id: deal.id });
221 return (got.ok ? got.value.deadlines.find((d) => d.id === date.id) : undefined) ?? date;
222 };
223 const list = (d: DeadlineView): string =>
224 d.papers.length === 0 ? "no papers yet" : `${papersLine(d.papers)}: ${d.papers.map((p, i) => `${i + 1} ${p.name} (${p.in_at === null ? "open" : "in"})`).join("; ")}`;
225 const hint = suggestedPaper(date.kind, date.papers) === undefined ? "" : ", suggest";
226 ask(deal, [`Papers on ${date.label}, ${list(date)} — add NAME, in N, out N, remove N${hint} (up to ${PAPERS_MAX}; blank only lists)`], ([typed]) => {
227 const [word = "", ...rest] = (typed ?? "").trim().split(/\s+/);
228 const arg = rest.join(" ");
229 const current = now();
230 const nth = current.papers[Number(arg) - 1];
231 const verb = word.toLowerCase();
232 if (verb === "") return `${date.label}: ${list(current)}.`;
233 if (verb === "add" || verb === "suggest") {
234 const name = verb === "add" ? arg : suggestedPaper(current.kind, current.papers);
235 if (name === undefined) return "No suggestion for this date, or it is already on the list.";
236 return report(addPaper(db, me, { deadlineId: date.id, name }), `Added ${name} to ${date.label}.`);
237 }
238 if (verb === "in" || verb === "out" || verb === "remove") {
239 if (nth === undefined) return `No paper ${arg === "" ? "number given" : arg}: ${list(current)}.`;
240 if (verb === "remove") return report(removePaper(db, me, { id: nth.id }), `Removed ${nth.name}.`);
241 return report(tickPaper(db, me, { id: nth.id, in: verb === "in" }), verb === "in" ? `${nth.name} is in.` : `${nth.name} is not in yet.`);
242 }
243 return "Type add NAME, in N, out N, remove N or suggest.";
244 });
245 }
246
216247 function onDate(key: string): boolean {
217248 const row = dateRows()[cursor];
218 if (!["x", "u", "W", "m", "N", "l", "T", "@", "o", "\r"].includes(key)) return false;
249 if (!["x", "u", "W", "m", "N", "l", "T", "@", "i", "o", "\r"].includes(key)) return false;
219250 if (row === undefined) {
220251 said = "No date here.";
221252 return true;
222253 }
223254 const { deal, date } = row;
224 if (key === "x") said = report(markDone(db, me, { id: date.id, done: true }), `Done: ${date.label}, ${deal.address}.`);
255 if (key === "x") {
256 const owed = stillOpenLine(openPapersOf(db, date.id));
257 const finish = (): string => report(markDone(db, me, { id: date.id, done: true }), `Done: ${date.label}, ${deal.address}.`);
258 // Papers still open ask once, as the page's "Mark done anyway" does.
259 if (owed === "") said = finish();
260 else ask(deal, [`${owed} Mark done anyway? Type yes`], ([yes]) => (yes?.toLowerCase() === "yes" ? finish() : `Not marked done: ${owed}`));
261 }
262 if (key === "i") papers(deal, date);
225263 if (key === "u")
226264 said =
227265 date.waived_at === null
@@ -776,8 +814,9 @@ export function terminal(
776814 const lead = isOpen(date) ? dim(` reminds ${date.lead_days}d before`) : "";
777815 const note = date.note === "" ? "" : dim(` note: ${date.note}`);
778816 const where = appointmentLine(date) === "" ? "" : dim(` ${appointmentLine(date)}`);
817 const paper = date.papers.length === 0 ? "" : dim(` ${papersLine(date.papers)}`);
779818 const clash = date.clashes.length === 0 ? "" : tint("overdue", ` ${clashesText(date.clashes)}`);
780 return `${i === cursor ? "▸" : " "} ${when} ${i === cursor ? bold(text) : text}${where}${clash}${flag}${lead}${moved}${note}`;
819 return `${i === cursor ? "▸" : " "} ${when} ${i === cursor ? bold(text) : text}${where}${paper}${clash}${flag}${lead}${moved}${note}`;
781820 };
782821
783822 function screen(): string[] {
src/timeline.ts +3 −1
@@ -41,12 +41,14 @@ export interface TimelineRow {
4141 readonly urgency: Urgency;
4242 /** What the client does for it: lines for an open date only, each labelled on a deal for both sides. */
4343 readonly todo: Guidance[];
44 /** The papers a client still owes for it, and the ones in: every paper of the date, in order, with its state. */
45 readonly papers: readonly { readonly name: string; readonly in: boolean }[];
4446 }
4547
4648 export function timelineRows(deal: DealView, on: string): TimelineRow[] {
4749 return deal.deadlines.filter((d) => !isInternal(d)).map((d) => {
4850 const u = urgencyOf(d, on);
4951 const { name, role } = owner(deal, d);
50 return { label: d.label, due: d.due, spoken: spoken(d.due), where: appointmentLine(d), role, name, status: STATUS[u].word, doneLine: u === "done" && d.done_on !== null ? doneLine(d.due, d.done_on) : "", mark: STATUS[u].mark, urgency: u, todo: guidance(d.kind, deal.side, u !== "done" && u !== "waived") };
52 return { label: d.label, due: d.due, spoken: spoken(d.due), where: appointmentLine(d), role, name, status: STATUS[u].word, doneLine: u === "done" && d.done_on !== null ? doneLine(d.due, d.done_on) : "", mark: STATUS[u].mark, urgency: u, todo: guidance(d.kind, deal.side, u !== "done" && u !== "waived"), papers: d.papers.map((p) => ({ name: p.name, in: p.in_at !== null })) };
5153 });
5254 }
static/app.css +27
@@ -570,6 +570,26 @@ footer code {
570570 font-size: var(--text-sm);
571571 }
572572
573 /* Paperwork checklist on a date. */
574 .papers-list {
575 list-style: none;
576 margin: 0 0 var(--space-3);
577 padding: 0;
578 }
579 .paper-row {
580 display: flex;
581 flex-wrap: wrap;
582 align-items: center;
583 gap: var(--space-2);
584 padding: var(--space-1) 0;
585 }
586 .paper-row > span {
587 flex: 1 1 12rem;
588 }
589 .paper-row form {
590 margin: 0;
591 }
592
573593 /* Reminder drafts. */
574594 .drafts {
575595 list-style: none;
@@ -872,6 +892,13 @@ a.week-row {
872892 .timeline-status {
873893 font-weight: 650;
874894 }
895 .timeline-papers ul {
896 margin: 0;
897 padding-left: var(--space-4);
898 }
899 .timeline-row .timeline-papers {
900 grid-column: 1 / -1;
901 }
875902 .timeline-row .timeline-todo {
876903 grid-column: 1 / -1;
877904 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs